@dickpy/dsh-imagegen 1.5.5 → 1.5.6

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.
@@ -10,15 +10,15 @@
10
10
 
11
11
  import { promises as fs } from 'node:fs'
12
12
  import { createHash } from 'node:crypto'
13
- import { homedir } from 'node:os'
14
13
  import path from 'node:path'
15
14
  import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
16
15
  import { notifyImageSaved } from './storage-sync.ts'
16
+ import { imageDataRoot } from './image-storage-path.ts'
17
17
 
18
- const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
19
- const GALLERY_DIR = path.join(HISTORY_DIR, 'gallery')
20
- const INDEX_PATH = path.join(GALLERY_DIR, 'index.json')
21
- const IMAGES_DIR = path.join(GALLERY_DIR, 'images')
18
+ function historyDir(): string { return imageDataRoot() }
19
+ function galleryDir(): string { return path.join(imageDataRoot(), 'gallery') }
20
+ function indexPath(): string { return path.join(galleryDir(), 'index.json') }
21
+ function imagesDir(): string { return path.join(galleryDir(), 'images') }
22
22
 
23
23
  /** One gallery entry carries the same wire shape as a history entry. */
24
24
  export interface GalleryAppendResult {
@@ -114,13 +114,13 @@ function fingerprint(input: HistoryEntryInput): string | undefined {
114
114
 
115
115
  /** Ensure the storage directories exist. */
116
116
  async function ensureDirs(): Promise<void> {
117
- await fs.mkdir(IMAGES_DIR, { recursive: true })
117
+ await fs.mkdir(imagesDir(), { recursive: true })
118
118
  }
119
119
 
120
120
  /** Read the index, tolerating a missing/corrupt file. */
121
121
  async function readIndex(): Promise<StoredEntry[]> {
122
122
  try {
123
- const raw = await fs.readFile(INDEX_PATH, 'utf8')
123
+ const raw = await fs.readFile(indexPath(), 'utf8')
124
124
  const parsed: unknown = JSON.parse(raw)
125
125
  if (parsed === null || typeof parsed !== 'object') return []
126
126
  const entries = (parsed as { entries?: unknown }).entries
@@ -135,9 +135,9 @@ async function readIndex(): Promise<StoredEntry[]> {
135
135
  async function writeIndex(entries: StoredEntry[]): Promise<void> {
136
136
  await ensureDirs()
137
137
  const payload: IndexFile = { entries }
138
- const tmp = `${INDEX_PATH}.tmp-${process.pid}`
138
+ const tmp = `${indexPath()}.tmp-${process.pid}`
139
139
  await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
140
- await fs.rename(tmp, INDEX_PATH)
140
+ await fs.rename(tmp, indexPath())
141
141
  }
142
142
 
143
143
  /** Structural guard for a stored entry. */
@@ -164,7 +164,7 @@ function isStoredEntry(value: unknown): value is StoredEntry {
164
164
  /** Remove one entry's image files (best effort). */
165
165
  async function removeEntryFiles(entry: StoredEntry): Promise<void> {
166
166
  for (const image of entry.images) {
167
- try { await fs.rm(path.join(IMAGES_DIR, image.file), { force: true }) } catch { /* ignore */ }
167
+ try { await fs.rm(path.join(imagesDir(), image.file), { force: true }) } catch { /* ignore */ }
168
168
  }
169
169
  }
170
170
 
@@ -223,8 +223,8 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
223
223
  for (let index = 0; index < input.images.length; index++) {
224
224
  const image = input.images[index]!
225
225
  const file = `${prefix}-${index}.${extensionOf(image.mime)}`
226
- await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
227
- notifyImageSaved('gallery', path.join(IMAGES_DIR, file))
226
+ await fs.writeFile(path.join(imagesDir(), file), Buffer.from(image.b64, 'base64'))
227
+ notifyImageSaved('gallery', path.join(imagesDir(), file))
228
228
  storedImages.push({
229
229
  file,
230
230
  mime: image.mime,
@@ -303,7 +303,7 @@ export async function readGalleryImage(file: string): Promise<{ data: Buffer; mi
303
303
  // store writes — so the route can never escape the images directory.
304
304
  if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
305
305
  try {
306
- const data = await fs.readFile(path.join(IMAGES_DIR, file))
306
+ const data = await fs.readFile(path.join(imagesDir(), file))
307
307
  return { data, mime: mimeOfFile(file) }
308
308
  } catch {
309
309
  return undefined
@@ -38,8 +38,8 @@ export class ImageGenerationRuntime {
38
38
  private readonly resolve: () => ChannelsView,
39
39
  private readonly history: HistorySink = { append: appendHistory },
40
40
  ) {
41
- // A comparison can contain up to four models; let those tasks run at the
42
- // same time while still applying a small host-wide concurrency limit.
41
+ // Every task runs in parallel up to this small host-wide limit; a
42
+ // four-model comparison fits within it in a single wave.
43
43
  this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4)
44
44
  }
45
45
 
@@ -9,14 +9,14 @@
9
9
  */
10
10
 
11
11
  import { promises as fs } from 'node:fs'
12
- import { homedir } from 'node:os'
13
12
  import path from 'node:path'
14
13
  import { HISTORY_MAX, type GenerateMode, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
15
14
  import { notifyImageSaved } from './storage-sync.ts'
15
+ import { imageDataRoot } from './image-storage-path.ts'
16
16
 
17
- const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
18
- const INDEX_PATH = path.join(HISTORY_DIR, 'index.json')
19
- const IMAGES_DIR = path.join(HISTORY_DIR, 'images')
17
+ function historyDir(): string { return imageDataRoot() }
18
+ function indexPath(): string { return path.join(historyDir(), 'index.json') }
19
+ function imagesDir(): string { return path.join(historyDir(), 'images') }
20
20
 
21
21
  // History mutations read and replace one shared index. Serialize them so
22
22
  // overlapping requests cannot each read an old index and lose the other's row.
@@ -95,13 +95,13 @@ function safeId(id: string): string {
95
95
 
96
96
  /** Ensure the storage directories exist. */
97
97
  async function ensureDirs(): Promise<void> {
98
- await fs.mkdir(IMAGES_DIR, { recursive: true })
98
+ await fs.mkdir(imagesDir(), { recursive: true })
99
99
  }
100
100
 
101
101
  /** Read the index, tolerating a missing/corrupt file. */
102
102
  async function readIndex(): Promise<StoredEntry[]> {
103
103
  try {
104
- const raw = await fs.readFile(INDEX_PATH, 'utf8')
104
+ const raw = await fs.readFile(indexPath(), 'utf8')
105
105
  const parsed: unknown = JSON.parse(raw)
106
106
  if (parsed === null || typeof parsed !== 'object') return []
107
107
  const entries = (parsed as { entries?: unknown }).entries
@@ -116,9 +116,9 @@ async function readIndex(): Promise<StoredEntry[]> {
116
116
  async function writeIndex(entries: StoredEntry[]): Promise<void> {
117
117
  await ensureDirs()
118
118
  const payload: IndexFile = { entries }
119
- const tmp = `${INDEX_PATH}.tmp-${process.pid}`
119
+ const tmp = `${indexPath()}.tmp-${process.pid}`
120
120
  await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
121
- await fs.rename(tmp, INDEX_PATH)
121
+ await fs.rename(tmp, indexPath())
122
122
  }
123
123
 
124
124
  /** Structural guard for a stored entry. */
@@ -145,7 +145,7 @@ function isStoredEntry(value: unknown): value is StoredEntry {
145
145
  /** Remove one entry's image files (best effort). */
146
146
  async function removeEntryFiles(entry: StoredEntry): Promise<void> {
147
147
  for (const image of entry.images) {
148
- try { await fs.rm(path.join(IMAGES_DIR, image.file), { force: true }) } catch { /* ignore */ }
148
+ try { await fs.rm(path.join(imagesDir(), image.file), { force: true }) } catch { /* ignore */ }
149
149
  }
150
150
  }
151
151
 
@@ -196,8 +196,8 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
196
196
  for (let index = 0; index < input.images.length; index++) {
197
197
  const image = input.images[index]!
198
198
  const file = `${prefix}-${index}.${extensionOf(image.mime)}`
199
- await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
200
- notifyImageSaved('history', path.join(IMAGES_DIR, file))
199
+ await fs.writeFile(path.join(imagesDir(), file), Buffer.from(image.b64, 'base64'))
200
+ notifyImageSaved('history', path.join(imagesDir(), file))
201
201
  storedImages.push({
202
202
  file,
203
203
  mime: image.mime,
@@ -267,7 +267,7 @@ export async function readHistoryImage(file: string): Promise<{ data: Buffer; mi
267
267
  // store writes — so the route can never escape the images directory.
268
268
  if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
269
269
  try {
270
- const data = await fs.readFile(path.join(IMAGES_DIR, file))
270
+ const data = await fs.readFile(path.join(imagesDir(), file))
271
271
  return { data, mime: mimeOfFile(file) }
272
272
  } catch {
273
273
  return undefined
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ const DEFAULT_ROOT = path.join(process.env.DSH_HOME?.trim() || path.join(homedir(), '.dsh'), 'dsh-imagegen')
5
+ let root = DEFAULT_ROOT
6
+
7
+ export function imageDataRoot(): string { return root }
8
+
9
+ export function setImageDataRoot(value: string | undefined): void {
10
+ const trimmed = value?.trim()
11
+ root = trimmed === undefined || trimmed === '' ? DEFAULT_ROOT : path.resolve(trimmed)
12
+ }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ function mimeOfPath(filePath: string): string {
37
37
  import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
38
38
  import { registerAgentImageTools } from './agent-image-tools.ts'
39
39
  import { registerEditImageCommand } from './edit-image-command.ts'
40
+ import { setImageDataRoot } from './image-storage-path.ts'
40
41
  import { presetById } from './presets.ts'
41
42
 
42
43
  /** Stable cordis plugin name. */
@@ -89,6 +90,8 @@ export interface Config {
89
90
  promptApiKey?: string
90
91
  /** Chat model used to expand short image prompts. */
91
92
  promptModel?: string
93
+ /** Local root for generated/history/gallery/canvas images. Empty keeps the default under DSH_HOME. */
94
+ localStoragePath?: string
92
95
  /** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
93
96
  storageEnabled?: boolean
94
97
  /** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
@@ -133,6 +136,7 @@ export const Config: z<Config> = z.object({
133
136
  promptApiUrl: z.string().default(''),
134
137
  promptApiKey: z.string().role('secret').default(''),
135
138
  promptModel: z.string().default(''),
139
+ localStoragePath: z.string().default(''),
136
140
  storageEnabled: z.boolean().default(false),
137
141
  storageEndpoint: z.string().default(''),
138
142
  storageRegion: z.string().default(''),
@@ -227,6 +231,7 @@ export function apply(ctx: Context, config?: Config): (() => void) | void {
227
231
  let current: () => Config = () => config ?? {}
228
232
  const resolve = (): EffectiveConfig => {
229
233
  const value = current() ?? {}
234
+ setImageDataRoot(value.localStoragePath)
230
235
  let channels = normalizeChannels(value.channels)
231
236
  // Settings scopes are deep-frozen by the host. Legacy migration adds the
232
237
  // synthesized default-channel secret, so always work on a detached copy.
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.5'
11
+ export const PLUGIN_VERSION = '1.5.6'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -324,7 +324,9 @@ export interface CanvasDocument {
324
324
  title: string
325
325
  revision: number
326
326
  viewport: CanvasViewport
327
- background: 'dots' | 'lines' | 'blank'
327
+ background: 'dots' | 'lines' | 'diagonal' | 'checker' | 'blank' | 'image'
328
+ /** Custom background image URL (a canvas asset) when background is 'image'. */
329
+ backgroundImage?: string
328
330
  nodes: CanvasNode[]
329
331
  connections: CanvasConnection[]
330
332
  createdAt: number
@@ -413,6 +415,10 @@ export interface GenerateRequest extends EcommerceTaskMeta {
413
415
  detail: string
414
416
  /** Reference image as a data URL (edit mode only). */
415
417
  image?: string
418
+ /** Additional reference images as data URLs (edit mode only). The first
419
+ * image stays in `image`; providers that accept several references get them
420
+ * all, single-reference providers see `image` alone. */
421
+ images?: string[]
416
422
  /** Original reference-image name, retained in the history entry. */
417
423
  refName?: string
418
424
  /** Channel this request targets (the host falls back to the default when
package/src/routes.ts CHANGED
@@ -187,6 +187,9 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
187
187
  n: typeof body.n === 'number' ? body.n : 1,
188
188
  detail: typeof body.detail === 'string' ? body.detail : '',
189
189
  ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
190
+ ...Array.isArray(body.images)
191
+ ? { images: body.images.filter((item): item is string => typeof item === 'string' && item !== '').slice(0, 4) }
192
+ : {},
190
193
  ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
191
194
  ...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
192
195
  ...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
package/src/task-queue.ts CHANGED
@@ -10,7 +10,6 @@ export class GenerationTaskQueue {
10
10
  private readonly controllers = new Map<string, AbortController>()
11
11
  private readonly listeners = new Set<GenerationTaskListener>()
12
12
  private running = 0
13
- private serialRunning = false
14
13
 
15
14
  constructor(
16
15
  private readonly run: (request: GenerateRequest, signal: AbortSignal) => Promise<GenerateResult>,
@@ -51,16 +50,16 @@ export class GenerationTaskQueue {
51
50
  return previous === undefined ? undefined : this.submit(previous.request)
52
51
  }
53
52
 
53
+ /** Start queued tasks while capacity remains. Plain submissions and
54
+ * comparison batches alike run in parallel up to the host-wide limit, so one
55
+ * slow upstream can no longer hold back unrelated generations. */
54
56
  private drain(): void {
55
57
  while (this.running < Math.max(1, this.concurrency)) {
56
- const task = this.tasks.find(item => item.status === 'queued'
57
- && (this.running === 0 || (item.request.comparisonId !== undefined && !this.serialRunning)))
58
+ const task = this.tasks.find(item => item.status === 'queued')
58
59
  if (task === undefined) return
59
60
  this.running += 1
60
- if (task.request.comparisonId === undefined) this.serialRunning = true
61
61
  void this.runTask(task).finally(() => {
62
62
  this.running -= 1
63
- if (task.request.comparisonId === undefined) this.serialRunning = false
64
63
  this.drain()
65
64
  })
66
65
  }