@dickpy/dsh-imagegen 1.5.3 → 1.5.5

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
@@ -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()) {
@@ -339,6 +345,7 @@ function effectiveCount(request: GenerateRequest): number {
339
345
  async function normalizeItem(
340
346
  item: Record<string, unknown>,
341
347
  upstream: UpstreamConfig,
348
+ signal?: AbortSignal,
342
349
  ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
343
350
  const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
344
351
  if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
@@ -356,30 +363,181 @@ async function normalizeItem(
356
363
  if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
357
364
  return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
358
365
  }
359
- const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
360
- let response: Response
366
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
361
367
  try {
362
- response = await fetch(url, {
363
- ...isPresignedUrl(url) || upstream.apiKey === ''
364
- ? {}
365
- : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
366
- signal: budget.signal,
367
- })
368
- } catch (error) {
369
- 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 }
370
390
  } finally {
371
391
  budget.dispose()
372
392
  }
373
- if (!response.ok) {
374
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
393
+ }
394
+
395
+ /** Expand a provider image item whose URL may be a string or an array. */
396
+ function imageItemsOf(value: unknown): Array<Record<string, unknown>> {
397
+ if (value === null || typeof value !== 'object') return []
398
+ const item = value as Record<string, unknown>
399
+ if (Array.isArray(item.url)) {
400
+ return item.url.filter((url): url is string => typeof url === 'string' && url !== '').map(url => ({ ...item, url }))
401
+ }
402
+ return [item]
403
+ }
404
+
405
+ /** Return the data records from the response shapes shared by sync gateways. */
406
+ function dataRecordsOf(payload: Record<string, unknown>): Array<Record<string, unknown>> | undefined {
407
+ const data = Array.isArray(payload.data)
408
+ ? payload.data
409
+ : payload.data !== null && typeof payload.data === 'object'
410
+ ? [payload.data]
411
+ : Array.isArray(payload.images)
412
+ ? payload.images
413
+ : Array.isArray(payload.output)
414
+ ? payload.output
415
+ : undefined
416
+ if (data === undefined) return undefined
417
+ return data.filter((entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object')
418
+ }
419
+
420
+ const ASYNC_PENDING_STATUSES = new Set(['submitted', 'pending', 'processing', 'running', 'in_progress', 'queued'])
421
+ const ASYNC_COMPLETED_STATUSES = new Set(['completed', 'succeeded', 'success', 'done'])
422
+ const ASYNC_FAILED_STATUSES = new Set(['failed', 'failure', 'cancelled', 'canceled', 'error'])
423
+ const ASYNC_POLL_MAX_MS = 240_000
424
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 30_000
425
+
426
+ /** Read a provider error message from the common nested locations. */
427
+ function asyncErrorMessage(payload: unknown, fallback: string): string {
428
+ if (payload !== null && typeof payload === 'object') {
429
+ const record = payload as Record<string, unknown>
430
+ const candidates: unknown[] = [record.message, record.error]
431
+ const data = record.data
432
+ const entries = Array.isArray(data) ? data : [data]
433
+ for (const entry of entries) {
434
+ if (entry === null || typeof entry !== 'object') continue
435
+ const item = entry as Record<string, unknown>
436
+ candidates.push(item.message, item.error)
437
+ const nested = item.error
438
+ if (nested !== null && typeof nested === 'object') candidates.push((nested as Record<string, unknown>).message)
439
+ }
440
+ for (const candidate of candidates) {
441
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
442
+ if (candidate !== null && typeof candidate === 'object') {
443
+ const message = (candidate as Record<string, unknown>).message
444
+ if (typeof message === 'string' && message.trim() !== '') return message
445
+ }
446
+ }
447
+ }
448
+ return fallback
449
+ }
450
+
451
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
452
+ function waitForPoll(ms: number, signal?: AbortSignal): Promise<void> {
453
+ return new Promise((resolve, reject) => {
454
+ if (signal?.aborted === true) {
455
+ reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
456
+ return
457
+ }
458
+ const onAbort = () => {
459
+ clearTimeout(timer)
460
+ signal?.removeEventListener('abort', onAbort)
461
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
462
+ }
463
+ const done = () => {
464
+ signal?.removeEventListener('abort', onAbort)
465
+ resolve()
466
+ }
467
+ const timer = setTimeout(done, ms)
468
+ timer.unref()
469
+ signal?.addEventListener('abort', onAbort, { once: true })
470
+ })
471
+ }
472
+
473
+ /**
474
+ * Poll one apib/apimart-style provider task until it yields image records.
475
+ * The total deadline is shared by every poll and the final image downloads;
476
+ * local task cancellation propagates through every request and sleep.
477
+ */
478
+ async function pollAsyncTask(
479
+ baseUrl: string,
480
+ upstream: UpstreamConfig,
481
+ taskId: string,
482
+ signal?: AbortSignal,
483
+ ): Promise<Array<Record<string, unknown>>> {
484
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS
485
+ let delay = 1000
486
+ while (Date.now() < deadline) {
487
+ const remaining = deadline - Date.now()
488
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
489
+ try {
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
+ }
534
+ } finally {
535
+ budget.dispose()
536
+ }
537
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
538
+ delay = Math.min(5000, delay * 2)
375
539
  }
376
- const buffer = Buffer.from(await response.arrayBuffer())
377
- const contentType = response.headers.get('content-type')
378
- const mime = detectImageMime(buffer)
379
- ?? (contentType !== null && contentType !== ''
380
- ? contentType.split(';')[0]!.trim()
381
- : mimeOfExtension(url) ?? 'image/png')
382
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
540
+ throw new ImageGenError('上游异步任务轮询超时(240 秒)', 'upstream-timeout')
383
541
  }
384
542
 
385
543
  /**
@@ -461,58 +619,57 @@ async function requestOneImage(
461
619
  }
462
620
 
463
621
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
464
- let response: Response
465
622
  try {
466
- // Seedream has no /images/edits endpoint: both modes hit generations.
467
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
468
- ? '/images/edits'
469
- : '/images/generations'
470
- response = await fetch(`${baseUrl}${endpoint}`, {
471
- method: 'POST',
472
- headers,
473
- body,
474
- signal: budget.signal,
475
- })
476
- } catch (error) {
477
- const message = error instanceof Error ? error.message : String(error)
478
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
479
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
623
+ let response: Response
624
+ try {
625
+ // Seedream has no /images/edits endpoint: both modes hit generations.
626
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
627
+ ? '/images/edits'
628
+ : '/images/generations'
629
+ response = await fetch(`${baseUrl}${endpoint}`, {
630
+ method: 'POST',
631
+ headers,
632
+ body,
633
+ signal: budget.signal,
634
+ })
635
+ } catch (error) {
636
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
637
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
638
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
480
639
  }
481
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
482
- } finally {
483
- budget.dispose()
484
- }
485
640
 
486
- let payload: unknown
487
- try {
488
- payload = await response.json()
489
- } catch {
490
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
491
- }
492
- if (!response.ok || payload === null || typeof payload !== 'object') {
493
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
494
- }
641
+ let payload: unknown
642
+ try {
643
+ // The budget stays armed through the body read: a gateway that returns
644
+ // headers but never completes the body must not hang the task forever.
645
+ payload = await response.json()
646
+ } catch (error) {
647
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
648
+ if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
649
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
650
+ }
651
+ if (!response.ok || payload === null || typeof payload !== 'object') {
652
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
653
+ }
495
654
 
496
- 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
504
- if (data === undefined) {
505
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
506
- }
507
- if (data.length === 0) {
508
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
509
- }
510
- return Promise.all(data.map(async (entry) => {
511
- if (entry === null || typeof entry !== 'object') {
512
- throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
655
+ const record = payload as Record<string, unknown>
656
+ const data = dataRecordsOf(record)
657
+ if (data === undefined) {
658
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
513
659
  }
514
- return normalizeItem(entry as Record<string, unknown>, upstream)
515
- }))
660
+ if (data.length === 0) {
661
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
662
+ }
663
+ const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
664
+ if (asyncEntries.length > 0) {
665
+ const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
666
+ if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
667
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
668
+ }
669
+ return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
670
+ } finally {
671
+ budget.dispose()
672
+ }
516
673
  }
517
674
 
518
675
  /**
@@ -559,36 +716,36 @@ async function generateQwenImage(
559
716
  }
560
717
 
561
718
  const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
562
- let response: Response
563
719
  try {
564
- response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
565
- method: 'POST',
566
- headers: {
567
- authorization: `Bearer ${upstream.apiKey.trim()}`,
568
- 'content-type': 'application/json',
569
- },
570
- body: JSON.stringify(body),
571
- signal: budget.signal,
572
- })
573
- } catch (error) {
574
- const message = error instanceof Error ? error.message : String(error)
575
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
576
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
720
+ let response: Response
721
+ try {
722
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
723
+ method: 'POST',
724
+ headers: {
725
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
726
+ 'content-type': 'application/json',
727
+ },
728
+ body: JSON.stringify(body),
729
+ signal: budget.signal,
730
+ })
731
+ } catch (error) {
732
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
733
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
734
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
577
735
  }
578
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
579
- } finally {
580
- budget.dispose()
581
- }
582
736
 
583
- let payload: unknown
584
- try {
585
- payload = await response.json()
586
- } catch {
587
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
588
- }
589
- if (!response.ok || payload === null || typeof payload !== 'object') {
590
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
591
- }
737
+ let payload: unknown
738
+ try {
739
+ // Budget stays armed through the body read (same rationale as the OpenAI path).
740
+ payload = await response.json()
741
+ } catch (error) {
742
+ if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
743
+ if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
744
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
745
+ }
746
+ if (!response.ok || payload === null || typeof payload !== 'object') {
747
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
748
+ }
592
749
 
593
750
  // output.choices[].message.content[] mixes text and { image: url } items.
594
751
  const record = payload as Record<string, unknown>
@@ -617,6 +774,9 @@ async function generateQwenImage(
617
774
  return { b64: normalized.b64, mime: normalized.mime }
618
775
  }))
619
776
  return { images }
777
+ } finally {
778
+ budget.dispose()
779
+ }
620
780
  }
621
781
 
622
782
  /**
@@ -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) {