@dickpy/dsh-imagegen 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * React binding for the plugin locale, which follows the DSH interface
3
+ * language (bridged from ctx.locale in client/index.ts). Surfaces that render
4
+ * tt() output subscribe to this tick so a DSH language switch re-renders them
5
+ * immediately.
6
+ */
7
+
8
+ import { useSyncExternalStore } from 'react'
9
+ import { getImageGenLanguageVersion, subscribeImageGenLanguage } from './helpers.ts'
10
+
11
+ /** Re-render the calling component whenever the DSH language changes. */
12
+ export function useImageGenLanguageTick(): number {
13
+ return useSyncExternalStore(subscribeImageGenLanguage, getImageGenLanguageVersion)
14
+ }
package/src/engine.ts CHANGED
@@ -84,6 +84,13 @@ function isZhipuImage(model: string): boolean {
84
84
  return modelFamily(model) === 'zhipu'
85
85
  }
86
86
 
87
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
88
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
89
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
90
+ function isQwenImage(model: string): boolean {
91
+ return modelFamily(model) === 'qwen'
92
+ }
93
+
87
94
  function isGlmImage(model: string): boolean {
88
95
  return /^glm-image(?:-|$)/i.test(model.trim())
89
96
  }
@@ -101,6 +108,42 @@ function seedreamSize(quality: string): string {
101
108
  return '2K'
102
109
  }
103
110
 
111
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
112
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
113
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
114
+ * recommend the larger set. */
115
+ const QWEN_SIZE_CLASSIC: Readonly<Record<string, string>> = {
116
+ '16:9': '1664*928',
117
+ '21:9': '1664*928',
118
+ '4:3': '1472*1104',
119
+ '3:2': '1472*1104',
120
+ '1:1': '1328*1328',
121
+ '3:4': '1104*1472',
122
+ '2:3': '1104*1472',
123
+ '9:16': '928*1664',
124
+ }
125
+
126
+ const QWEN_SIZE_HD: Readonly<Record<string, string>> = {
127
+ '16:9': '2688*1536',
128
+ '21:9': '2688*1536',
129
+ '4:3': '2368*1728',
130
+ '3:2': '2368*1728',
131
+ '1:1': '2048*2048',
132
+ '3:4': '1728*2368',
133
+ '2:3': '1728*2368',
134
+ '9:16': '1536*2688',
135
+ }
136
+
137
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
138
+ function isVersionedQwenImage(model: string): boolean {
139
+ return /^qwen-image-\d+\.\d/i.test(model.trim())
140
+ }
141
+
142
+ function qwenSize(model: string, ratio: string): string | undefined {
143
+ if (ratio === '' || ratio === 'auto') return undefined
144
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio]
145
+ }
146
+
104
147
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
105
148
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
106
149
  const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
@@ -296,6 +339,7 @@ function effectiveCount(request: GenerateRequest): number {
296
339
  async function normalizeItem(
297
340
  item: Record<string, unknown>,
298
341
  upstream: UpstreamConfig,
342
+ signal?: AbortSignal,
299
343
  ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
300
344
  const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
301
345
  if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
@@ -313,7 +357,7 @@ async function normalizeItem(
313
357
  if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
314
358
  return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
315
359
  }
316
- const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
360
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
317
361
  let response: Response
318
362
  try {
319
363
  response = await fetch(url, {
@@ -339,6 +383,152 @@ async function normalizeItem(
339
383
  return { b64: buffer.toString('base64'), mime, revisedPrompt }
340
384
  }
341
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
+
342
532
  /**
343
533
  * Issue one single-image request (never sends `n`). The response is kept as a
344
534
  * list so a gateway that happens to return several images per call still works.
@@ -451,25 +641,124 @@ async function requestOneImage(
451
641
  }
452
642
 
453
643
  const record = payload as Record<string, unknown>
454
- const data = Array.isArray(record.data)
455
- ? record.data
456
- : Array.isArray(record.images)
457
- ? record.images
458
- : Array.isArray(record.output)
459
- ? record.output
460
- : undefined
644
+ const data = dataRecordsOf(record)
461
645
  if (data === undefined) {
462
646
  throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
463
647
  }
464
648
  if (data.length === 0) {
465
649
  throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
466
650
  }
467
- return Promise.all(data.map(async (entry) => {
468
- if (entry === null || typeof entry !== 'object') {
469
- throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
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)))
658
+ }
659
+
660
+ /**
661
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
662
+ * carries the prompt (plus the reference image for edit mode) and answers
663
+ * synchronously with image URLs in the reply content. The versioned series
664
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
665
+ * single-image per call.
666
+ */
667
+ async function generateQwenImage(
668
+ baseUrl: string,
669
+ upstream: UpstreamConfig,
670
+ request: GenerateRequest,
671
+ options: { signal?: AbortSignal },
672
+ ): Promise<GenerateResult> {
673
+ const model = wireModel(request)
674
+ const content: Array<Record<string, unknown>> = []
675
+ if (request.mode === 'edit') {
676
+ if (typeof request.image !== 'string' || request.image === '') {
677
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
678
+ }
679
+ // DashScope multimodal messages take the reference image as a content
680
+ // item; a base64 data URI rides in the same field as a remote URL.
681
+ const parsed = parseDataUrl(request.image)
682
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
683
+ const bytes = Buffer.from(parsed.base64, 'base64')
684
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
685
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
686
+ }
687
+ content.push({ image: request.image })
688
+ }
689
+ content.push({ text: request.prompt })
690
+
691
+ const batchable = isVersionedQwenImage(model)
692
+ const count = batchable ? clampCount(request.n) : 1
693
+ const size = qwenSize(model, request.size)
694
+ const body = {
695
+ model,
696
+ input: { messages: [{ role: 'user', content }] },
697
+ parameters: {
698
+ ...size !== undefined ? { size } : {},
699
+ ...count > 1 ? { n: count } : {},
700
+ },
701
+ }
702
+
703
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
704
+ let response: Response
705
+ 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')
719
+ }
720
+ throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
721
+ } finally {
722
+ budget.dispose()
723
+ }
724
+
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
+ }
734
+
735
+ // output.choices[].message.content[] mixes text and { image: url } items.
736
+ const record = payload as Record<string, unknown>
737
+ const output = record.output as Record<string, unknown> | undefined
738
+ const choices = output !== undefined && Array.isArray(output.choices) ? output.choices : []
739
+ const urls: string[] = []
740
+ for (const choice of choices) {
741
+ const message = choice !== null && typeof choice === 'object'
742
+ ? (choice as Record<string, unknown>).message
743
+ : undefined
744
+ const items = message !== null && typeof message === 'object' && Array.isArray((message as Record<string, unknown>).content)
745
+ ? (message as Record<string, unknown>).content as unknown[]
746
+ : []
747
+ for (const item of items) {
748
+ if (item !== null && typeof item === 'object') {
749
+ const image = (item as Record<string, unknown>).image
750
+ if (typeof image === 'string' && image !== '') urls.push(image)
751
+ }
470
752
  }
471
- return normalizeItem(entry as Record<string, unknown>, upstream)
753
+ }
754
+ if (urls.length === 0) {
755
+ throw new ImageGenError('上游响应缺少图片内容', 'upstream-empty')
756
+ }
757
+ const images = await Promise.all(urls.map(async url => {
758
+ const normalized = await normalizeItem({ url }, upstream)
759
+ return { b64: normalized.b64, mime: normalized.mime }
472
760
  }))
761
+ return { images }
473
762
  }
474
763
 
475
764
  /**
@@ -482,6 +771,7 @@ export async function generateImage(upstream: UpstreamConfig, request: GenerateR
482
771
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
483
772
  if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
484
773
  if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
774
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options)
485
775
  if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
486
776
  throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
487
777
  }
@@ -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)