@dickpy/dsh-imagegen 1.5.4 → 1.5.6
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 +3 -0
- package/lib/client.js +1141 -508
- package/lib/client.js.map +1 -1
- package/lib/index.js +217 -170
- package/package.json +1 -1
- package/src/canvas-store.ts +15 -16
- package/src/client/CanvasWorkspace.tsx +333 -31
- package/src/client/ImageGenPanel.tsx +2737 -2679
- package/src/client/SettingsCard.tsx +14 -0
- package/src/client/canvas-workspace.module.css +144 -8
- package/src/client/locales.ts +82 -25
- package/src/client/panel.module.css +20 -26
- package/src/engine.ts +181 -146
- package/src/gallery-store.ts +13 -13
- package/src/generation-runtime.ts +2 -2
- package/src/history-store.ts +12 -12
- package/src/image-storage-path.ts +12 -0
- package/src/index.ts +5 -0
- package/src/protocol.ts +8 -2
- package/src/routes.ts +3 -0
- package/src/task-queue.ts +4 -5
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()) {
|
|
@@ -358,29 +364,32 @@ async function normalizeItem(
|
|
|
358
364
|
return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
|
|
359
365
|
}
|
|
360
366
|
const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS)
|
|
361
|
-
let response: Response
|
|
362
367
|
try {
|
|
363
|
-
response
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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 }
|
|
371
390
|
} finally {
|
|
372
391
|
budget.dispose()
|
|
373
392
|
}
|
|
374
|
-
if (!response.ok) {
|
|
375
|
-
throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
|
|
376
|
-
}
|
|
377
|
-
const buffer = Buffer.from(await response.arrayBuffer())
|
|
378
|
-
const contentType = response.headers.get('content-type')
|
|
379
|
-
const mime = detectImageMime(buffer)
|
|
380
|
-
?? (contentType !== null && contentType !== ''
|
|
381
|
-
? contentType.split(';')[0]!.trim()
|
|
382
|
-
: mimeOfExtension(url) ?? 'image/png')
|
|
383
|
-
return { b64: buffer.toString('base64'), mime, revisedPrompt }
|
|
384
393
|
}
|
|
385
394
|
|
|
386
395
|
/** Expand a provider image item whose URL may be a string or an array. */
|
|
@@ -477,52 +486,54 @@ async function pollAsyncTask(
|
|
|
477
486
|
while (Date.now() < deadline) {
|
|
478
487
|
const remaining = deadline - Date.now()
|
|
479
488
|
const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining))
|
|
480
|
-
let response: Response
|
|
481
489
|
try {
|
|
482
|
-
response
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|
|
492
534
|
} finally {
|
|
493
535
|
budget.dispose()
|
|
494
536
|
}
|
|
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
537
|
await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal)
|
|
527
538
|
delay = Math.min(5000, delay * 2)
|
|
528
539
|
}
|
|
@@ -548,17 +559,25 @@ async function requestOneImage(
|
|
|
548
559
|
if (typeof request.image !== 'string' || request.image === '') {
|
|
549
560
|
throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
|
|
550
561
|
}
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
562
|
+
const decodeReference = (dataUrl: string): { bytes: Buffer; mime: string; filename: string } => {
|
|
563
|
+
const parsed = parseDataUrl(dataUrl)
|
|
564
|
+
if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
|
|
565
|
+
let bytes: Buffer
|
|
566
|
+
try {
|
|
567
|
+
bytes = Buffer.from(parsed.base64, 'base64')
|
|
568
|
+
} catch {
|
|
569
|
+
throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
|
|
570
|
+
}
|
|
571
|
+
if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
|
|
572
|
+
throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
|
|
573
|
+
}
|
|
574
|
+
return { bytes, mime: parsed.mime, filename: `reference.${extensionOf(parsed.mime)}` }
|
|
561
575
|
}
|
|
576
|
+
const primary = decodeReference(request.image)
|
|
577
|
+
const extras = (request.images ?? [])
|
|
578
|
+
.filter(img => typeof img === 'string' && img !== '')
|
|
579
|
+
.slice(0, 4)
|
|
580
|
+
.map(decodeReference)
|
|
562
581
|
// Grok Imagine /images/edits takes a JSON image_url object (a base64 data
|
|
563
582
|
// URI is accepted) instead of OpenAI's multipart form-data upload.
|
|
564
583
|
if (isGrokImagine(params.model)) {
|
|
@@ -574,7 +593,7 @@ async function requestOneImage(
|
|
|
574
593
|
// Nano Banana OpenAI-compatible gateways accept the standard multipart
|
|
575
594
|
// edit upload, with the family's own aspect_ratio / image_size knobs.
|
|
576
595
|
const form = new FormData()
|
|
577
|
-
form.append('image', new Blob([bytes], { type:
|
|
596
|
+
form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
|
|
578
597
|
form.append('prompt', request.prompt)
|
|
579
598
|
form.append('model', params.model)
|
|
580
599
|
if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
|
|
@@ -582,19 +601,28 @@ async function requestOneImage(
|
|
|
582
601
|
body = form
|
|
583
602
|
} else if (isSeedream(params.model)) {
|
|
584
603
|
// Seedream unifies generation and editing on /images/generations; the
|
|
585
|
-
// reference image is a JSON URL / data-URL array, never multipart
|
|
604
|
+
// reference image is a JSON URL / data-URL array, never multipart, and
|
|
605
|
+
// the protocol natively accepts several references.
|
|
586
606
|
headers['content-type'] = 'application/json'
|
|
587
607
|
body = JSON.stringify({
|
|
588
608
|
model: params.model,
|
|
589
609
|
prompt: request.prompt,
|
|
590
|
-
image: [request.image],
|
|
610
|
+
image: [request.image, ...(request.images ?? []).filter(img => typeof img === 'string' && img !== '').slice(0, 4)],
|
|
591
611
|
...params.size !== undefined ? { size: params.size } : {},
|
|
592
612
|
...params.resolution !== undefined ? { resolution: params.resolution } : {},
|
|
593
613
|
response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
|
|
594
614
|
})
|
|
595
615
|
} else {
|
|
596
616
|
const form = new FormData()
|
|
597
|
-
|
|
617
|
+
if (extras.length > 0) {
|
|
618
|
+
// OpenAI-style multi-reference upload: repeat the image[] field so
|
|
619
|
+
// every connected canvas reference reaches the gateway.
|
|
620
|
+
for (const [index, reference] of [primary, ...extras].entries()) {
|
|
621
|
+
form.append('image[]', new Blob([reference.bytes], { type: reference.mime }), `reference-${index}.${extensionOf(reference.mime)}`)
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
form.append('image', new Blob([primary.bytes], { type: primary.mime }), primary.filename)
|
|
625
|
+
}
|
|
598
626
|
form.append('prompt', request.prompt)
|
|
599
627
|
form.append('model', params.model)
|
|
600
628
|
if (params.size !== undefined) form.append('size', params.size)
|
|
@@ -608,53 +636,57 @@ async function requestOneImage(
|
|
|
608
636
|
}
|
|
609
637
|
|
|
610
638
|
const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
|
|
611
|
-
let response: Response
|
|
612
639
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
640
|
+
let response: Response
|
|
641
|
+
try {
|
|
642
|
+
// Seedream has no /images/edits endpoint: both modes hit generations.
|
|
643
|
+
const endpoint = request.mode === 'edit' && !isSeedream(params.model)
|
|
644
|
+
? '/images/edits'
|
|
645
|
+
: '/images/generations'
|
|
646
|
+
response = await fetch(`${baseUrl}${endpoint}`, {
|
|
647
|
+
method: 'POST',
|
|
648
|
+
headers,
|
|
649
|
+
body,
|
|
650
|
+
signal: budget.signal,
|
|
651
|
+
})
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
654
|
+
if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
|
|
655
|
+
throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
|
|
627
656
|
}
|
|
628
|
-
throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
|
|
629
|
-
} finally {
|
|
630
|
-
budget.dispose()
|
|
631
|
-
}
|
|
632
657
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
658
|
+
let payload: unknown
|
|
659
|
+
try {
|
|
660
|
+
// The budget stays armed through the body read: a gateway that returns
|
|
661
|
+
// headers but never completes the body must not hang the task forever.
|
|
662
|
+
payload = await response.json()
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
665
|
+
if (signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
|
|
666
|
+
throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
|
|
667
|
+
}
|
|
668
|
+
if (!response.ok || payload === null || typeof payload !== 'object') {
|
|
669
|
+
throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
|
|
670
|
+
}
|
|
642
671
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
672
|
+
const record = payload as Record<string, unknown>
|
|
673
|
+
const data = dataRecordsOf(record)
|
|
674
|
+
if (data === undefined) {
|
|
675
|
+
throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
|
|
676
|
+
}
|
|
677
|
+
if (data.length === 0) {
|
|
678
|
+
throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
|
|
679
|
+
}
|
|
680
|
+
const asyncEntries = data.filter(entry => typeof entry.task_id === 'string' && entry.task_id.trim() !== '')
|
|
681
|
+
if (asyncEntries.length > 0) {
|
|
682
|
+
const asyncRecords = (await Promise.all(asyncEntries.map(entry => pollAsyncTask(baseUrl, upstream, entry.task_id as string, signal)))).flat()
|
|
683
|
+
if (asyncRecords.length === 0) throw new ImageGenError('上游异步任务完成但没有图片结果', 'upstream-empty')
|
|
684
|
+
return Promise.all(asyncRecords.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
|
|
685
|
+
}
|
|
686
|
+
return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
|
|
687
|
+
} finally {
|
|
688
|
+
budget.dispose()
|
|
656
689
|
}
|
|
657
|
-
return Promise.all(data.flatMap(imageItemsOf).map(item => normalizeItem(item, upstream, signal)))
|
|
658
690
|
}
|
|
659
691
|
|
|
660
692
|
/**
|
|
@@ -701,36 +733,36 @@ async function generateQwenImage(
|
|
|
701
733
|
}
|
|
702
734
|
|
|
703
735
|
const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
|
|
704
|
-
let response: Response
|
|
705
736
|
try {
|
|
706
|
-
response
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
737
|
+
let response: Response
|
|
738
|
+
try {
|
|
739
|
+
response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
|
|
740
|
+
method: 'POST',
|
|
741
|
+
headers: {
|
|
742
|
+
authorization: `Bearer ${upstream.apiKey.trim()}`,
|
|
743
|
+
'content-type': 'application/json',
|
|
744
|
+
},
|
|
745
|
+
body: JSON.stringify(body),
|
|
746
|
+
signal: budget.signal,
|
|
747
|
+
})
|
|
748
|
+
} catch (error) {
|
|
749
|
+
if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
750
|
+
if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
|
|
751
|
+
throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, 'upstream-unreachable')
|
|
719
752
|
}
|
|
720
|
-
throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
|
|
721
|
-
} finally {
|
|
722
|
-
budget.dispose()
|
|
723
|
-
}
|
|
724
753
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
754
|
+
let payload: unknown
|
|
755
|
+
try {
|
|
756
|
+
// Budget stays armed through the body read (same rationale as the OpenAI path).
|
|
757
|
+
payload = await response.json()
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (isBudgetTimeout(error)) throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
760
|
+
if (options.signal?.aborted === true) throw new ImageGenError('任务已取消', 'cancelled')
|
|
761
|
+
throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
|
|
762
|
+
}
|
|
763
|
+
if (!response.ok || payload === null || typeof payload !== 'object') {
|
|
764
|
+
throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
|
|
765
|
+
}
|
|
734
766
|
|
|
735
767
|
// output.choices[].message.content[] mixes text and { image: url } items.
|
|
736
768
|
const record = payload as Record<string, unknown>
|
|
@@ -759,6 +791,9 @@ async function generateQwenImage(
|
|
|
759
791
|
return { b64: normalized.b64, mime: normalized.mime }
|
|
760
792
|
}))
|
|
761
793
|
return { images }
|
|
794
|
+
} finally {
|
|
795
|
+
budget.dispose()
|
|
796
|
+
}
|
|
762
797
|
}
|
|
763
798
|
|
|
764
799
|
/**
|
package/src/gallery-store.ts
CHANGED
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { promises as fs } from 'node:fs'
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
|
-
import { homedir } from 'node:os'
|
|
14
13
|
import path from 'node:path'
|
|
15
14
|
import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
16
15
|
import { notifyImageSaved } from './storage-sync.ts'
|
|
16
|
+
import { imageDataRoot } from './image-storage-path.ts'
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
function historyDir(): string { return imageDataRoot() }
|
|
19
|
+
function galleryDir(): string { return path.join(imageDataRoot(), 'gallery') }
|
|
20
|
+
function indexPath(): string { return path.join(galleryDir(), 'index.json') }
|
|
21
|
+
function imagesDir(): string { return path.join(galleryDir(), 'images') }
|
|
22
22
|
|
|
23
23
|
/** One gallery entry carries the same wire shape as a history entry. */
|
|
24
24
|
export interface GalleryAppendResult {
|
|
@@ -114,13 +114,13 @@ function fingerprint(input: HistoryEntryInput): string | undefined {
|
|
|
114
114
|
|
|
115
115
|
/** Ensure the storage directories exist. */
|
|
116
116
|
async function ensureDirs(): Promise<void> {
|
|
117
|
-
await fs.mkdir(
|
|
117
|
+
await fs.mkdir(imagesDir(), { recursive: true })
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/** Read the index, tolerating a missing/corrupt file. */
|
|
121
121
|
async function readIndex(): Promise<StoredEntry[]> {
|
|
122
122
|
try {
|
|
123
|
-
const raw = await fs.readFile(
|
|
123
|
+
const raw = await fs.readFile(indexPath(), 'utf8')
|
|
124
124
|
const parsed: unknown = JSON.parse(raw)
|
|
125
125
|
if (parsed === null || typeof parsed !== 'object') return []
|
|
126
126
|
const entries = (parsed as { entries?: unknown }).entries
|
|
@@ -135,9 +135,9 @@ async function readIndex(): Promise<StoredEntry[]> {
|
|
|
135
135
|
async function writeIndex(entries: StoredEntry[]): Promise<void> {
|
|
136
136
|
await ensureDirs()
|
|
137
137
|
const payload: IndexFile = { entries }
|
|
138
|
-
const tmp = `${
|
|
138
|
+
const tmp = `${indexPath()}.tmp-${process.pid}`
|
|
139
139
|
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
140
|
-
await fs.rename(tmp,
|
|
140
|
+
await fs.rename(tmp, indexPath())
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/** Structural guard for a stored entry. */
|
|
@@ -164,7 +164,7 @@ function isStoredEntry(value: unknown): value is StoredEntry {
|
|
|
164
164
|
/** Remove one entry's image files (best effort). */
|
|
165
165
|
async function removeEntryFiles(entry: StoredEntry): Promise<void> {
|
|
166
166
|
for (const image of entry.images) {
|
|
167
|
-
try { await fs.rm(path.join(
|
|
167
|
+
try { await fs.rm(path.join(imagesDir(), image.file), { force: true }) } catch { /* ignore */ }
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -223,8 +223,8 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
|
|
|
223
223
|
for (let index = 0; index < input.images.length; index++) {
|
|
224
224
|
const image = input.images[index]!
|
|
225
225
|
const file = `${prefix}-${index}.${extensionOf(image.mime)}`
|
|
226
|
-
await fs.writeFile(path.join(
|
|
227
|
-
notifyImageSaved('gallery', path.join(
|
|
226
|
+
await fs.writeFile(path.join(imagesDir(), file), Buffer.from(image.b64, 'base64'))
|
|
227
|
+
notifyImageSaved('gallery', path.join(imagesDir(), file))
|
|
228
228
|
storedImages.push({
|
|
229
229
|
file,
|
|
230
230
|
mime: image.mime,
|
|
@@ -303,7 +303,7 @@ export async function readGalleryImage(file: string): Promise<{ data: Buffer; mi
|
|
|
303
303
|
// store writes — so the route can never escape the images directory.
|
|
304
304
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
|
|
305
305
|
try {
|
|
306
|
-
const data = await fs.readFile(path.join(
|
|
306
|
+
const data = await fs.readFile(path.join(imagesDir(), file))
|
|
307
307
|
return { data, mime: mimeOfFile(file) }
|
|
308
308
|
} catch {
|
|
309
309
|
return undefined
|
|
@@ -38,8 +38,8 @@ export class ImageGenerationRuntime {
|
|
|
38
38
|
private readonly resolve: () => ChannelsView,
|
|
39
39
|
private readonly history: HistorySink = { append: appendHistory },
|
|
40
40
|
) {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// Every task runs in parallel up to this small host-wide limit; a
|
|
42
|
+
// four-model comparison fits within it in a single wave.
|
|
43
43
|
this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4)
|
|
44
44
|
}
|
|
45
45
|
|
package/src/history-store.ts
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { promises as fs } from 'node:fs'
|
|
12
|
-
import { homedir } from 'node:os'
|
|
13
12
|
import path from 'node:path'
|
|
14
13
|
import { HISTORY_MAX, type GenerateMode, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
|
|
15
14
|
import { notifyImageSaved } from './storage-sync.ts'
|
|
15
|
+
import { imageDataRoot } from './image-storage-path.ts'
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
function historyDir(): string { return imageDataRoot() }
|
|
18
|
+
function indexPath(): string { return path.join(historyDir(), 'index.json') }
|
|
19
|
+
function imagesDir(): string { return path.join(historyDir(), 'images') }
|
|
20
20
|
|
|
21
21
|
// History mutations read and replace one shared index. Serialize them so
|
|
22
22
|
// overlapping requests cannot each read an old index and lose the other's row.
|
|
@@ -95,13 +95,13 @@ function safeId(id: string): string {
|
|
|
95
95
|
|
|
96
96
|
/** Ensure the storage directories exist. */
|
|
97
97
|
async function ensureDirs(): Promise<void> {
|
|
98
|
-
await fs.mkdir(
|
|
98
|
+
await fs.mkdir(imagesDir(), { recursive: true })
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/** Read the index, tolerating a missing/corrupt file. */
|
|
102
102
|
async function readIndex(): Promise<StoredEntry[]> {
|
|
103
103
|
try {
|
|
104
|
-
const raw = await fs.readFile(
|
|
104
|
+
const raw = await fs.readFile(indexPath(), 'utf8')
|
|
105
105
|
const parsed: unknown = JSON.parse(raw)
|
|
106
106
|
if (parsed === null || typeof parsed !== 'object') return []
|
|
107
107
|
const entries = (parsed as { entries?: unknown }).entries
|
|
@@ -116,9 +116,9 @@ async function readIndex(): Promise<StoredEntry[]> {
|
|
|
116
116
|
async function writeIndex(entries: StoredEntry[]): Promise<void> {
|
|
117
117
|
await ensureDirs()
|
|
118
118
|
const payload: IndexFile = { entries }
|
|
119
|
-
const tmp = `${
|
|
119
|
+
const tmp = `${indexPath()}.tmp-${process.pid}`
|
|
120
120
|
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
121
|
-
await fs.rename(tmp,
|
|
121
|
+
await fs.rename(tmp, indexPath())
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/** Structural guard for a stored entry. */
|
|
@@ -145,7 +145,7 @@ function isStoredEntry(value: unknown): value is StoredEntry {
|
|
|
145
145
|
/** Remove one entry's image files (best effort). */
|
|
146
146
|
async function removeEntryFiles(entry: StoredEntry): Promise<void> {
|
|
147
147
|
for (const image of entry.images) {
|
|
148
|
-
try { await fs.rm(path.join(
|
|
148
|
+
try { await fs.rm(path.join(imagesDir(), image.file), { force: true }) } catch { /* ignore */ }
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
@@ -196,8 +196,8 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
|
|
|
196
196
|
for (let index = 0; index < input.images.length; index++) {
|
|
197
197
|
const image = input.images[index]!
|
|
198
198
|
const file = `${prefix}-${index}.${extensionOf(image.mime)}`
|
|
199
|
-
await fs.writeFile(path.join(
|
|
200
|
-
notifyImageSaved('history', path.join(
|
|
199
|
+
await fs.writeFile(path.join(imagesDir(), file), Buffer.from(image.b64, 'base64'))
|
|
200
|
+
notifyImageSaved('history', path.join(imagesDir(), file))
|
|
201
201
|
storedImages.push({
|
|
202
202
|
file,
|
|
203
203
|
mime: image.mime,
|
|
@@ -267,7 +267,7 @@ export async function readHistoryImage(file: string): Promise<{ data: Buffer; mi
|
|
|
267
267
|
// store writes — so the route can never escape the images directory.
|
|
268
268
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
|
|
269
269
|
try {
|
|
270
|
-
const data = await fs.readFile(path.join(
|
|
270
|
+
const data = await fs.readFile(path.join(imagesDir(), file))
|
|
271
271
|
return { data, mime: mimeOfFile(file) }
|
|
272
272
|
} catch {
|
|
273
273
|
return undefined
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_ROOT = path.join(process.env.DSH_HOME?.trim() || path.join(homedir(), '.dsh'), 'dsh-imagegen')
|
|
5
|
+
let root = DEFAULT_ROOT
|
|
6
|
+
|
|
7
|
+
export function imageDataRoot(): string { return root }
|
|
8
|
+
|
|
9
|
+
export function setImageDataRoot(value: string | undefined): void {
|
|
10
|
+
const trimmed = value?.trim()
|
|
11
|
+
root = trimmed === undefined || trimmed === '' ? DEFAULT_ROOT : path.resolve(trimmed)
|
|
12
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ function mimeOfPath(filePath: string): string {
|
|
|
37
37
|
import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
|
|
38
38
|
import { registerAgentImageTools } from './agent-image-tools.ts'
|
|
39
39
|
import { registerEditImageCommand } from './edit-image-command.ts'
|
|
40
|
+
import { setImageDataRoot } from './image-storage-path.ts'
|
|
40
41
|
import { presetById } from './presets.ts'
|
|
41
42
|
|
|
42
43
|
/** Stable cordis plugin name. */
|
|
@@ -89,6 +90,8 @@ export interface Config {
|
|
|
89
90
|
promptApiKey?: string
|
|
90
91
|
/** Chat model used to expand short image prompts. */
|
|
91
92
|
promptModel?: string
|
|
93
|
+
/** Local root for generated/history/gallery/canvas images. Empty keeps the default under DSH_HOME. */
|
|
94
|
+
localStoragePath?: string
|
|
92
95
|
/** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
|
|
93
96
|
storageEnabled?: boolean
|
|
94
97
|
/** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
|
|
@@ -133,6 +136,7 @@ export const Config: z<Config> = z.object({
|
|
|
133
136
|
promptApiUrl: z.string().default(''),
|
|
134
137
|
promptApiKey: z.string().role('secret').default(''),
|
|
135
138
|
promptModel: z.string().default(''),
|
|
139
|
+
localStoragePath: z.string().default(''),
|
|
136
140
|
storageEnabled: z.boolean().default(false),
|
|
137
141
|
storageEndpoint: z.string().default(''),
|
|
138
142
|
storageRegion: z.string().default(''),
|
|
@@ -227,6 +231,7 @@ export function apply(ctx: Context, config?: Config): (() => void) | void {
|
|
|
227
231
|
let current: () => Config = () => config ?? {}
|
|
228
232
|
const resolve = (): EffectiveConfig => {
|
|
229
233
|
const value = current() ?? {}
|
|
234
|
+
setImageDataRoot(value.localStoragePath)
|
|
230
235
|
let channels = normalizeChannels(value.channels)
|
|
231
236
|
// Settings scopes are deep-frozen by the host. Legacy migration adds the
|
|
232
237
|
// synthesized default-channel secret, so always work on a detached copy.
|