@dickpy/dsh-imagegen 1.5.4 → 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.
package/src/engine.ts CHANGED
@@ -184,8 +184,14 @@ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { si
184
184
  }
185
185
  }
186
186
 
187
- /** Content-type extension hints for URL-fetched images. */
188
- function mimeOfExtension(path: string): string | undefined {
187
+ /** Whether an error was produced by a requestSignal budget timeout. These can
188
+ * surface from the fetch call itself or from reading the response body, so the
189
+ * budget must stay armed until the body has been consumed. */
190
+ function isBudgetTimeout(error: unknown): boolean {
191
+ return (error instanceof DOMException || error instanceof Error) && error.name === 'TimeoutError'
192
+ }
193
+
194
+ /** Content-type extension hints for URL-fetched images. */function mimeOfExtension(path: string): string | undefined {
189
195
  const match = /\.([a-z0-9]+)$/i.exec(path)
190
196
  if (match === null) return undefined
191
197
  switch (match[1]!.toLowerCase()) {
@@ -358,29 +364,32 @@ async function normalizeItem(
358
364
  return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
359
365
  }
360
366
  const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
361
- let response: Response
362
367
  try {
363
- response = await fetch(url, {
364
- ...isPresignedUrl(url) || upstream.apiKey === ''
365
- ? {}
366
- : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
367
- signal: budget.signal,
368
- })
369
- } catch (error) {
370
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
368
+ let response: Response
369
+ try {
370
+ response = await fetch(url, {
371
+ ...isPresignedUrl(url) || upstream.apiKey === ''
372
+ ? {}
373
+ : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
374
+ signal: budget.signal,
375
+ })
376
+ } catch (error) {
377
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
378
+ }
379
+ if (!response.ok) {
380
+ throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
381
+ }
382
+ // Budget stays armed through the body read so a stalled download cannot hang the task.
383
+ const buffer = Buffer.from(await response.arrayBuffer())
384
+ const contentType = response.headers.get('content-type')
385
+ const mime = detectImageMime(buffer)
386
+ ?? (contentType !== null && contentType !== ''
387
+ ? contentType.split(';')[0]!.trim()
388
+ : mimeOfExtension(url) ?? 'image/png')
389
+ return { b64: buffer.toString('base64'), mime, revisedPrompt }
371
390
  } finally {
372
391
  budget.dispose()
373
392
  }
374
- if (!response.ok) {
375
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
376
- }
377
- const buffer = Buffer.from(await response.arrayBuffer())
378
- const contentType = response.headers.get('content-type')
379
- const mime = detectImageMime(buffer)
380
- ?? (contentType !== null && contentType !== ''
381
- ? contentType.split(';')[0]!.trim()
382
- : mimeOfExtension(url) ?? 'image/png')
383
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
384
393
  }
385
394
 
386
395
  /** Expand a provider image item whose URL may be a string or an array. */
@@ -477,52 +486,54 @@ async function pollAsyncTask(
477
486
  while (Date.now() < deadline) {
478
487
  const remaining = deadline - Date.now()
479
488
  const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
480
- let response: Response
481
489
  try {
482
- response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
483
- method: 'GET',
484
- headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
485
- signal: budget.signal,
486
- })
487
- } catch (error) {
488
- if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
489
- const message = error instanceof Error ? error.message : String(error)
490
- if (/timeout|abort/i.test(message)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
491
- throw new ImageGenError(`无法轮询上游异步任务:${message}`, 'upstream-unreachable')
490
+ let response: Response
491
+ try {
492
+ response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
493
+ method: 'GET',
494
+ headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
495
+ signal: budget.signal,
496
+ })
497
+ } catch (error) {
498
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
499
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
500
+ throw new ImageGenError(`无法轮询上游异步任务:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
501
+ }
502
+ let payload: unknown
503
+ try {
504
+ payload = await response.json()
505
+ } catch (error) {
506
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
507
+ throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
508
+ }
509
+ if (!response.ok || payload === null || typeof payload !== 'object') {
510
+ throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
511
+ }
512
+ const record = payload as Record<string, unknown>
513
+ const data = record.data
514
+ const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
515
+ const statusValue = statusRecord !== null && typeof statusRecord === 'object'
516
+ ? (statusRecord as Record<string, unknown>).status
517
+ : undefined
518
+ const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
519
+ if (ASYNC_FAILED_STATUSES.has(status)) {
520
+ throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
521
+ }
522
+ const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
523
+ const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
524
+ const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
525
+ const images = resultRecord?.images ?? (nested.images ?? record.images)
526
+ if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
527
+ const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
528
+ if (items.length > 0) return items
529
+ if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
530
+ }
531
+ if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
532
+ throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
533
+ }
492
534
  } finally {
493
535
  budget.dispose()
494
536
  }
495
- let payload: unknown
496
- try {
497
- payload = await response.json()
498
- } catch {
499
- throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
500
- }
501
- if (!response.ok || payload === null || typeof payload !== 'object') {
502
- throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
503
- }
504
- const record = payload as Record<string, unknown>
505
- const data = record.data
506
- const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
507
- const statusValue = statusRecord !== null && typeof statusRecord === 'object'
508
- ? (statusRecord as Record<string, unknown>).status
509
- : undefined
510
- const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
511
- if (ASYNC_FAILED_STATUSES.has(status)) {
512
- throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
513
- }
514
- const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
515
- const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
516
- const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
517
- const images = resultRecord?.images ?? (nested.images ?? record.images)
518
- if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
519
- const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
520
- if (items.length > 0) return items
521
- if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
522
- }
523
- if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
524
- throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
525
- }
526
537
  await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
527
538
  delay = Math.min(5000, delay * 2)
528
539
  }
@@ -548,17 +559,25 @@ async function requestOneImage(
548
559
  if (typeof request.image !== 'string' || request.image === '') {
549
560
  throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
550
561
  }
551
- const parsed = parseDataUrl(request.image)
552
- if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
553
- let bytes: Buffer
554
- try {
555
- bytes = Buffer.from(parsed.base64, 'base64')
556
- } catch {
557
- throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
558
- }
559
- if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
560
- throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
562
+ const decodeReference = (dataUrl: string): { bytes: Buffer; mime: string; filename: string } => {
563
+ const parsed = parseDataUrl(dataUrl)
564
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
565
+ let bytes: Buffer
566
+ try {
567
+ bytes = Buffer.from(parsed.base64, 'base64')
568
+ } catch {
569
+ throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
570
+ }
571
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
572
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
573
+ }
574
+ return { bytes, mime: parsed.mime, filename: `reference.${extensionOf(parsed.mime)}` }
561
575
  }
576
+ const primary = decodeReference(request.image)
577
+ const extras = (request.images ?? [])
578
+ .filter(img => typeof img === 'string' && img !== '')
579
+ .slice(0, 4)
580
+ .map(decodeReference)
562
581
  // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
563
582
  // URI is accepted) instead of OpenAI's multipart form-data upload.
564
583
  if (isGrokImagine(params.model)) {
@@ -574,7 +593,7 @@ async function requestOneImage(
574
593
  // Nano Banana OpenAI-compatible gateways accept the standard multipart
575
594
  // edit upload, with the family's own aspect_ratio / image_size knobs.
576
595
  const form = new FormData()
577
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
596
+ form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
578
597
  form.append('prompt', request.prompt)
579
598
  form.append('model', params.model)
580
599
  if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
@@ -582,19 +601,28 @@ async function requestOneImage(
582
601
  body = form
583
602
  } else if (isSeedream(params.model)) {
584
603
  // Seedream unifies generation and editing on /images/generations; the
585
- // reference image is a JSON URL / data-URL array, never multipart.
604
+ // reference image is a JSON URL / data-URL array, never multipart, and
605
+ // the protocol natively accepts several references.
586
606
  headers['content-type'] = 'application/json'
587
607
  body = JSON.stringify({
588
608
  model: params.model,
589
609
  prompt: request.prompt,
590
- image: [request.image],
610
+ image: [request.image, ...(request.images ?? []).filter(img => typeof img === 'string' && img !== '').slice(0, 4)],
591
611
  ...params.size !== undefined ? { size: params.size } : {},
592
612
  ...params.resolution !== undefined ? { resolution: params.resolution } : {},
593
613
  response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
594
614
  })
595
615
  } else {
596
616
  const form = new FormData()
597
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
617
+ if (extras.length > 0) {
618
+ // OpenAI-style multi-reference upload: repeat the image[] field so
619
+ // every connected canvas reference reaches the gateway.
620
+ for (const [index, reference] of [primary, ...extras].entries()) {
621
+ form.append('image[]', new Blob([reference.bytes], { type: reference.mime }), `reference-${index}.${extensionOf(reference.mime)}`)
622
+ }
623
+ } else {
624
+ form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
625
+ }
598
626
  form.append('prompt', request.prompt)
599
627
  form.append('model', params.model)
600
628
  if (params.size !== undefined) form.append('size', params.size)
@@ -608,53 +636,57 @@ async function requestOneImage(
608
636
  }
609
637
 
610
638
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
611
- let response: Response
612
639
  try {
613
- // Seedream has no /images/edits endpoint: both modes hit generations.
614
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
615
- ? '/images/edits'
616
- : '/images/generations'
617
- response = await fetch(`${baseUrl}${endpoint}`, {
618
- method: 'POST',
619
- headers,
620
- body,
621
- signal: budget.signal,
622
- })
623
- } catch (error) {
624
- const message = error instanceof Error ? error.message : String(error)
625
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
626
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
640
+ let response: Response
641
+ try {
642
+ // Seedream has no /images/edits endpoint: both modes hit generations.
643
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
644
+ ? '/images/edits'
645
+ : '/images/generations'
646
+ response = await fetch(`${baseUrl}${endpoint}`, {
647
+ method: 'POST',
648
+ headers,
649
+ body,
650
+ signal: budget.signal,
651
+ })
652
+ } catch (error) {
653
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
654
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
655
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
627
656
  }
628
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
629
- } finally {
630
- budget.dispose()
631
- }
632
657
 
633
- let payload: unknown
634
- try {
635
- payload = await response.json()
636
- } catch {
637
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
638
- }
639
- if (!response.ok || payload === null || typeof payload !== 'object') {
640
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
641
- }
658
+ let payload: unknown
659
+ try {
660
+ // The budget stays armed through the body read: a gateway that returns
661
+ // headers but never completes the body must not hang the task forever.
662
+ payload = await response.json()
663
+ } catch (error) {
664
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
665
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
666
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
667
+ }
668
+ if (!response.ok || payload === null || typeof payload !== 'object') {
669
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
670
+ }
642
671
 
643
- const record = payload as Record<string, unknown>
644
- const data = dataRecordsOf(record)
645
- if (data === undefined) {
646
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
647
- }
648
- if (data.length === 0) {
649
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
650
- }
651
- const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
652
- if (asyncEntries.length > 0) {
653
- const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
654
- if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
655
- return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
672
+ const record = payload as Record<string, unknown>
673
+ const data = dataRecordsOf(record)
674
+ if (data === undefined) {
675
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
676
+ }
677
+ if (data.length === 0) {
678
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
679
+ }
680
+ const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
681
+ if (asyncEntries.length > 0) {
682
+ const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
683
+ if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
684
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
685
+ }
686
+ return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
687
+ } finally {
688
+ budget.dispose()
656
689
  }
657
- return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
658
690
  }
659
691
 
660
692
  /**
@@ -701,36 +733,36 @@ async function generateQwenImage(
701
733
  }
702
734
 
703
735
  const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
704
- let response: Response
705
736
  try {
706
- response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
707
- method: 'POST',
708
- headers: {
709
- authorization: `Bearer ${upstream.apiKey.trim()}`,
710
- 'content-type': 'application/json',
711
- },
712
- body: JSON.stringify(body),
713
- signal: budget.signal,
714
- })
715
- } catch (error) {
716
- const message = error instanceof Error ? error.message : String(error)
717
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
718
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
737
+ let response: Response
738
+ try {
739
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
740
+ method: 'POST',
741
+ headers: {
742
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
743
+ 'content-type': 'application/json',
744
+ },
745
+ body: JSON.stringify(body),
746
+ signal: budget.signal,
747
+ })
748
+ } catch (error) {
749
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
750
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
751
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
719
752
  }
720
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
721
- } finally {
722
- budget.dispose()
723
- }
724
753
 
725
- let payload: unknown
726
- try {
727
- payload = await response.json()
728
- } catch {
729
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
730
- }
731
- if (!response.ok || payload === null || typeof payload !== 'object') {
732
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
733
- }
754
+ let payload: unknown
755
+ try {
756
+ // Budget stays armed through the body read (same rationale as the OpenAI path).
757
+ payload = await response.json()
758
+ } catch (error) {
759
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
760
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
761
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
762
+ }
763
+ if (!response.ok || payload === null || typeof payload !== 'object') {
764
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
765
+ }
734
766
 
735
767
  // output.choices[].message.content[] mixes text and { image: url } items.
736
768
  const record = payload as Record<string, unknown>
@@ -759,6 +791,9 @@ async function generateQwenImage(
759
791
  return { b64: normalized.b64, mime: normalized.mime }
760
792
  }))
761
793
  return { images }
794
+ } finally {
795
+ budget.dispose()
796
+ }
762
797
  }
763
798
 
764
799
  /**
@@ -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.