@dickpy/dsh-imagegen 1.5.3 → 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.
@@ -488,13 +488,26 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
488
488
  flex: 1;
489
489
  }
490
490
 
491
- .generation {
492
- display: flex;
493
- width: 100%;
494
- gap: 14px;
495
- min-width: 0;
496
- min-height: 0;
497
- }
491
+ .generation {
492
+ display: flex;
493
+ width: 100%;
494
+ gap: 14px;
495
+ min-width: 0;
496
+ min-height: 0;
497
+ }
498
+
499
+ /* The canvas workspace owns the full generation surface. Keep the classic
500
+ config/result DOM mounted so switching tabs remains instantaneous, but hide
501
+ those siblings while the scene graph is active. */
502
+ .generation[data-workspace='canvas'] {
503
+ display: flex;
504
+ height: 100%;
505
+ }
506
+
507
+ .generation[data-workspace='canvas'] > .config,
508
+ .generation[data-workspace='canvas'] > .canvas {
509
+ display: none !important;
510
+ }
498
511
 
499
512
  .config {
500
513
  display: flex;
@@ -846,29 +859,43 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
846
859
 
847
860
  .historyActions {
848
861
  display: flex;
849
- gap: 6px;
862
+ gap: 4px;
850
863
  justify-content: flex-end;
851
864
  }
852
865
 
853
- .historyAction {
854
- padding: 2px 8px;
855
- font: inherit;
856
- font-size: 11.5px;
857
- color: var(--dsw-alias-label-secondary);
866
+ /* Compact icon actions on each history row: chat / gallery / trash. */
867
+ .historyIconAction {
868
+ display: inline-flex;
869
+ align-items: center;
870
+ justify-content: center;
871
+ width: 26px;
872
+ height: 24px;
873
+ padding: 0;
874
+ border: 1px solid transparent;
875
+ border-radius: 7px;
858
876
  background: transparent;
859
- border: 1px solid var(--dsw-alias-border-l2);
860
- border-radius: 999px;
877
+ color: var(--dsw-alias-label-tertiary);
861
878
  cursor: pointer;
862
879
  }
863
880
 
864
- .historyAction:hover {
881
+ .historyIconAction:hover {
865
882
  color: var(--dsw-alias-label-primary);
866
- border-color: var(--dsw-alias-label-dimmed);
883
+ background: var(--dsw-alias-interactive-bg-hover);
867
884
  }
868
885
 
869
- .historyAction[data-danger]:hover {
870
- color: var(--dsw-alias-label-error);
871
- border-color: var(--dsw-alias-label-error);
886
+ .historyIconAction:focus-visible {
887
+ outline: 2px solid var(--dsw-alias-brand-primary);
888
+ outline-offset: 1px;
889
+ }
890
+
891
+ .historyIconAction:disabled {
892
+ opacity: 0.5;
893
+ cursor: default;
894
+ }
895
+
896
+ .historyIconAction[data-danger]:hover {
897
+ color: var(--dsw-alias-state-error);
898
+ background: color-mix(in srgb, var(--dsw-alias-state-error) 10%, transparent);
872
899
  }
873
900
 
874
901
  /* --- cards (system-aligned rounded group chrome) ------------------------------ */
@@ -887,30 +914,36 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
887
914
  .ecommerceWorkspace {
888
915
  display: flex;
889
916
  flex-direction: column;
890
- gap: 16px;
891
- max-width: 720px;
917
+ gap: 18px;
918
+ max-width: 760px;
892
919
  margin: 0 auto;
893
- padding: 6px 2px 12px;
920
+ padding: 10px 8px 18px;
894
921
  }
895
922
 
896
923
  /* Flat sections in the CapyAI style: plain headings, no card boxes. */
897
924
  .ecommerceSection {
898
925
  display: flex;
899
926
  flex-direction: column;
900
- gap: 8px;
901
- padding: 0;
927
+ gap: 10px;
928
+ padding: 0 0 16px;
902
929
  border: none;
930
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
903
931
  border-radius: 0;
904
932
  background: transparent;
905
933
  }
906
934
 
935
+ .ecommerceSection:last-of-type {
936
+ padding-bottom: 4px;
937
+ border-bottom: none;
938
+ }
939
+
907
940
  .ecommerceSection h3 {
908
- margin: 0;
909
- font-size: 13.5px;
910
- font-weight: 600;
941
+ margin: 0 0 2px;
942
+ font-size: 14px;
943
+ font-weight: 650;
911
944
  display: flex;
912
945
  align-items: baseline;
913
- gap: 6px;
946
+ gap: 7px;
914
947
  }
915
948
 
916
949
  .ecommerceSectionHint {
@@ -919,13 +952,14 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
919
952
  color: var(--dsw-alias-label-tertiary);
920
953
  }
921
954
 
922
- .ecommerceSection input:not([type="checkbox"]), .ecommerceSection select, .ecommerceSection textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; font: inherit; }
923
- .ecommerceSection textarea { min-height: 60px; resize: vertical; }
955
+ .ecommerceSection input:not([type="checkbox"]), .ecommerceSection select, .ecommerceSection textarea { box-sizing: border-box; width: 100%; padding: 9px 11px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; font: inherit; }
956
+ .ecommerceSection input:not([type="checkbox"]):focus-visible, .ecommerceSection select:focus-visible, .ecommerceSection textarea:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
957
+ .ecommerceSection textarea { min-height: 68px; resize: vertical; line-height: 1.5; }
924
958
 
925
959
  .ecommerceField {
926
960
  display: flex;
927
961
  flex-direction: column;
928
- gap: 4px;
962
+ gap: 5px;
929
963
  min-width: 0;
930
964
  }
931
965
 
@@ -937,7 +971,7 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
937
971
  .ecommerceParamGrid {
938
972
  display: grid;
939
973
  grid-template-columns: 1fr 1fr;
940
- gap: 10px 8px;
974
+ gap: 12px 10px;
941
975
  }
942
976
 
943
977
  .ecommerceUploadHero {
@@ -963,6 +997,11 @@ html[data-dsh-imagegen-active] .sidebarHistoryHost {
963
997
  background: var(--dsw-alias-interactive-bg-hover);
964
998
  }
965
999
 
1000
+ .ecommerceUploadHero:focus-visible, .ecommerceAssetAdd:focus-visible, .ecommerceSlotCard:focus-visible, .ecommerceAdvancedToggle:focus-visible, .ecommerceAsset button:focus-visible {
1001
+ outline: 2px solid var(--dsw-alias-brand-primary);
1002
+ outline-offset: 2px;
1003
+ }
1004
+
966
1005
  .ecommerceUploadHero span {
967
1006
  font-size: 12px;
968
1007
  }
@@ -2598,6 +2637,17 @@ html[data-dsh-imagegen-chat-collapsed] [data-dsh-imagegen-chat-resizer] {
2598
2637
  .galleryRemove { flex: none; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; color: var(--dsw-alias-label-tertiary); background: transparent; cursor: pointer; font-size: 18px; }
2599
2638
  .galleryRemove:hover { color: var(--dsw-alias-state-error); background: var(--dsw-alias-bg-layer-2); }
2600
2639
 
2640
+ @media (max-width: 760px) {
2641
+ .ecommerceParamGrid { grid-template-columns: 1fr; }
2642
+ .ecommerceStructureGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2643
+ .ecommerceAssets { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2644
+ }
2645
+
2646
+ @media (max-width: 420px) {
2647
+ .ecommerceWorkspace { padding-inline: 2px; }
2648
+ .ecommerceStructureGrid, .ecommerceAssets { grid-template-columns: 1fr; }
2649
+ }
2650
+
2601
2651
  @media (max-width: 1100px) {
2602
2652
  .galleryMasonry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2603
2653
  }
package/src/engine.ts CHANGED
@@ -339,6 +339,7 @@ function effectiveCount(request: GenerateRequest): number {
339
339
  async function normalizeItem(
340
340
  item: Record<string, unknown>,
341
341
  upstream: UpstreamConfig,
342
+ signal?: AbortSignal,
342
343
  ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
343
344
  const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
344
345
  if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
@@ -356,7 +357,7 @@ async function normalizeItem(
356
357
  if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
357
358
  return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
358
359
  }
359
- const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
360
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
360
361
  let response: Response
361
362
  try {
362
363
  response = await fetch(url, {
@@ -382,6 +383,152 @@ async function normalizeItem(
382
383
  return { b64: buffer.toString('base64'), mime, revisedPrompt }
383
384
  }
384
385
 
386
+ /** Expand a provider image item whose URL may be a string or an array. */
387
+ function imageItemsOf(value: unknown): Array<Record<string, unknown>> {
388
+ if (value === null || typeof value !== 'object') return []
389
+ const item = value as Record<string, unknown>
390
+ if (Array.isArray(item.url)) {
391
+ return item.url.filter((url): url is string => typeof url === 'string' && url !== '').map(url => ({ ...item, url }))
392
+ }
393
+ return [item]
394
+ }
395
+
396
+ /** Return the data records from the response shapes shared by sync gateways. */
397
+ function dataRecordsOf(payload: Record<string, unknown>): Array<Record<string, unknown>> | undefined {
398
+ const data = Array.isArray(payload.data)
399
+ ? payload.data
400
+ : payload.data !== null && typeof payload.data === 'object'
401
+ ? [payload.data]
402
+ : Array.isArray(payload.images)
403
+ ? payload.images
404
+ : Array.isArray(payload.output)
405
+ ? payload.output
406
+ : undefined
407
+ if (data === undefined) return undefined
408
+ return data.filter((entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object')
409
+ }
410
+
411
+ const ASYNC_PENDING_STATUSES = new Set(['submitted', 'pending', 'processing', 'running', 'in_progress', 'queued'])
412
+ const ASYNC_COMPLETED_STATUSES = new Set(['completed', 'succeeded', 'success', 'done'])
413
+ const ASYNC_FAILED_STATUSES = new Set(['failed', 'failure', 'cancelled', 'canceled', 'error'])
414
+ const ASYNC_POLL_MAX_MS = 240_000
415
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 30_000
416
+
417
+ /** Read a provider error message from the common nested locations. */
418
+ function asyncErrorMessage(payload: unknown, fallback: string): string {
419
+ if (payload !== null && typeof payload === 'object') {
420
+ const record = payload as Record<string, unknown>
421
+ const candidates: unknown[] = [record.message, record.error]
422
+ const data = record.data
423
+ const entries = Array.isArray(data) ? data : [data]
424
+ for (const entry of entries) {
425
+ if (entry === null || typeof entry !== 'object') continue
426
+ const item = entry as Record<string, unknown>
427
+ candidates.push(item.message, item.error)
428
+ const nested = item.error
429
+ if (nested !== null && typeof nested === 'object') candidates.push((nested as Record<string, unknown>).message)
430
+ }
431
+ for (const candidate of candidates) {
432
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
433
+ if (candidate !== null && typeof candidate === 'object') {
434
+ const message = (candidate as Record<string, unknown>).message
435
+ if (typeof message === 'string' && message.trim() !== '') return message
436
+ }
437
+ }
438
+ }
439
+ return fallback
440
+ }
441
+
442
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
443
+ function waitForPoll(ms: number, signal?: AbortSignal): Promise<void> {
444
+ return new Promise((resolve, reject) => {
445
+ if (signal?.aborted === true) {
446
+ reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
447
+ return
448
+ }
449
+ const onAbort = () => {
450
+ clearTimeout(timer)
451
+ signal?.removeEventListener('abort', onAbort)
452
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
453
+ }
454
+ const done = () => {
455
+ signal?.removeEventListener('abort', onAbort)
456
+ resolve()
457
+ }
458
+ const timer = setTimeout(done, ms)
459
+ timer.unref()
460
+ signal?.addEventListener('abort', onAbort, { once: true })
461
+ })
462
+ }
463
+
464
+ /**
465
+ * Poll one apib/apimart-style provider task until it yields image records.
466
+ * The total deadline is shared by every poll and the final image downloads;
467
+ * local task cancellation propagates through every request and sleep.
468
+ */
469
+ async function pollAsyncTask(
470
+ baseUrl: string,
471
+ upstream: UpstreamConfig,
472
+ taskId: string,
473
+ signal?: AbortSignal,
474
+ ): Promise<Array<Record<string, unknown>>> {
475
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS
476
+ let delay = 1000
477
+ while (Date.now() < deadline) {
478
+ const remaining = deadline - Date.now()
479
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
480
+ let response: Response
481
+ 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')
492
+ } finally {
493
+ budget.dispose()
494
+ }
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
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
527
+ delay = Math.min(5000, delay * 2)
528
+ }
529
+ throw new ImageGenError('上游异步任务轮询超时(240 秒)', 'upstream-timeout')
530
+ }
531
+
385
532
  /**
386
533
  * Issue one single-image request (never sends `n`). The response is kept as a
387
534
  * list so a gateway that happens to return several images per call still works.
@@ -494,25 +641,20 @@ async function requestOneImage(
494
641
  }
495
642
 
496
643
  const record = payload as Record<string, unknown>
497
- const data = Array.isArray(record.data)
498
- ? record.data
499
- : Array.isArray(record.images)
500
- ? record.images
501
- : Array.isArray(record.output)
502
- ? record.output
503
- : undefined
644
+ const data = dataRecordsOf(record)
504
645
  if (data === undefined) {
505
646
  throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
506
647
  }
507
648
  if (data.length === 0) {
508
649
  throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
509
650
  }
510
- return Promise.all(data.map(async (entry) => {
511
- if (entry === null || typeof entry !== 'object') {
512
- throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
513
- }
514
- return normalizeItem(entry as Record<string, unknown>, upstream)
515
- }))
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)))
656
+ }
657
+ return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
516
658
  }
517
659
 
518
660
  /**
@@ -13,6 +13,7 @@ import { createHash } from 'node:crypto'
13
13
  import { homedir } from 'node:os'
14
14
  import path from 'node:path'
15
15
  import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
16
+ import { notifyImageSaved } from './storage-sync.ts'
16
17
 
17
18
  const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
18
19
  const GALLERY_DIR = path.join(HISTORY_DIR, 'gallery')
@@ -68,6 +69,7 @@ interface StoredEntry {
68
69
  projectName?: string
69
70
  slotKey?: string
70
71
  slotLabel?: string
72
+ canvas?: HistoryEntryInput['canvas']
71
73
  }
72
74
 
73
75
  /** The index.json shape. */
@@ -192,6 +194,7 @@ function toWire(entry: StoredEntry): HistoryEntry {
192
194
  ...entry.projectName === undefined ? {} : { projectName: entry.projectName },
193
195
  ...entry.slotKey === undefined ? {} : { slotKey: entry.slotKey },
194
196
  ...entry.slotLabel === undefined ? {} : { slotLabel: entry.slotLabel },
197
+ ...entry.canvas === undefined ? {} : { canvas: entry.canvas },
195
198
  }
196
199
  }
197
200
 
@@ -221,6 +224,7 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
221
224
  const image = input.images[index]!
222
225
  const file = `${prefix}-${index}.${extensionOf(image.mime)}`
223
226
  await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
227
+ notifyImageSaved('gallery', path.join(IMAGES_DIR, file))
224
228
  storedImages.push({
225
229
  file,
226
230
  mime: image.mime,
@@ -251,6 +255,7 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
251
255
  ...input.projectName === undefined ? {} : { projectName: input.projectName },
252
256
  ...input.slotKey === undefined ? {} : { slotKey: input.slotKey },
253
257
  ...input.slotLabel === undefined ? {} : { slotLabel: input.slotLabel },
258
+ ...input.canvas === undefined ? {} : { canvas: input.canvas },
254
259
  }
255
260
  const merged = [entry, ...await readIndex()]
256
261
  await writeIndex(merged)
@@ -75,6 +75,7 @@ export class ImageGenerationRuntime {
75
75
  ...request.projectName === undefined ? {} : { projectName: request.projectName },
76
76
  ...request.slotKey === undefined ? {} : { slotKey: request.slotKey },
77
77
  ...request.slotLabel === undefined ? {} : { slotLabel: request.slotLabel },
78
+ ...request.canvas === undefined ? {} : { canvas: request.canvas },
78
79
  })
79
80
  return { ...result, history }
80
81
  } catch (error) {
@@ -12,6 +12,7 @@ import { promises as fs } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
13
  import path from 'node:path'
14
14
  import { HISTORY_MAX, type GenerateMode, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
15
+ import { notifyImageSaved } from './storage-sync.ts'
15
16
 
16
17
  const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
17
18
  const INDEX_PATH = path.join(HISTORY_DIR, 'index.json')
@@ -56,6 +57,7 @@ interface StoredEntry {
56
57
  projectName?: string
57
58
  slotKey?: string
58
59
  slotLabel?: string
60
+ canvas?: HistoryEntryInput['canvas']
59
61
  }
60
62
 
61
63
  /** The index.json shape. */
@@ -174,6 +176,7 @@ function toWire(entry: StoredEntry): HistoryEntry {
174
176
  ...entry.projectName === undefined ? {} : { projectName: entry.projectName },
175
177
  ...entry.slotKey === undefined ? {} : { slotKey: entry.slotKey },
176
178
  ...entry.slotLabel === undefined ? {} : { slotLabel: entry.slotLabel },
179
+ ...entry.canvas === undefined ? {} : { canvas: entry.canvas },
177
180
  }
178
181
  }
179
182
 
@@ -194,6 +197,7 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
194
197
  const image = input.images[index]!
195
198
  const file = `${prefix}-${index}.${extensionOf(image.mime)}`
196
199
  await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
200
+ notifyImageSaved('history', path.join(IMAGES_DIR, file))
197
201
  storedImages.push({
198
202
  file,
199
203
  mime: image.mime,
@@ -225,6 +229,7 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
225
229
  ...input.projectName === undefined ? {} : { projectName: input.projectName },
226
230
  ...input.slotKey === undefined ? {} : { slotKey: input.slotKey },
227
231
  ...input.slotLabel === undefined ? {} : { slotLabel: input.slotLabel },
232
+ ...input.canvas === undefined ? {} : { canvas: input.canvas },
228
233
  }
229
234
  const merged = [entry, ...await readIndex()]
230
235
  const kept = merged.slice(0, HISTORY_MAX)
package/src/index.ts CHANGED
@@ -8,9 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
+ import { readFileSync } from 'node:fs'
12
+ import path from 'node:path'
11
13
  import { installSettingsSectionCompat, settingsNamespaceCompat } from './settings-compat.ts'
12
- import z from 'schemastery'
13
- // Type-only: pulls the webServer Context merge (route registration).
14
+ import z from 'schemastery'// Type-only: pulls the webServer Context merge (route registration).
14
15
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
16
  // Type-only: pulls the systemPrompt Context merge (announcement section).
16
17
  import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -21,6 +22,18 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
21
22
  import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
22
23
  import { makeRoutes, type SettingsSeam } from './routes.ts'
23
24
  import { syncAllTemplates } from './templates-store.ts'
25
+ import { setStorageSyncHandler, putObject, type StorageSyncConfig } from './storage-sync.ts'
26
+
27
+ /** Content type for a saved image file name (object uploads). */
28
+ function mimeOfPath(filePath: string): string {
29
+ switch (path.extname(filePath).toLowerCase()) {
30
+ case '.jpg':
31
+ case '.jpeg': return 'image/jpeg'
32
+ case '.webp': return 'image/webp'
33
+ case '.gif': return 'image/gif'
34
+ default: return 'image/png'
35
+ }
36
+ }
24
37
  import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
25
38
  import { registerAgentImageTools } from './agent-image-tools.ts'
26
39
  import { registerEditImageCommand } from './edit-image-command.ts'
@@ -42,6 +55,7 @@ export { latestSessionImage, registerEditImageCommand } from './edit-image-comma
42
55
  export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
43
56
  export { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates, syncAllTemplates, clearTemplateMemo } from './templates-store.ts'
44
57
  export { addTemplateFavorite, clearTemplateFavoritesMemo, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
58
+ export { putObject, setStorageSyncHandler, testStorage, type StorageSyncConfig } from './storage-sync.ts'
45
59
  export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
46
60
 
47
61
  /** The branded settings namespace of this plugin (the card edits it). */
@@ -75,6 +89,22 @@ export interface Config {
75
89
  promptApiKey?: string
76
90
  /** Chat model used to expand short image prompts. */
77
91
  promptModel?: string
92
+ /** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
93
+ storageEnabled?: boolean
94
+ /** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
95
+ storageEndpoint?: string
96
+ /** Provider region for SigV4 scope, e.g. ap-guangzhou / oss-cn-hangzhou. */
97
+ storageRegion?: string
98
+ /** Object key prefix, default 'dsh-imagegen'. */
99
+ storagePrefix?: string
100
+ /** S3 access key id. */
101
+ storageAccessKey?: string
102
+ /** S3 secret access key (stored redacted). */
103
+ storageSecretKey?: string
104
+ /** Upload gallery additions (default on when storage is enabled). */
105
+ storageSyncGallery?: boolean
106
+ /** Also upload history images. */
107
+ storageSyncHistory?: boolean
78
108
  /* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
79
109
  /** Legacy base URL; synthesized into the default channel on upgrade. */
80
110
  apiUrl?: string
@@ -103,6 +133,14 @@ export const Config: z<Config> = z.object({
103
133
  promptApiUrl: z.string().default(''),
104
134
  promptApiKey: z.string().role('secret').default(''),
105
135
  promptModel: z.string().default(''),
136
+ storageEnabled: z.boolean().default(false),
137
+ storageEndpoint: z.string().default(''),
138
+ storageRegion: z.string().default(''),
139
+ storagePrefix: z.string().default('dsh-imagegen'),
140
+ storageAccessKey: z.string().default(''),
141
+ storageSecretKey: z.string().role('secret').default(''),
142
+ storageSyncGallery: z.boolean().default(true),
143
+ storageSyncHistory: z.boolean().default(false),
106
144
  apiUrl: z.string().default(''),
107
145
  apiKey: z.string().role('secret').default(''),
108
146
  imageModels: z.array(z.string()).default([]),
@@ -175,6 +213,7 @@ export interface EffectiveConfig {
175
213
  promptApiUrl: string
176
214
  promptApiKey: string
177
215
  promptModel: string
216
+ storage: StorageSyncConfig & { enabled: boolean; syncGallery: boolean; syncHistory: boolean }
178
217
  }
179
218
 
180
219
  /**
@@ -182,7 +221,7 @@ export interface EffectiveConfig {
182
221
  * @param ctx - host plugin context carrying webServer/systemPrompt.
183
222
  * @param config - resolved plugin config (schema defaults applied by the loader).
184
223
  */
185
- export function apply(ctx: Context, config?: Config): void {
224
+ export function apply(ctx: Context, config?: Config): (() => void) | void {
186
225
  // The live source the surfaces read: the settings section once the settings
187
226
  // service is attached, the composition entry otherwise.
188
227
  let current: () => Config = () => config ?? {}
@@ -226,6 +265,16 @@ export function apply(ctx: Context, config?: Config): void {
226
265
  promptApiUrl: typeof value.promptApiUrl === 'string' ? value.promptApiUrl.trim() : '',
227
266
  promptApiKey: typeof value.promptApiKey === 'string' ? value.promptApiKey.trim() : '',
228
267
  promptModel: typeof value.promptModel === 'string' ? value.promptModel.trim() : '',
268
+ storage: {
269
+ enabled: value.storageEnabled ?? false,
270
+ endpoint: typeof value.storageEndpoint === 'string' ? value.storageEndpoint.trim() : '',
271
+ region: typeof value.storageRegion === 'string' ? value.storageRegion.trim() : '',
272
+ accessKey: typeof value.storageAccessKey === 'string' ? value.storageAccessKey.trim() : '',
273
+ secretKey: typeof value.storageSecretKey === 'string' ? value.storageSecretKey.trim() : '',
274
+ prefix: typeof value.storagePrefix === 'string' && value.storagePrefix.trim() !== '' ? value.storagePrefix.trim() : 'dsh-imagegen',
275
+ syncGallery: value.storageSyncGallery ?? true,
276
+ syncHistory: value.storageSyncHistory ?? false,
277
+ },
229
278
  }
230
279
  }
231
280
 
@@ -236,6 +285,21 @@ export function apply(ctx: Context, config?: Config): void {
236
285
  return { channels: value.channels, defaultChannelId: value.defaultChannelId }
237
286
  }
238
287
 
288
+ // Object-storage sync: the image stores announce every file they write; the
289
+ // handler resolves the live settings and uploads when enabled. Fire and
290
+ // forget — a sync failure never blocks the save path.
291
+ setStorageSyncHandler((kind, filePath) => {
292
+ const storage = resolve().storage
293
+ if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === '') return
294
+ if (kind === 'gallery' && !storage.syncGallery) return
295
+ if (kind === 'history' && !storage.syncHistory) return
296
+ const key = `${storage.prefix}/${kind === 'gallery' ? 'gallery' : 'images'}/${path.basename(filePath)}`
297
+ const data = readFileSync(filePath)
298
+ void putObject(storage, key, data, mimeOfPath(filePath)).catch(() => {
299
+ // Best-effort sync: surfaced through the settings test, never fatal here.
300
+ })
301
+ })
302
+
239
303
  // Browser endpoints and Agent tools share the exact same serial queue. This
240
304
  // keeps image persistence, cancellation, and retries coherent across both
241
305
  // entry points; Agent tools wait for their task result by default and render
@@ -276,6 +340,7 @@ export function apply(ctx: Context, config?: Config): void {
276
340
  attachments: sctx.attachments,
277
341
  pendingConversationImages,
278
342
  runtime,
343
+ resolveStorage: () => resolve().storage,
279
344
  })
280
345
  const disposers = routes.map(route => ctx.webServer.register(route))
281
346
  // Background template sync: the upstream sources update on their own
@@ -354,4 +419,6 @@ export function apply(ctx: Context, config?: Config): void {
354
419
  // Initial registration from the composition entry (covers deployments with
355
420
  // no settings service, whose installSettingsSection never fires its hooks).
356
421
  sync()
422
+
423
+ return () => { setStorageSyncHandler(undefined) }
357
424
  }