@dickpy/dsh-imagegen 1.5.3 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/lib/client.js +5310 -1934
- package/lib/client.js.map +1 -1
- package/lib/index.js +986 -32
- package/package.json +1 -1
- package/src/canvas-store.ts +376 -0
- package/src/client/CanvasWorkspace.tsx +1569 -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 +1443 -1146
- package/src/client/panel.module.css +83 -33
- package/src/engine.ts +156 -14
- 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
|
@@ -339,6 +339,7 @@ function effectiveCount(request: GenerateRequest): number {
|
|
|
339
339
|
async function normalizeItem(
|
|
340
340
|
item: Record<string, unknown>,
|
|
341
341
|
upstream: UpstreamConfig,
|
|
342
|
+
signal?: AbortSignal,
|
|
342
343
|
): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
|
|
343
344
|
const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
|
|
344
345
|
if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
|
|
@@ -356,7 +357,7 @@ async function normalizeItem(
|
|
|
356
357
|
if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
|
|
357
358
|
return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
|
|
358
359
|
}
|
|
359
|
-
const budget = requestSignal(
|
|
360
|
+
const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
|
|
360
361
|
let response: Response
|
|
361
362
|
try {
|
|
362
363
|
response = await fetch(url, {
|
|
@@ -382,6 +383,152 @@ async function normalizeItem(
|
|
|
382
383
|
return { b64: buffer.toString('base64'), mime, revisedPrompt }
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
/** Expand a provider image item whose URL may be a string or an array. */
|
|
387
|
+
function imageItemsOf(value: unknown): Array<Record<string, unknown>> {
|
|
388
|
+
if (value === null || typeof value !== 'object') return []
|
|
389
|
+
const item = value as Record<string, unknown>
|
|
390
|
+
if (Array.isArray(item.url)) {
|
|
391
|
+
return item.url.filter((url): url is string => typeof url === 'string' && url !== '').map(url => ({ ...item, url }))
|
|
392
|
+
}
|
|
393
|
+
return [item]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Return the data records from the response shapes shared by sync gateways. */
|
|
397
|
+
function dataRecordsOf(payload: Record<string, unknown>): Array<Record<string, unknown>> | undefined {
|
|
398
|
+
const data = Array.isArray(payload.data)
|
|
399
|
+
? payload.data
|
|
400
|
+
: payload.data !== null && typeof payload.data === 'object'
|
|
401
|
+
? [payload.data]
|
|
402
|
+
: Array.isArray(payload.images)
|
|
403
|
+
? payload.images
|
|
404
|
+
: Array.isArray(payload.output)
|
|
405
|
+
? payload.output
|
|
406
|
+
: undefined
|
|
407
|
+
if (data === undefined) return undefined
|
|
408
|
+
return data.filter((entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const ASYNC_PENDING_STATUSES = new Set(['submitted', 'pending', 'processing', 'running', 'in_progress', 'queued'])
|
|
412
|
+
const ASYNC_COMPLETED_STATUSES = new Set(['completed', 'succeeded', 'success', 'done'])
|
|
413
|
+
const ASYNC_FAILED_STATUSES = new Set(['failed', 'failure', 'cancelled', 'canceled', 'error'])
|
|
414
|
+
const ASYNC_POLL_MAX_MS = 240_000
|
|
415
|
+
const ASYNC_POLL_REQUEST_TIMEOUT_MS = 30_000
|
|
416
|
+
|
|
417
|
+
/** Read a provider error message from the common nested locations. */
|
|
418
|
+
function asyncErrorMessage(payload: unknown, fallback: string): string {
|
|
419
|
+
if (payload !== null && typeof payload === 'object') {
|
|
420
|
+
const record = payload as Record<string, unknown>
|
|
421
|
+
const candidates: unknown[] = [record.message, record.error]
|
|
422
|
+
const data = record.data
|
|
423
|
+
const entries = Array.isArray(data) ? data : [data]
|
|
424
|
+
for (const entry of entries) {
|
|
425
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
426
|
+
const item = entry as Record<string, unknown>
|
|
427
|
+
candidates.push(item.message, item.error)
|
|
428
|
+
const nested = item.error
|
|
429
|
+
if (nested !== null && typeof nested === 'object') candidates.push((nested as Record<string, unknown>).message)
|
|
430
|
+
}
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
|
|
433
|
+
if (candidate !== null && typeof candidate === 'object') {
|
|
434
|
+
const message = (candidate as Record<string, unknown>).message
|
|
435
|
+
if (typeof message === 'string' && message.trim() !== '') return message
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return fallback
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Wait between async-provider polls, but wake immediately when cancelled. */
|
|
443
|
+
function waitForPoll(ms: number, signal?: AbortSignal): Promise<void> {
|
|
444
|
+
return new Promise((resolve, reject) => {
|
|
445
|
+
if (signal?.aborted === true) {
|
|
446
|
+
reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
const onAbort = () => {
|
|
450
|
+
clearTimeout(timer)
|
|
451
|
+
signal?.removeEventListener('abort', onAbort)
|
|
452
|
+
reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'))
|
|
453
|
+
}
|
|
454
|
+
const done = () => {
|
|
455
|
+
signal?.removeEventListener('abort', onAbort)
|
|
456
|
+
resolve()
|
|
457
|
+
}
|
|
458
|
+
const timer = setTimeout(done, ms)
|
|
459
|
+
timer.unref()
|
|
460
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Poll one apib/apimart-style provider task until it yields image records.
|
|
466
|
+
* The total deadline is shared by every poll and the final image downloads;
|
|
467
|
+
* local task cancellation propagates through every request and sleep.
|
|
468
|
+
*/
|
|
469
|
+
async function pollAsyncTask(
|
|
470
|
+
baseUrl: string,
|
|
471
|
+
upstream: UpstreamConfig,
|
|
472
|
+
taskId: string,
|
|
473
|
+
signal?: AbortSignal,
|
|
474
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
475
|
+
const deadline = Date.now() + ASYNC_POLL_MAX_MS
|
|
476
|
+
let delay = 1000
|
|
477
|
+
while (Date.now() < deadline) {
|
|
478
|
+
const remaining = deadline - Date.now()
|
|
479
|
+
const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
|
|
480
|
+
let response: Response
|
|
481
|
+
try {
|
|
482
|
+
response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
|
|
483
|
+
method: 'GET',
|
|
484
|
+
headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
|
|
485
|
+
signal: budget.signal,
|
|
486
|
+
})
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
490
|
+
if (/timeout|abort/i.test(message)) throw new ImageGenError('上游异步任务轮询超时', 'upstream-timeout')
|
|
491
|
+
throw new ImageGenError(`无法轮询上游异步任务:${message}`, 'upstream-unreachable')
|
|
492
|
+
} finally {
|
|
493
|
+
budget.dispose()
|
|
494
|
+
}
|
|
495
|
+
let payload: unknown
|
|
496
|
+
try {
|
|
497
|
+
payload = await response.json()
|
|
498
|
+
} catch {
|
|
499
|
+
throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
|
|
500
|
+
}
|
|
501
|
+
if (!response.ok || payload === null || typeof payload !== 'object') {
|
|
502
|
+
throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), 'upstream-rejected')
|
|
503
|
+
}
|
|
504
|
+
const record = payload as Record<string, unknown>
|
|
505
|
+
const data = record.data
|
|
506
|
+
const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === 'object' ? data : record
|
|
507
|
+
const statusValue = statusRecord !== null && typeof statusRecord === 'object'
|
|
508
|
+
? (statusRecord as Record<string, unknown>).status
|
|
509
|
+
: undefined
|
|
510
|
+
const status = typeof statusValue === 'string' ? statusValue.toLowerCase() : ''
|
|
511
|
+
if (ASYNC_FAILED_STATUSES.has(status)) {
|
|
512
|
+
throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || 'unknown'})`), 'upstream-rejected')
|
|
513
|
+
}
|
|
514
|
+
const nested = statusRecord !== null && typeof statusRecord === 'object' ? statusRecord as Record<string, unknown> : record
|
|
515
|
+
const result = nested.result ?? (nested.output !== null && typeof nested.output === 'object' ? (nested.output as Record<string, unknown>).result : undefined) ?? record.result
|
|
516
|
+
const resultRecord = result !== null && typeof result === 'object' ? result as Record<string, unknown> : undefined
|
|
517
|
+
const images = resultRecord?.images ?? (nested.images ?? record.images)
|
|
518
|
+
if (ASYNC_COMPLETED_STATUSES.has(status) || images !== undefined) {
|
|
519
|
+
const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images)
|
|
520
|
+
if (items.length > 0) return items
|
|
521
|
+
if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
|
|
522
|
+
}
|
|
523
|
+
if (status !== '' && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) {
|
|
524
|
+
throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, 'upstream-invalid')
|
|
525
|
+
}
|
|
526
|
+
await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
|
|
527
|
+
delay = Math.min(5000, delay * 2)
|
|
528
|
+
}
|
|
529
|
+
throw new ImageGenError('上游异步任务轮询超时(240 秒)', 'upstream-timeout')
|
|
530
|
+
}
|
|
531
|
+
|
|
385
532
|
/**
|
|
386
533
|
* Issue one single-image request (never sends `n`). The response is kept as a
|
|
387
534
|
* list so a gateway that happens to return several images per call still works.
|
|
@@ -494,25 +641,20 @@ async function requestOneImage(
|
|
|
494
641
|
}
|
|
495
642
|
|
|
496
643
|
const record = payload as Record<string, unknown>
|
|
497
|
-
const data =
|
|
498
|
-
? record.data
|
|
499
|
-
: Array.isArray(record.images)
|
|
500
|
-
? record.images
|
|
501
|
-
: Array.isArray(record.output)
|
|
502
|
-
? record.output
|
|
503
|
-
: undefined
|
|
644
|
+
const data = dataRecordsOf(record)
|
|
504
645
|
if (data === undefined) {
|
|
505
646
|
throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
|
|
506
647
|
}
|
|
507
648
|
if (data.length === 0) {
|
|
508
649
|
throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
|
|
509
650
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return
|
|
515
|
-
}
|
|
651
|
+
const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
|
|
652
|
+
if (asyncEntries.length > 0) {
|
|
653
|
+
const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
|
|
654
|
+
if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
|
|
655
|
+
return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
|
|
656
|
+
}
|
|
657
|
+
return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
|
|
516
658
|
}
|
|
517
659
|
|
|
518
660
|
/**
|
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)
|
package/src/index.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
11
13
|
import { installSettingsSectionCompat, settingsNamespaceCompat } from './settings-compat.ts'
|
|
12
|
-
import z from 'schemastery'
|
|
13
|
-
// Type-only: pulls the webServer Context merge (route registration).
|
|
14
|
+
import z from 'schemastery'// Type-only: pulls the webServer Context merge (route registration).
|
|
14
15
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
15
16
|
// Type-only: pulls the systemPrompt Context merge (announcement section).
|
|
16
17
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
@@ -21,6 +22,18 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
|
21
22
|
import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
|
|
22
23
|
import { makeRoutes, type SettingsSeam } from './routes.ts'
|
|
23
24
|
import { syncAllTemplates } from './templates-store.ts'
|
|
25
|
+
import { setStorageSyncHandler, putObject, type StorageSyncConfig } from './storage-sync.ts'
|
|
26
|
+
|
|
27
|
+
/** Content type for a saved image file name (object uploads). */
|
|
28
|
+
function mimeOfPath(filePath: string): string {
|
|
29
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
30
|
+
case '.jpg':
|
|
31
|
+
case '.jpeg': return 'image/jpeg'
|
|
32
|
+
case '.webp': return 'image/webp'
|
|
33
|
+
case '.gif': return 'image/gif'
|
|
34
|
+
default: return 'image/png'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
|
|
25
38
|
import { registerAgentImageTools } from './agent-image-tools.ts'
|
|
26
39
|
import { registerEditImageCommand } from './edit-image-command.ts'
|
|
@@ -42,6 +55,7 @@ export { latestSessionImage, registerEditImageCommand } from './edit-image-comma
|
|
|
42
55
|
export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
|
|
43
56
|
export { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates, syncAllTemplates, clearTemplateMemo } from './templates-store.ts'
|
|
44
57
|
export { addTemplateFavorite, clearTemplateFavoritesMemo, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
|
|
58
|
+
export { putObject, setStorageSyncHandler, testStorage, type StorageSyncConfig } from './storage-sync.ts'
|
|
45
59
|
export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
|
|
46
60
|
|
|
47
61
|
/** The branded settings namespace of this plugin (the card edits it). */
|
|
@@ -75,6 +89,22 @@ export interface Config {
|
|
|
75
89
|
promptApiKey?: string
|
|
76
90
|
/** Chat model used to expand short image prompts. */
|
|
77
91
|
promptModel?: string
|
|
92
|
+
/** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
|
|
93
|
+
storageEnabled?: boolean
|
|
94
|
+
/** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
|
|
95
|
+
storageEndpoint?: string
|
|
96
|
+
/** Provider region for SigV4 scope, e.g. ap-guangzhou / oss-cn-hangzhou. */
|
|
97
|
+
storageRegion?: string
|
|
98
|
+
/** Object key prefix, default 'dsh-imagegen'. */
|
|
99
|
+
storagePrefix?: string
|
|
100
|
+
/** S3 access key id. */
|
|
101
|
+
storageAccessKey?: string
|
|
102
|
+
/** S3 secret access key (stored redacted). */
|
|
103
|
+
storageSecretKey?: string
|
|
104
|
+
/** Upload gallery additions (default on when storage is enabled). */
|
|
105
|
+
storageSyncGallery?: boolean
|
|
106
|
+
/** Also upload history images. */
|
|
107
|
+
storageSyncHistory?: boolean
|
|
78
108
|
/* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
|
|
79
109
|
/** Legacy base URL; synthesized into the default channel on upgrade. */
|
|
80
110
|
apiUrl?: string
|
|
@@ -103,6 +133,14 @@ export const Config: z<Config> = z.object({
|
|
|
103
133
|
promptApiUrl: z.string().default(''),
|
|
104
134
|
promptApiKey: z.string().role('secret').default(''),
|
|
105
135
|
promptModel: z.string().default(''),
|
|
136
|
+
storageEnabled: z.boolean().default(false),
|
|
137
|
+
storageEndpoint: z.string().default(''),
|
|
138
|
+
storageRegion: z.string().default(''),
|
|
139
|
+
storagePrefix: z.string().default('dsh-imagegen'),
|
|
140
|
+
storageAccessKey: z.string().default(''),
|
|
141
|
+
storageSecretKey: z.string().role('secret').default(''),
|
|
142
|
+
storageSyncGallery: z.boolean().default(true),
|
|
143
|
+
storageSyncHistory: z.boolean().default(false),
|
|
106
144
|
apiUrl: z.string().default(''),
|
|
107
145
|
apiKey: z.string().role('secret').default(''),
|
|
108
146
|
imageModels: z.array(z.string()).default([]),
|
|
@@ -175,6 +213,7 @@ export interface EffectiveConfig {
|
|
|
175
213
|
promptApiUrl: string
|
|
176
214
|
promptApiKey: string
|
|
177
215
|
promptModel: string
|
|
216
|
+
storage: StorageSyncConfig & { enabled: boolean; syncGallery: boolean; syncHistory: boolean }
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
/**
|
|
@@ -182,7 +221,7 @@ export interface EffectiveConfig {
|
|
|
182
221
|
* @param ctx - host plugin context carrying webServer/systemPrompt.
|
|
183
222
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
184
223
|
*/
|
|
185
|
-
export function apply(ctx: Context, config?: Config): void {
|
|
224
|
+
export function apply(ctx: Context, config?: Config): (() => void) | void {
|
|
186
225
|
// The live source the surfaces read: the settings section once the settings
|
|
187
226
|
// service is attached, the composition entry otherwise.
|
|
188
227
|
let current: () => Config = () => config ?? {}
|
|
@@ -226,6 +265,16 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
226
265
|
promptApiUrl: typeof value.promptApiUrl === 'string' ? value.promptApiUrl.trim() : '',
|
|
227
266
|
promptApiKey: typeof value.promptApiKey === 'string' ? value.promptApiKey.trim() : '',
|
|
228
267
|
promptModel: typeof value.promptModel === 'string' ? value.promptModel.trim() : '',
|
|
268
|
+
storage: {
|
|
269
|
+
enabled: value.storageEnabled ?? false,
|
|
270
|
+
endpoint: typeof value.storageEndpoint === 'string' ? value.storageEndpoint.trim() : '',
|
|
271
|
+
region: typeof value.storageRegion === 'string' ? value.storageRegion.trim() : '',
|
|
272
|
+
accessKey: typeof value.storageAccessKey === 'string' ? value.storageAccessKey.trim() : '',
|
|
273
|
+
secretKey: typeof value.storageSecretKey === 'string' ? value.storageSecretKey.trim() : '',
|
|
274
|
+
prefix: typeof value.storagePrefix === 'string' && value.storagePrefix.trim() !== '' ? value.storagePrefix.trim() : 'dsh-imagegen',
|
|
275
|
+
syncGallery: value.storageSyncGallery ?? true,
|
|
276
|
+
syncHistory: value.storageSyncHistory ?? false,
|
|
277
|
+
},
|
|
229
278
|
}
|
|
230
279
|
}
|
|
231
280
|
|
|
@@ -236,6 +285,21 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
236
285
|
return { channels: value.channels, defaultChannelId: value.defaultChannelId }
|
|
237
286
|
}
|
|
238
287
|
|
|
288
|
+
// Object-storage sync: the image stores announce every file they write; the
|
|
289
|
+
// handler resolves the live settings and uploads when enabled. Fire and
|
|
290
|
+
// forget — a sync failure never blocks the save path.
|
|
291
|
+
setStorageSyncHandler((kind, filePath) => {
|
|
292
|
+
const storage = resolve().storage
|
|
293
|
+
if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === '') return
|
|
294
|
+
if (kind === 'gallery' && !storage.syncGallery) return
|
|
295
|
+
if (kind === 'history' && !storage.syncHistory) return
|
|
296
|
+
const key = `${storage.prefix}/${kind === 'gallery' ? 'gallery' : 'images'}/${path.basename(filePath)}`
|
|
297
|
+
const data = readFileSync(filePath)
|
|
298
|
+
void putObject(storage, key, data, mimeOfPath(filePath)).catch(() => {
|
|
299
|
+
// Best-effort sync: surfaced through the settings test, never fatal here.
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
239
303
|
// Browser endpoints and Agent tools share the exact same serial queue. This
|
|
240
304
|
// keeps image persistence, cancellation, and retries coherent across both
|
|
241
305
|
// entry points; Agent tools wait for their task result by default and render
|
|
@@ -276,6 +340,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
276
340
|
attachments: sctx.attachments,
|
|
277
341
|
pendingConversationImages,
|
|
278
342
|
runtime,
|
|
343
|
+
resolveStorage: () => resolve().storage,
|
|
279
344
|
})
|
|
280
345
|
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
281
346
|
// Background template sync: the upstream sources update on their own
|
|
@@ -354,4 +419,6 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
354
419
|
// Initial registration from the composition entry (covers deployments with
|
|
355
420
|
// no settings service, whose installSettingsSection never fires its hooks).
|
|
356
421
|
sync()
|
|
422
|
+
|
|
423
|
+
return () => { setStorageSyncHandler(undefined) }
|
|
357
424
|
}
|