@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.
- package/README.md +23 -2
- package/lib/client.js +5803 -1942
- package/lib/client.js.map +1 -1
- package/lib/index.js +1151 -33
- package/package.json +1 -1
- package/src/canvas-store.ts +376 -0
- package/src/client/CanvasWorkspace.tsx +1569 -0
- package/src/client/ImageGenPanel.tsx +164 -96
- package/src/client/SettingsCard.tsx +182 -4
- package/src/client/api.ts +48 -1
- package/src/client/canvas-workspace.module.css +929 -0
- package/src/client/helpers.ts +71 -33
- package/src/client/index.ts +59 -7
- package/src/client/locales.ts +1452 -772
- package/src/client/panel.module.css +83 -33
- package/src/client/use-language.ts +14 -0
- package/src/engine.ts +302 -12
- package/src/gallery-store.ts +5 -0
- package/src/generation-runtime.ts +1 -0
- package/src/history-store.ts +5 -0
- package/src/index.ts +71 -4
- package/src/model-catalog.ts +10 -1
- package/src/presets.ts +15 -0
- package/src/protocol.ts +121 -1
- package/src/routes.ts +231 -1
- package/src/storage-sync.ts +105 -0
|
@@ -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:
|
|
862
|
+
gap: 4px;
|
|
850
863
|
justify-content: flex-end;
|
|
851
864
|
}
|
|
852
865
|
|
|
853
|
-
.
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
border-radius: 999px;
|
|
877
|
+
color: var(--dsw-alias-label-tertiary);
|
|
861
878
|
cursor: pointer;
|
|
862
879
|
}
|
|
863
880
|
|
|
864
|
-
.
|
|
881
|
+
.historyIconAction:hover {
|
|
865
882
|
color: var(--dsw-alias-label-primary);
|
|
866
|
-
|
|
883
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
867
884
|
}
|
|
868
885
|
|
|
869
|
-
.
|
|
870
|
-
|
|
871
|
-
|
|
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:
|
|
891
|
-
max-width:
|
|
917
|
+
gap: 18px;
|
|
918
|
+
max-width: 760px;
|
|
892
919
|
margin: 0 auto;
|
|
893
|
-
padding:
|
|
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:
|
|
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:
|
|
910
|
-
font-weight:
|
|
941
|
+
margin: 0 0 2px;
|
|
942
|
+
font-size: 14px;
|
|
943
|
+
font-weight: 650;
|
|
911
944
|
display: flex;
|
|
912
945
|
align-items: baseline;
|
|
913
|
-
gap:
|
|
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:
|
|
923
|
-
.ecommerceSection textarea {
|
|
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:
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/gallery-store.ts
CHANGED
|
@@ -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) {
|
package/src/history-store.ts
CHANGED
|
@@ -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)
|