@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.
- package/README.md +23 -1
- package/lib/client.js +5412 -1935
- package/lib/client.js.map +1 -1
- package/lib/index.js +1082 -111
- package/package.json +1 -1
- package/src/canvas-store.ts +376 -0
- package/src/client/CanvasWorkspace.tsx +1628 -0
- package/src/client/ImageGenPanel.tsx +159 -96
- package/src/client/SettingsCard.tsx +173 -1
- package/src/client/api.ts +48 -1
- package/src/client/canvas-workspace.module.css +929 -0
- package/src/client/locales.ts +1446 -1146
- package/src/client/panel.module.css +83 -33
- package/src/engine.ts +255 -95
- 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 +70 -3
- 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
|
}
|
package/src/engine.ts
CHANGED
|
@@ -184,8 +184,14 @@ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { si
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
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(
|
|
360
|
-
let response: Response
|
|
366
|
+
const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
|
|
361
367
|
try {
|
|
362
|
-
response
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
/**
|
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) {
|