dsh-image-gen 0.2.6 → 0.3.1
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/lib/client.js +3084 -314
- package/lib/client.js.map +1 -1
- package/lib/index.js +906 -256
- package/lib/types/client/browser-image-utils.d.ts +10 -0
- package/lib/types/client/conversation-image-revisions.d.ts +25 -0
- package/lib/types/client/conversation-regenerate.d.ts +11 -0
- package/lib/types/client/gallery-store.d.ts +19 -0
- package/lib/types/client/gallery-view.d.ts +27 -5
- package/lib/types/client/image-cache.d.ts +26 -0
- package/lib/types/client/studio-style.d.ts +1 -0
- package/lib/types/client/studio-view.d.ts +30 -0
- package/lib/types/image-route.d.ts +18 -2
- package/lib/types/index.d.ts +2 -1
- package/lib/types/shared.d.ts +84 -0
- package/lib/types/studio-route.d.ts +12 -0
- package/lib/types/studio.d.ts +9 -0
- package/lib/types/workspace-save.d.ts +10 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -3,14 +3,17 @@ import * as dshSettings from "@deepseek-ai/dsh-settings";
|
|
|
3
3
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
import { lstat, mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
//#region lib/types/shared.js
|
|
9
|
-
/** Values shared by the Host and browser Bundle faces. */
|
|
10
9
|
/** Browser route used by the generated-image card. */
|
|
11
10
|
const IMAGE_ROUTE = "/plugins/dsh-image-gen/image";
|
|
12
11
|
/** Browser route used for deleting generated images and workspace files. */
|
|
13
12
|
const DELETE_ROUTE = "/plugins/dsh-image-gen/delete";
|
|
13
|
+
/** Same-origin route used by the browser image workbench. */
|
|
14
|
+
const STUDIO_ROUTE = "/plugins/dsh-image-gen/studio";
|
|
15
|
+
/** Browser route used for saving generated images to workspace on demand. */
|
|
16
|
+
const SAVE_WORKSPACE_ROUTE = "/plugins/dsh-image-gen/save-workspace";
|
|
14
17
|
/** Namespace persisted through DSH Settings. */
|
|
15
18
|
const IMAGE_GENERATION_NAMESPACE = "image-generation";
|
|
16
19
|
/** Supported providers. */
|
|
@@ -21,6 +24,13 @@ const IMAGE_PROVIDERS = [
|
|
|
21
24
|
"dashscope",
|
|
22
25
|
"comfyui"
|
|
23
26
|
];
|
|
27
|
+
/** Providers supported by the first browser workbench release. */
|
|
28
|
+
const CLOUD_IMAGE_PROVIDERS = [
|
|
29
|
+
"google",
|
|
30
|
+
"openai",
|
|
31
|
+
"seedream",
|
|
32
|
+
"dashscope"
|
|
33
|
+
];
|
|
24
34
|
/** Default endpoints and base URLs. */
|
|
25
35
|
const DEFAULT_GOOGLE_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions";
|
|
26
36
|
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
@@ -93,6 +103,21 @@ const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
|
|
|
93
103
|
const SEEDREAM_API_KEY_ENV = "ARK_API_KEY";
|
|
94
104
|
/** DashScope credential reference. */
|
|
95
105
|
const DASHSCOPE_API_KEY_ENV = "DASHSCOPE_API_KEY";
|
|
106
|
+
/** Google tool-level controls. */
|
|
107
|
+
const ASPECT_RATIOS = [
|
|
108
|
+
"1:1",
|
|
109
|
+
"3:2",
|
|
110
|
+
"2:3",
|
|
111
|
+
"4:3",
|
|
112
|
+
"3:4",
|
|
113
|
+
"16:9",
|
|
114
|
+
"9:16"
|
|
115
|
+
];
|
|
116
|
+
const IMAGE_SIZES = [
|
|
117
|
+
"1K",
|
|
118
|
+
"2K",
|
|
119
|
+
"4K"
|
|
120
|
+
];
|
|
96
121
|
/** Cordis configuration schema. */
|
|
97
122
|
const Config = z.object({
|
|
98
123
|
provider: z.union(IMAGE_PROVIDERS).default("google"),
|
|
@@ -212,8 +237,8 @@ function prepareComfyUIWorkflow(workflowJson, prompt, seed = randomSeed(), image
|
|
|
212
237
|
};
|
|
213
238
|
let imageInputs = 0;
|
|
214
239
|
const prepared = Object.fromEntries(Object.entries(workflow).map(([nodeId, value]) => {
|
|
215
|
-
const node = record$
|
|
216
|
-
const inputs = node === void 0 ? void 0 : record$
|
|
240
|
+
const node = record$5(value);
|
|
241
|
+
const inputs = node === void 0 ? void 0 : record$5(node.inputs);
|
|
217
242
|
if (inputs === void 0) return [nodeId, value];
|
|
218
243
|
const nextInputs = inject(inputs);
|
|
219
244
|
if (isImagePlaceholder(nextInputs[IMAGE_INPUT_KEY])) {
|
|
@@ -249,7 +274,7 @@ function parseWorkflow(workflowJson) {
|
|
|
249
274
|
}
|
|
250
275
|
if (!isRecord(value) || Object.keys(value).length === 0) throw new Error("ComfyUI workflow must be a non-empty API-format JSON object");
|
|
251
276
|
if (!Object.values(value).some((node) => {
|
|
252
|
-
const inputs = record$
|
|
277
|
+
const inputs = record$5(node)?.inputs;
|
|
253
278
|
return inputs !== void 0 && containsPromptPlaceholder(inputs);
|
|
254
279
|
})) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} (or ${LEGACY_PROMPT_PLACEHOLDER}) in a text input`);
|
|
255
280
|
return value;
|
|
@@ -265,7 +290,7 @@ function containsPromptPlaceholder(value) {
|
|
|
265
290
|
function isRecord(value) {
|
|
266
291
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
267
292
|
}
|
|
268
|
-
function record$
|
|
293
|
+
function record$5(value) {
|
|
269
294
|
return isRecord(value) ? value : void 0;
|
|
270
295
|
}
|
|
271
296
|
//#endregion
|
|
@@ -373,9 +398,9 @@ async function waitForOutput(baseURL, promptId, signal) {
|
|
|
373
398
|
});
|
|
374
399
|
const text = await readBoundedText$3(response, MAX_HISTORY_BYTES);
|
|
375
400
|
if (!response.ok) throw new Error(`ComfyUI history request failed (${response.status}): ${text.slice(0, ERROR_LIMIT$3)}`);
|
|
376
|
-
const entry = record$
|
|
401
|
+
const entry = record$4(parseJsonRecord(text, "ComfyUI history returned invalid JSON")[promptId]);
|
|
377
402
|
if (entry !== void 0) {
|
|
378
|
-
const status = record$
|
|
403
|
+
const status = record$4(entry.status);
|
|
379
404
|
if (status?.status_str === "error") throw new Error(`ComfyUI workflow failed: ${JSON.stringify(status.messages ?? status).slice(0, ERROR_LIMIT$3)}`);
|
|
380
405
|
const output = firstOutputImage(entry.outputs);
|
|
381
406
|
if (output !== void 0) return output;
|
|
@@ -385,13 +410,13 @@ async function waitForOutput(baseURL, promptId, signal) {
|
|
|
385
410
|
}
|
|
386
411
|
}
|
|
387
412
|
function firstOutputImage(value) {
|
|
388
|
-
const outputs = record$
|
|
413
|
+
const outputs = record$4(value);
|
|
389
414
|
if (outputs === void 0) return void 0;
|
|
390
415
|
for (const nodeOutput of Object.values(outputs)) {
|
|
391
|
-
const images = record$
|
|
416
|
+
const images = record$4(nodeOutput)?.images;
|
|
392
417
|
if (!Array.isArray(images)) continue;
|
|
393
418
|
for (const image of images) {
|
|
394
|
-
const item = record$
|
|
419
|
+
const item = record$4(image);
|
|
395
420
|
if (typeof item?.filename !== "string" || item.filename.length === 0) continue;
|
|
396
421
|
const output = {
|
|
397
422
|
filename: item.filename,
|
|
@@ -412,7 +437,7 @@ async function downloadOutput(baseURL, output, maxBytes, signal) {
|
|
|
412
437
|
signal
|
|
413
438
|
});
|
|
414
439
|
if (!response.ok) throw new Error(`ComfyUI image download failed (${response.status})`);
|
|
415
|
-
const mediaType = imageMediaType$
|
|
440
|
+
const mediaType = imageMediaType$4(response.headers.get("content-type")) ?? imageMediaTypeFromName(output.filename);
|
|
416
441
|
if (mediaType === void 0) throw new Error("ComfyUI image download returned an unsupported content type");
|
|
417
442
|
return {
|
|
418
443
|
data: await readBoundedBytes$2(response, maxBytes),
|
|
@@ -432,7 +457,7 @@ function comfyUIBaseURL(value) {
|
|
|
432
457
|
function endpoint(baseURL, path) {
|
|
433
458
|
return new URL(path, baseURL);
|
|
434
459
|
}
|
|
435
|
-
function record$
|
|
460
|
+
function record$4(value) {
|
|
436
461
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
437
462
|
}
|
|
438
463
|
function parseJsonRecord(text, message) {
|
|
@@ -442,11 +467,11 @@ function parseJsonRecord(text, message) {
|
|
|
442
467
|
} catch {
|
|
443
468
|
throw new Error(message);
|
|
444
469
|
}
|
|
445
|
-
const parsed = record$
|
|
470
|
+
const parsed = record$4(value);
|
|
446
471
|
if (parsed === void 0) throw new Error(message);
|
|
447
472
|
return parsed;
|
|
448
473
|
}
|
|
449
|
-
function imageMediaType$
|
|
474
|
+
function imageMediaType$4(value) {
|
|
450
475
|
const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
|
|
451
476
|
return mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : void 0;
|
|
452
477
|
}
|
|
@@ -601,17 +626,28 @@ function generateGoogleImage(input) {
|
|
|
601
626
|
}
|
|
602
627
|
/** Send one native Google image-editing request using already-resolved bytes. */
|
|
603
628
|
function editGoogleImage(input) {
|
|
629
|
+
const interactionInput = input.sourceImages.length === 1 ? [{
|
|
630
|
+
type: "image",
|
|
631
|
+
mime_type: input.sourceImages[0].mediaType,
|
|
632
|
+
data: Buffer.from(input.sourceImages[0].data).toString("base64")
|
|
633
|
+
}, {
|
|
634
|
+
type: "text",
|
|
635
|
+
text: input.prompt
|
|
636
|
+
}] : [...input.sourceImages.flatMap((sourceImage, index) => [{
|
|
637
|
+
type: "text",
|
|
638
|
+
text: `图 ${index + 1} (Image ${index + 1}):`
|
|
639
|
+
}, {
|
|
640
|
+
type: "image",
|
|
641
|
+
mime_type: sourceImage.mediaType,
|
|
642
|
+
data: Buffer.from(sourceImage.data).toString("base64")
|
|
643
|
+
}]), {
|
|
644
|
+
type: "text",
|
|
645
|
+
text: input.prompt
|
|
646
|
+
}];
|
|
604
647
|
return requestGoogleImage({
|
|
605
648
|
...input,
|
|
606
649
|
operation: "editing",
|
|
607
|
-
interactionInput
|
|
608
|
-
type: "text",
|
|
609
|
-
text: input.prompt
|
|
610
|
-
}, ...input.sourceImages.map((sourceImage) => ({
|
|
611
|
-
type: "image",
|
|
612
|
-
mime_type: sourceImage.mediaType,
|
|
613
|
-
data: Buffer.from(sourceImage.data).toString("base64")
|
|
614
|
-
}))]
|
|
650
|
+
interactionInput
|
|
615
651
|
});
|
|
616
652
|
}
|
|
617
653
|
/** Shared Google request, response parsing, decoding, and size enforcement. */
|
|
@@ -656,13 +692,13 @@ async function requestGoogleImage(input) {
|
|
|
656
692
|
};
|
|
657
693
|
}
|
|
658
694
|
function outputImage(value) {
|
|
659
|
-
const interaction = record$
|
|
695
|
+
const interaction = record$3(value);
|
|
660
696
|
if (interaction === void 0) return void 0;
|
|
661
697
|
const direct = imageContent(interaction.output_image, false);
|
|
662
698
|
if (direct !== void 0) return direct;
|
|
663
699
|
if (!Array.isArray(interaction.steps)) return void 0;
|
|
664
700
|
for (const step of interaction.steps) {
|
|
665
|
-
const modelOutput = record$
|
|
701
|
+
const modelOutput = record$3(step);
|
|
666
702
|
if (modelOutput?.type !== "model_output" || !Array.isArray(modelOutput.content)) continue;
|
|
667
703
|
for (const content of modelOutput.content) {
|
|
668
704
|
const image = imageContent(content, true);
|
|
@@ -671,14 +707,14 @@ function outputImage(value) {
|
|
|
671
707
|
}
|
|
672
708
|
}
|
|
673
709
|
function imageContent(value, requiresImageType) {
|
|
674
|
-
const image = record$
|
|
710
|
+
const image = record$3(value);
|
|
675
711
|
if (image === void 0 || requiresImageType && image.type !== "image" || typeof image.data !== "string") return void 0;
|
|
676
712
|
return {
|
|
677
713
|
data: image.data,
|
|
678
714
|
mime_type: image.mime_type
|
|
679
715
|
};
|
|
680
716
|
}
|
|
681
|
-
function record$
|
|
717
|
+
function record$3(value) {
|
|
682
718
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
683
719
|
}
|
|
684
720
|
function mediaTypeOf(value) {
|
|
@@ -822,9 +858,9 @@ function findReferenceImage(messages, sourceAttachmentId) {
|
|
|
822
858
|
}
|
|
823
859
|
/** Validate an untrusted serialized image reference at an HTTP/UI boundary. */
|
|
824
860
|
function parseImageAttachmentRef(value) {
|
|
825
|
-
const ref = record$
|
|
861
|
+
const ref = record$2(value);
|
|
826
862
|
if (ref === void 0) return void 0;
|
|
827
|
-
if (typeof ref.attachmentId !== "string" || !imageMediaType$
|
|
863
|
+
if (typeof ref.attachmentId !== "string" || !imageMediaType$3(ref.mediaType)) return void 0;
|
|
828
864
|
if (!nonNegativeInteger(ref.bytes) || !positiveInteger(ref.width) || !positiveInteger(ref.height)) return void 0;
|
|
829
865
|
if (ref.name !== void 0 && typeof ref.name !== "string") return void 0;
|
|
830
866
|
const originalDimensions = ref.originalDimensions === void 0 ? void 0 : parseDimensions(ref.originalDimensions);
|
|
@@ -854,14 +890,14 @@ function findInBlocks(blocks, sourceAttachmentId) {
|
|
|
854
890
|
}
|
|
855
891
|
}
|
|
856
892
|
function parseDimensions(value) {
|
|
857
|
-
const dimensions = record$
|
|
893
|
+
const dimensions = record$2(value);
|
|
858
894
|
if (dimensions === void 0 || !positiveInteger(dimensions.width) || !positiveInteger(dimensions.height)) return void 0;
|
|
859
895
|
return {
|
|
860
896
|
width: dimensions.width,
|
|
861
897
|
height: dimensions.height
|
|
862
898
|
};
|
|
863
899
|
}
|
|
864
|
-
function imageMediaType$
|
|
900
|
+
function imageMediaType$3(value) {
|
|
865
901
|
return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
|
|
866
902
|
}
|
|
867
903
|
function collectInBlocks(blocks) {
|
|
@@ -926,27 +962,251 @@ function positiveInteger(value) {
|
|
|
926
962
|
function nonNegativeInteger(value) {
|
|
927
963
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
928
964
|
}
|
|
929
|
-
function record$
|
|
965
|
+
function record$2(value) {
|
|
930
966
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
931
967
|
}
|
|
932
968
|
//#endregion
|
|
969
|
+
//#region lib/types/workspace-save.js
|
|
970
|
+
/** Persist one generated image as a file under the session workspace. */
|
|
971
|
+
/** File extension for each supported image media type. */
|
|
972
|
+
const EXTENSION = {
|
|
973
|
+
"image/png": "png",
|
|
974
|
+
"image/jpeg": "jpg",
|
|
975
|
+
"image/webp": "webp",
|
|
976
|
+
"image/gif": "gif"
|
|
977
|
+
};
|
|
978
|
+
/**
|
|
979
|
+
* Build the deterministic file name for a generated image:
|
|
980
|
+
* `image-<digest-prefix>.<ext>`. The digest prefix comes from the
|
|
981
|
+
* content-addressed attachment id, so the same image bytes always map to the
|
|
982
|
+
* same file name regardless of when they were generated, and re-saving simply
|
|
983
|
+
* overwrites the previous copy in place.
|
|
984
|
+
* @param attachmentId - durable attachment id (`sha256:<hex>`).
|
|
985
|
+
* @param mediaType - verified image media type.
|
|
986
|
+
* @returns the file name (no directory).
|
|
987
|
+
*/
|
|
988
|
+
function workspaceImageName(attachmentId, mediaType) {
|
|
989
|
+
return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Resolve the configured image folder inside the session workspace. The
|
|
993
|
+
* folder may nest, but must stay inside the workspace: absolute paths and
|
|
994
|
+
* parent-traversal segments are rejected for both separator styles.
|
|
995
|
+
*
|
|
996
|
+
* This lexical pass is necessary but not sufficient: `saveImageToWorkspace`
|
|
997
|
+
* additionally verifies the on-disk resolution so symlinked folders cannot
|
|
998
|
+
* escape the workspace.
|
|
999
|
+
* @param workspaceRoot - the session workspace directory.
|
|
1000
|
+
* @param folder - configured subfolder; empty/blank means the workspace root.
|
|
1001
|
+
* @returns the absolute image directory.
|
|
1002
|
+
* @throws when the folder would escape the workspace root.
|
|
1003
|
+
*/
|
|
1004
|
+
function workspaceImageDir(workspaceRoot, folder) {
|
|
1005
|
+
const trimmed = (folder ?? "").trim();
|
|
1006
|
+
const root = resolve(workspaceRoot);
|
|
1007
|
+
const dir = trimmed === "" ? root : resolve(root, trimmed);
|
|
1008
|
+
if (!containsPath(root, dir)) throw new Error(`image workspace folder '${folder}' must stay inside the session workspace`);
|
|
1009
|
+
return dir;
|
|
1010
|
+
}
|
|
1011
|
+
/** True when `child` equals `parent` or lives underneath it (lexically). */
|
|
1012
|
+
function containsPath(parent, child) {
|
|
1013
|
+
const rel = relative(parent, child);
|
|
1014
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Real path of the closest existing ancestor of `dir` (inclusive). Walking up
|
|
1018
|
+
* lets us validate symlinked folder segments before creating anything under
|
|
1019
|
+
* them.
|
|
1020
|
+
*/
|
|
1021
|
+
async function nearestExistingRealPath(dir) {
|
|
1022
|
+
let probe = dir;
|
|
1023
|
+
for (;;) try {
|
|
1024
|
+
return await realpath(probe);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1027
|
+
const parent = dirname(probe);
|
|
1028
|
+
if (parent === probe) throw error;
|
|
1029
|
+
probe = parent;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/** Reject a directory whose on-disk resolution lands outside the workspace. */
|
|
1033
|
+
function assertInsideWorkspace(realRoot, candidate, folder) {
|
|
1034
|
+
if (containsPath(realRoot, candidate)) return;
|
|
1035
|
+
throw new Error(`image workspace folder '${folder ?? ""}' must stay inside the session workspace (${candidate} resolves outside ${realRoot})`);
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Write one generated image durably under the session workspace.
|
|
1039
|
+
*
|
|
1040
|
+
* Containment is enforced twice: lexically by `workspaceImageDir`, then
|
|
1041
|
+
* against real paths, so a configured folder (or any intermediate segment)
|
|
1042
|
+
* that is a symlink pointing outside the workspace is rejected before and
|
|
1043
|
+
* after anything is created.
|
|
1044
|
+
*
|
|
1045
|
+
* The bytes are written to a same-directory staging file and renamed onto the
|
|
1046
|
+
* target, so a crash never leaves a half-written image under its final name.
|
|
1047
|
+
* Re-saving identical bytes rewrites the same file (the name is content-
|
|
1048
|
+
* addressed), which keeps repeated generations idempotent. A cancellation is
|
|
1049
|
+
* honoured up to and including the final rename: an aborted save never
|
|
1050
|
+
* resolves successfully and never leaves the image behind under its final
|
|
1051
|
+
* name.
|
|
1052
|
+
* @param options - workspace root, configured folder, attachment identity, and image bytes.
|
|
1053
|
+
* @returns the absolute path of the written file.
|
|
1054
|
+
*/
|
|
1055
|
+
async function saveImageToWorkspace(options) {
|
|
1056
|
+
const dir = workspaceImageDir(options.workspaceRoot, options.folder);
|
|
1057
|
+
options.signal?.throwIfAborted();
|
|
1058
|
+
const realRoot = await realpath(resolve(options.workspaceRoot));
|
|
1059
|
+
assertInsideWorkspace(realRoot, await nearestExistingRealPath(dir), options.folder);
|
|
1060
|
+
const name = workspaceImageName(options.attachmentId, options.mediaType);
|
|
1061
|
+
const target = join(dir, name);
|
|
1062
|
+
const staging = join(dir, `.${name}.${process.pid}-${randomUUID()}.tmp`);
|
|
1063
|
+
await mkdir(dir, { recursive: true });
|
|
1064
|
+
assertInsideWorkspace(realRoot, await realpath(dir), options.folder);
|
|
1065
|
+
try {
|
|
1066
|
+
await writeFile(staging, options.data, {
|
|
1067
|
+
flag: "wx",
|
|
1068
|
+
signal: options.signal
|
|
1069
|
+
});
|
|
1070
|
+
options.signal?.throwIfAborted();
|
|
1071
|
+
await rename(staging, target);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
await unlink(staging).catch(() => {});
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
options.signal?.throwIfAborted();
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
await unlink(target).catch(() => {});
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
return target;
|
|
1083
|
+
}
|
|
1084
|
+
/** Regex pattern strictly matching generated image file names (e.g. image-01234567.png). */
|
|
1085
|
+
const GENERATED_IMAGE_NAME_REGEX = /^image-[0-9a-f]{8,}\.(png|jpg|jpeg|webp|gif)$/i;
|
|
1086
|
+
/** Discover all DSH workspace paths recorded in local application storage. */
|
|
1087
|
+
async function getDshWorkspaceRoots() {
|
|
1088
|
+
const roots = [];
|
|
1089
|
+
try {
|
|
1090
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
1091
|
+
if (home) {
|
|
1092
|
+
const workspaceJsonPath = join(home, ".dsh", "storages", "workspace.json");
|
|
1093
|
+
const content = await readFile(workspaceJsonPath, "utf8");
|
|
1094
|
+
const workspaces = JSON.parse(content)?.tables?.workspaces;
|
|
1095
|
+
if (workspaces && typeof workspaces === "object") {
|
|
1096
|
+
for (const ws of Object.values(workspaces)) if (ws && typeof ws === "object" && "path" in ws && typeof ws.path === "string") {
|
|
1097
|
+
const p = ws.path;
|
|
1098
|
+
if (p.trim()) roots.push(p.trim());
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
} catch {}
|
|
1103
|
+
return roots;
|
|
1104
|
+
}
|
|
1105
|
+
/** Discover detailed workspace records from DSH workspace.json storage. */
|
|
1106
|
+
async function getDshWorkspacesFull() {
|
|
1107
|
+
const list = [];
|
|
1108
|
+
try {
|
|
1109
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
1110
|
+
if (home) {
|
|
1111
|
+
const workspaceJsonPath = join(home, ".dsh", "storages", "workspace.json");
|
|
1112
|
+
const content = await readFile(workspaceJsonPath, "utf8");
|
|
1113
|
+
const workspaces = JSON.parse(content)?.tables?.workspaces;
|
|
1114
|
+
if (workspaces && typeof workspaces === "object") for (const [id, ws] of Object.entries(workspaces)) {
|
|
1115
|
+
const raw = ws;
|
|
1116
|
+
if (raw && typeof raw.path === "string") {
|
|
1117
|
+
const p = raw.path.trim();
|
|
1118
|
+
if (!p) continue;
|
|
1119
|
+
const title = typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : basename(p);
|
|
1120
|
+
const rawSessions = raw.sessionIds;
|
|
1121
|
+
const sessionIds = Array.isArray(rawSessions) ? rawSessions.filter((s) => typeof s === "string") : [];
|
|
1122
|
+
list.push({
|
|
1123
|
+
workspaceId: id,
|
|
1124
|
+
path: p,
|
|
1125
|
+
title,
|
|
1126
|
+
sessionIds
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
} catch {}
|
|
1132
|
+
return list;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Verify whether a candidate path lives within an allowed DSH workspace root.
|
|
1136
|
+
* Resolves symlinks via `realpath` to prevent symlink traversal attacks.
|
|
1137
|
+
* @returns the canonical realpath of the candidate root.
|
|
1138
|
+
* @throws when candidateRoot does not exist or falls outside the allowed roots.
|
|
1139
|
+
*/
|
|
1140
|
+
async function assertWorkspaceAllowed(candidateRoot, allowedRoots) {
|
|
1141
|
+
const realCandidate = await realpath(resolve(candidateRoot));
|
|
1142
|
+
const rootsToCheck = /* @__PURE__ */ new Set();
|
|
1143
|
+
for (const r of allowedRoots) if (typeof r === "string" && r.trim()) try {
|
|
1144
|
+
rootsToCheck.add(await realpath(resolve(r)));
|
|
1145
|
+
} catch {}
|
|
1146
|
+
for (const allowed of rootsToCheck) if (containsPath(allowed, realCandidate)) return realCandidate;
|
|
1147
|
+
throw new Error(`Directory '${candidateRoot}' is not within an allowed DSH workspace`);
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Safely delete a generated image file from workspace disk by its path.
|
|
1151
|
+
* Enforces strict safety gates:
|
|
1152
|
+
* 1. Base name must match generated image pattern (`image-<hex>.<ext>`) to avoid touching user files.
|
|
1153
|
+
* 2. Path must be a regular file and NOT a symbolic link.
|
|
1154
|
+
* 3. Real canonical path's basename must also match the pattern.
|
|
1155
|
+
* 4. Real canonical path must reside within an allowed workspace root (or process.cwd()).
|
|
1156
|
+
*/
|
|
1157
|
+
async function deleteImageFromWorkspace(filePath, allowedWorkspaceRoots) {
|
|
1158
|
+
const resolved = resolve(filePath);
|
|
1159
|
+
const base = resolved.split(/[/\\]/).pop() ?? "";
|
|
1160
|
+
if (!GENERATED_IMAGE_NAME_REGEX.test(base)) return false;
|
|
1161
|
+
try {
|
|
1162
|
+
const fileLstat = await lstat(resolved);
|
|
1163
|
+
if (fileLstat.isSymbolicLink() || !fileLstat.isFile()) return false;
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
if (err?.code === "ENOENT") return true;
|
|
1166
|
+
throw err;
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
const realFile = await realpath(resolved);
|
|
1170
|
+
const realBase = realFile.split(/[/\\]/).pop() ?? "";
|
|
1171
|
+
if (!GENERATED_IMAGE_NAME_REGEX.test(realBase)) return false;
|
|
1172
|
+
const rootsToCheck = /* @__PURE__ */ new Set();
|
|
1173
|
+
if (allowedWorkspaceRoots) {
|
|
1174
|
+
for (const r of allowedWorkspaceRoots) if (typeof r === "string" && r.trim()) rootsToCheck.add(resolve(r));
|
|
1175
|
+
}
|
|
1176
|
+
rootsToCheck.add(resolve(process.cwd()));
|
|
1177
|
+
let withinAllowed = false;
|
|
1178
|
+
for (const root of rootsToCheck) try {
|
|
1179
|
+
if (containsPath(await realpath(root), realFile)) {
|
|
1180
|
+
withinAllowed = true;
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
} catch {}
|
|
1184
|
+
if (!withinAllowed) return false;
|
|
1185
|
+
await unlink(realFile);
|
|
1186
|
+
return true;
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
if (err?.code === "ENOENT") return true;
|
|
1189
|
+
throw err;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
933
1193
|
//#region lib/types/image-route.js
|
|
934
1194
|
const MAX_BODY_BYTES = 65536;
|
|
935
1195
|
/** Serve one verified durable image reference to a same-origin browser request. */
|
|
936
1196
|
async function serveImage(req, res, deps) {
|
|
937
|
-
if (req.method !== "POST") return jsonError(res, 405, "method-not-allowed");
|
|
938
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError(res, 415, "json-required");
|
|
1197
|
+
if (req.method !== "POST") return jsonError$1(res, 405, "method-not-allowed");
|
|
1198
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$1(res, 415, "json-required");
|
|
939
1199
|
const origin = req.headers.origin;
|
|
940
1200
|
const host = req.headers.host;
|
|
941
|
-
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError(res, 403, "origin-rejected");
|
|
1201
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$1(res, 403, "origin-rejected");
|
|
942
1202
|
let body;
|
|
943
1203
|
try {
|
|
944
|
-
body = JSON.parse(await readBody(req));
|
|
1204
|
+
body = JSON.parse(await readBody$1(req));
|
|
945
1205
|
} catch {
|
|
946
|
-
return jsonError(res, 400, "invalid-request");
|
|
1206
|
+
return jsonError$1(res, 400, "invalid-request");
|
|
947
1207
|
}
|
|
948
1208
|
const attachment = attachmentFromRequest(body);
|
|
949
|
-
if (attachment === void 0) return jsonError(res, 400, "invalid-attachment");
|
|
1209
|
+
if (attachment === void 0) return jsonError$1(res, 400, "invalid-attachment");
|
|
950
1210
|
try {
|
|
951
1211
|
const stored = await deps.readImage(attachment);
|
|
952
1212
|
res.writeHead(200, {
|
|
@@ -957,24 +1217,27 @@ async function serveImage(req, res, deps) {
|
|
|
957
1217
|
});
|
|
958
1218
|
res.end(stored.data);
|
|
959
1219
|
} catch {
|
|
960
|
-
jsonError(res, 404, "image-unavailable");
|
|
1220
|
+
jsonError$1(res, 404, "image-unavailable");
|
|
961
1221
|
}
|
|
962
1222
|
}
|
|
963
|
-
|
|
964
|
-
async function serveDelete(req, res, deps) {
|
|
965
|
-
if (req.method !== "POST") return jsonError(res, 405, "method-not-allowed");
|
|
966
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError(res, 415, "json-required");
|
|
1223
|
+
function sameOrigin$1(req) {
|
|
967
1224
|
const origin = req.headers.origin;
|
|
968
1225
|
const host = req.headers.host;
|
|
969
|
-
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return
|
|
1226
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
/** Safely delete one or more generated image files from the workspace disk. */
|
|
1230
|
+
async function serveDelete(req, res, deps) {
|
|
1231
|
+
if (req.method !== "POST") return jsonError$1(res, 405, "method-not-allowed");
|
|
1232
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$1(res, 415, "json-required");
|
|
1233
|
+
if (!sameOrigin$1(req)) return jsonError$1(res, 403, "origin-rejected");
|
|
970
1234
|
let body;
|
|
971
1235
|
try {
|
|
972
|
-
body = JSON.parse(await readBody(req));
|
|
1236
|
+
body = JSON.parse(await readBody$1(req));
|
|
973
1237
|
} catch {
|
|
974
|
-
return jsonError(res, 400, "invalid-request");
|
|
1238
|
+
return jsonError$1(res, 400, "invalid-request");
|
|
975
1239
|
}
|
|
976
1240
|
const rawPaths = typeof body === "object" && body !== null && "paths" in body && Array.isArray(body.paths) ? body.paths : [];
|
|
977
|
-
const rawAttachmentIds = typeof body === "object" && body !== null && "attachmentIds" in body && Array.isArray(body.attachmentIds) ? body.attachmentIds : [];
|
|
978
1241
|
const deletedFiles = [];
|
|
979
1242
|
const failedFiles = [];
|
|
980
1243
|
for (const p of rawPaths) if (typeof p === "string" && p.trim()) try {
|
|
@@ -989,14 +1252,6 @@ async function serveDelete(req, res, deps) {
|
|
|
989
1252
|
error: err instanceof Error ? err.message : String(err)
|
|
990
1253
|
});
|
|
991
1254
|
}
|
|
992
|
-
for (const id of rawAttachmentIds) if (typeof id === "string" && id.trim()) try {
|
|
993
|
-
if (await deps.deleteWorkspaceImageByAttachmentId(id)) deletedFiles.push(id);
|
|
994
|
-
} catch (err) {
|
|
995
|
-
failedFiles.push({
|
|
996
|
-
path: id,
|
|
997
|
-
error: err instanceof Error ? err.message : String(err)
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
1255
|
res.writeHead(200, {
|
|
1001
1256
|
"content-type": "application/json",
|
|
1002
1257
|
"cache-control": "no-store"
|
|
@@ -1008,19 +1263,74 @@ async function serveDelete(req, res, deps) {
|
|
|
1008
1263
|
failedFiles
|
|
1009
1264
|
}));
|
|
1010
1265
|
}
|
|
1266
|
+
/** Serve on-demand image persistence into the workspace. */
|
|
1267
|
+
async function serveSaveWorkspace(req, res, deps) {
|
|
1268
|
+
if (!sameOrigin$1(req)) return jsonError$1(res, 403, "origin-rejected");
|
|
1269
|
+
if (req.method !== "POST") return jsonError$1(res, 405, "method-not-allowed");
|
|
1270
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$1(res, 415, "json-required");
|
|
1271
|
+
try {
|
|
1272
|
+
const raw = JSON.parse(await readBody$1(req));
|
|
1273
|
+
const attachment = parseImageAttachmentRef(raw.attachment);
|
|
1274
|
+
if (!attachment) return jsonError$1(res, 400, "invalid-attachment");
|
|
1275
|
+
if (!deps.isSaveEnabled()) {
|
|
1276
|
+
res.writeHead(200, {
|
|
1277
|
+
"content-type": "application/json",
|
|
1278
|
+
"cache-control": "no-store"
|
|
1279
|
+
});
|
|
1280
|
+
res.end(JSON.stringify({
|
|
1281
|
+
ok: true,
|
|
1282
|
+
savedTo: void 0
|
|
1283
|
+
}));
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const rawTarget = typeof raw.workspaceRoot === "string" && raw.workspaceRoot.trim() ? raw.workspaceRoot.trim() : deps.getActiveWorkspaceRoot();
|
|
1287
|
+
const allowedRoots = await deps.getAllowedWorkspaceRoots();
|
|
1288
|
+
let targetRoot;
|
|
1289
|
+
try {
|
|
1290
|
+
targetRoot = await assertWorkspaceAllowed(rawTarget, allowedRoots);
|
|
1291
|
+
} catch {
|
|
1292
|
+
return jsonError$1(res, 403, "workspace-root-not-allowed");
|
|
1293
|
+
}
|
|
1294
|
+
const stored = await deps.readImage(attachment);
|
|
1295
|
+
if (!stored || !stored.data) return jsonError$1(res, 404, "image-not-found");
|
|
1296
|
+
const savedTo = await deps.saveToWorkspace({
|
|
1297
|
+
workspaceRoot: targetRoot,
|
|
1298
|
+
attachmentId: String(attachment.attachmentId),
|
|
1299
|
+
mediaType: attachment.mediaType,
|
|
1300
|
+
data: stored.data
|
|
1301
|
+
});
|
|
1302
|
+
res.writeHead(200, {
|
|
1303
|
+
"content-type": "application/json",
|
|
1304
|
+
"cache-control": "no-store"
|
|
1305
|
+
});
|
|
1306
|
+
res.end(JSON.stringify({
|
|
1307
|
+
ok: true,
|
|
1308
|
+
savedTo
|
|
1309
|
+
}));
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
res.writeHead(500, {
|
|
1312
|
+
"content-type": "application/json",
|
|
1313
|
+
"cache-control": "no-store"
|
|
1314
|
+
});
|
|
1315
|
+
res.end(JSON.stringify({
|
|
1316
|
+
ok: false,
|
|
1317
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1318
|
+
}));
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1011
1321
|
/** Validate the persisted reference carried by a tool presentation. */
|
|
1012
1322
|
function imageAttachmentFromMeta(meta) {
|
|
1013
|
-
const value = record(meta);
|
|
1323
|
+
const value = record$1(meta);
|
|
1014
1324
|
if (value?.kind !== "dsh-image-gen") return void 0;
|
|
1015
1325
|
return parseImageAttachmentRef(value.attachment);
|
|
1016
1326
|
}
|
|
1017
1327
|
function attachmentFromRequest(value) {
|
|
1018
|
-
return parseImageAttachmentRef(record(value)?.attachment);
|
|
1328
|
+
return parseImageAttachmentRef(record$1(value)?.attachment);
|
|
1019
1329
|
}
|
|
1020
|
-
function record(value) {
|
|
1330
|
+
function record$1(value) {
|
|
1021
1331
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
1022
1332
|
}
|
|
1023
|
-
async function readBody(req) {
|
|
1333
|
+
async function readBody$1(req) {
|
|
1024
1334
|
const chunks = [];
|
|
1025
1335
|
let bytes = 0;
|
|
1026
1336
|
for await (const chunk of req) {
|
|
@@ -1031,7 +1341,7 @@ async function readBody(req) {
|
|
|
1031
1341
|
}
|
|
1032
1342
|
return Buffer.concat(chunks).toString("utf8");
|
|
1033
1343
|
}
|
|
1034
|
-
function jsonError(res, status, code) {
|
|
1344
|
+
function jsonError$1(res, status, code) {
|
|
1035
1345
|
res.writeHead(status, {
|
|
1036
1346
|
"content-type": "application/json",
|
|
1037
1347
|
"cache-control": "no-store"
|
|
@@ -1060,10 +1370,12 @@ async function generateOpenAICompatibleImage(input) {
|
|
|
1060
1370
|
}
|
|
1061
1371
|
async function editOpenAICompatibleImage(input) {
|
|
1062
1372
|
const form = new FormData();
|
|
1063
|
-
const imageField = input.sourceImages.length
|
|
1373
|
+
const imageField = input.sourceImages.length > 1 ? "image[]" : "image";
|
|
1064
1374
|
input.sourceImages.forEach((sourceImage, index) => {
|
|
1065
1375
|
const uploadBytes = new Uint8Array(sourceImage.data);
|
|
1066
|
-
|
|
1376
|
+
const blob = new Blob([uploadBytes], { type: sourceImage.mediaType });
|
|
1377
|
+
const filename = `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`;
|
|
1378
|
+
form.append(imageField, blob, filename);
|
|
1067
1379
|
});
|
|
1068
1380
|
form.append("prompt", input.prompt);
|
|
1069
1381
|
form.append("model", input.model);
|
|
@@ -1089,7 +1401,7 @@ async function parseImageResponse(response, provider, input) {
|
|
|
1089
1401
|
if (image === void 0) throw new Error(`${provider} image request returned no image: ${text.slice(0, ERROR_LIMIT$1)}`);
|
|
1090
1402
|
if (image.b64_json !== void 0) return {
|
|
1091
1403
|
data: decodeBase64$1(image.b64_json, provider),
|
|
1092
|
-
mediaType: imageMediaType$
|
|
1404
|
+
mediaType: imageMediaType$2(image.mime_type) ?? "image/png"
|
|
1093
1405
|
};
|
|
1094
1406
|
return downloadImage$1(image.url, provider, input);
|
|
1095
1407
|
}
|
|
@@ -1124,7 +1436,7 @@ async function downloadImage$1(url, provider, input) {
|
|
|
1124
1436
|
if (parsed === void 0) throw new Error(`${provider} image request returned invalid data URL`);
|
|
1125
1437
|
return {
|
|
1126
1438
|
data: decodeBase64$1(parsed.base64, provider),
|
|
1127
|
-
mediaType: imageMediaType$
|
|
1439
|
+
mediaType: imageMediaType$2(parsed.mediaType) ?? "image/png"
|
|
1128
1440
|
};
|
|
1129
1441
|
}
|
|
1130
1442
|
const response = await fetch(url, {
|
|
@@ -1133,7 +1445,7 @@ async function downloadImage$1(url, provider, input) {
|
|
|
1133
1445
|
...input.apiKey === void 0 ? {} : { headers: { authorization: `Bearer ${input.apiKey}` } }
|
|
1134
1446
|
});
|
|
1135
1447
|
if (!response.ok) throw new Error(`${provider} image download failed (${response.status})`);
|
|
1136
|
-
const mediaType = imageMediaType$
|
|
1448
|
+
const mediaType = imageMediaType$2(response.headers.get("content-type"));
|
|
1137
1449
|
if (mediaType === void 0) throw new Error(`${provider} image download returned unsupported content type`);
|
|
1138
1450
|
return {
|
|
1139
1451
|
data: await readBoundedBytes$1(response, input.maxBytes),
|
|
@@ -1162,7 +1474,7 @@ function decodeBase64$1(value, provider) {
|
|
|
1162
1474
|
if (decoded.length === 0) throw new Error(`${provider} image request returned invalid base64 image data`);
|
|
1163
1475
|
return new Uint8Array(decoded);
|
|
1164
1476
|
}
|
|
1165
|
-
function imageMediaType$
|
|
1477
|
+
function imageMediaType$2(value) {
|
|
1166
1478
|
const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1167
1479
|
return mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : void 0;
|
|
1168
1480
|
}
|
|
@@ -1226,7 +1538,7 @@ async function editSeedreamImage(input) {
|
|
|
1226
1538
|
if (image === void 0) throw new Error(`seedream image editing returned no image: ${text.slice(0, ERROR_LIMIT)}`);
|
|
1227
1539
|
if (image.b64_json !== void 0) return {
|
|
1228
1540
|
data: decodeBase64(image.b64_json),
|
|
1229
|
-
mediaType: imageMediaType(image.mime_type) ?? "image/png"
|
|
1541
|
+
mediaType: imageMediaType$1(image.mime_type) ?? "image/png"
|
|
1230
1542
|
};
|
|
1231
1543
|
return downloadImage(image.url, input);
|
|
1232
1544
|
}
|
|
@@ -1264,7 +1576,7 @@ async function downloadImage(url, input) {
|
|
|
1264
1576
|
signal: input.signal
|
|
1265
1577
|
});
|
|
1266
1578
|
if (!response.ok) throw new Error(`seedream image download failed (${response.status})`);
|
|
1267
|
-
const mediaType = imageMediaType(response.headers.get("content-type"));
|
|
1579
|
+
const mediaType = imageMediaType$1(response.headers.get("content-type"));
|
|
1268
1580
|
if (mediaType === void 0) throw new Error("seedream image download returned unsupported content type");
|
|
1269
1581
|
return {
|
|
1270
1582
|
data: await readBoundedBytes(response, input.maxBytes),
|
|
@@ -1278,7 +1590,7 @@ function decodeBase64(value) {
|
|
|
1278
1590
|
if (decoded.length === 0) throw new Error("seedream image editing returned invalid base64 image data");
|
|
1279
1591
|
return new Uint8Array(decoded);
|
|
1280
1592
|
}
|
|
1281
|
-
function imageMediaType(value) {
|
|
1593
|
+
function imageMediaType$1(value) {
|
|
1282
1594
|
const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1283
1595
|
return mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : void 0;
|
|
1284
1596
|
}
|
|
@@ -1310,202 +1622,500 @@ async function readBoundedBytes(response, maxBytes) {
|
|
|
1310
1622
|
return joined;
|
|
1311
1623
|
}
|
|
1312
1624
|
//#endregion
|
|
1313
|
-
//#region lib/types/
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
"image/webp": "webp",
|
|
1320
|
-
"image/gif": "gif"
|
|
1625
|
+
//#region lib/types/studio.js
|
|
1626
|
+
const PROVIDER_LABELS = {
|
|
1627
|
+
google: "Google",
|
|
1628
|
+
openai: "OpenAI",
|
|
1629
|
+
seedream: "Seedream",
|
|
1630
|
+
dashscope: "DashScope"
|
|
1321
1631
|
};
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1632
|
+
const CREDENTIALS = {
|
|
1633
|
+
google: GOOGLE_API_KEY_ENV,
|
|
1634
|
+
openai: OPENAI_API_KEY_ENV,
|
|
1635
|
+
seedream: SEEDREAM_API_KEY_ENV,
|
|
1636
|
+
dashscope: DASHSCOPE_API_KEY_ENV
|
|
1637
|
+
};
|
|
1638
|
+
const RATIO_LABELS = {
|
|
1639
|
+
auto: "自动",
|
|
1640
|
+
"1:1": "1:1 方形",
|
|
1641
|
+
"3:2": "3:2 横向",
|
|
1642
|
+
"2:3": "2:3 肖像",
|
|
1643
|
+
"4:3": "4:3 横向",
|
|
1644
|
+
"3:4": "3:4 竖向",
|
|
1645
|
+
"16:9": "16:9 宽屏",
|
|
1646
|
+
"9:16": "9:16 竖屏"
|
|
1647
|
+
};
|
|
1648
|
+
/** Return only browser-safe capability data. */
|
|
1649
|
+
async function describeStudio(ctx, config) {
|
|
1650
|
+
const configuredEntries = await Promise.all(CLOUD_IMAGE_PROVIDERS.map(async (provider) => {
|
|
1651
|
+
const credential = await ctx.credentials.resolve(credentialRef(CREDENTIALS[provider]));
|
|
1652
|
+
return [provider, credential !== void 0 && credential.value.trim().length > 0];
|
|
1653
|
+
}));
|
|
1654
|
+
const configured = Object.fromEntries(configuredEntries);
|
|
1655
|
+
const profiles = CLOUD_IMAGE_PROVIDERS.map((provider) => studioProfile(config, provider, configured[provider]));
|
|
1656
|
+
const preferred = config.provider;
|
|
1657
|
+
return {
|
|
1658
|
+
providers: profiles,
|
|
1659
|
+
activeProvider: preferred !== void 0 && cloudProvider$1(preferred) ? preferred : profiles.find((profile) => profile.configured)?.provider ?? "google"
|
|
1660
|
+
};
|
|
1334
1661
|
}
|
|
1335
|
-
/**
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1662
|
+
/** Execute one validated browser workbench request using the existing provider adapters. */
|
|
1663
|
+
async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceRoot) {
|
|
1664
|
+
assertAllowed(studioProfile(config, input.provider, true), input);
|
|
1665
|
+
const active = resolveProvider(providerConfig(config, input.provider, input.model));
|
|
1666
|
+
if (active.provider === "comfyui") throw new Error("ComfyUI 暂未接入工作台");
|
|
1667
|
+
const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
|
|
1668
|
+
if (credential === void 0 || credential.value.trim().length === 0) throw new Error(`${PROVIDER_LABELS[input.provider]} 尚未配置 API Key,请先到设置中配置`);
|
|
1669
|
+
const rawRefs = input.references ?? (input.reference ? [input.reference] : []);
|
|
1670
|
+
if (input.mode === "edit" && rawRefs.length === 0) throw new Error("图生图需要至少一张参考图");
|
|
1671
|
+
const sourceImages = input.mode === "edit" ? await Promise.all(rawRefs.map((ref) => readStudioReference(ctx, ref, signal))) : [];
|
|
1672
|
+
const startedAt = Date.now();
|
|
1673
|
+
const count = input.count ?? 1;
|
|
1674
|
+
const generateSingle = async (index) => {
|
|
1675
|
+
let generated;
|
|
1676
|
+
let output;
|
|
1677
|
+
if (active.provider === "google") {
|
|
1678
|
+
const aspectRatio = input.ratio;
|
|
1679
|
+
const imageSize = input.quality;
|
|
1680
|
+
generated = input.mode === "edit" ? await editGoogleImage({
|
|
1681
|
+
apiKey: credential.value,
|
|
1682
|
+
endpoint: active.endpoint,
|
|
1683
|
+
model: active.model,
|
|
1684
|
+
prompt: input.prompt,
|
|
1685
|
+
sourceImages,
|
|
1686
|
+
aspectRatio,
|
|
1687
|
+
imageSize,
|
|
1688
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1689
|
+
signal
|
|
1690
|
+
}) : await generateGoogleImage({
|
|
1691
|
+
apiKey: credential.value,
|
|
1692
|
+
endpoint: active.endpoint,
|
|
1693
|
+
model: active.model,
|
|
1694
|
+
prompt: input.prompt,
|
|
1695
|
+
aspectRatio,
|
|
1696
|
+
imageSize,
|
|
1697
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1698
|
+
signal
|
|
1699
|
+
});
|
|
1700
|
+
output = `${aspectRatio}, ${imageSize}`;
|
|
1701
|
+
} else if (active.provider === "openai") {
|
|
1702
|
+
const size = openAISize(input.ratio);
|
|
1703
|
+
generated = input.mode === "edit" ? await editOpenAICompatibleImage({
|
|
1704
|
+
apiKey: credential.value,
|
|
1705
|
+
baseURL: active.baseURL,
|
|
1706
|
+
model: active.model,
|
|
1707
|
+
prompt: input.prompt,
|
|
1708
|
+
sourceImages,
|
|
1709
|
+
size,
|
|
1710
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1711
|
+
signal
|
|
1712
|
+
}) : await generateOpenAICompatibleImage({
|
|
1713
|
+
provider: "openai",
|
|
1714
|
+
apiKey: credential.value,
|
|
1715
|
+
baseURL: active.baseURL,
|
|
1716
|
+
model: active.model,
|
|
1717
|
+
prompt: input.prompt,
|
|
1718
|
+
size,
|
|
1719
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1720
|
+
signal
|
|
1721
|
+
});
|
|
1722
|
+
output = size;
|
|
1723
|
+
} else if (active.provider === "seedream") {
|
|
1724
|
+
const size = input.quality;
|
|
1725
|
+
generated = input.mode === "edit" ? await editSeedreamImage({
|
|
1726
|
+
apiKey: credential.value,
|
|
1727
|
+
baseURL: active.baseURL,
|
|
1728
|
+
model: active.model,
|
|
1729
|
+
prompt: input.prompt,
|
|
1730
|
+
sourceImages,
|
|
1731
|
+
size,
|
|
1732
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1733
|
+
signal
|
|
1734
|
+
}) : await generateOpenAICompatibleImage({
|
|
1735
|
+
provider: "seedream",
|
|
1736
|
+
apiKey: credential.value,
|
|
1737
|
+
baseURL: active.baseURL,
|
|
1738
|
+
model: active.model,
|
|
1739
|
+
prompt: input.prompt,
|
|
1740
|
+
size,
|
|
1741
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1742
|
+
signal
|
|
1743
|
+
});
|
|
1744
|
+
output = size;
|
|
1745
|
+
} else {
|
|
1746
|
+
if (input.mode === "edit" && sourceImages.length > 3) throw new Error("DashScope (通义万相) 图生图目前最多支持 3 张参考图,请精简后重试");
|
|
1747
|
+
const size = dashScopeSize(input.ratio);
|
|
1748
|
+
generated = input.mode === "edit" ? await editDashScopeImage({
|
|
1749
|
+
apiKey: credential.value,
|
|
1750
|
+
endpoint: active.endpoint,
|
|
1751
|
+
model: active.model,
|
|
1752
|
+
prompt: input.prompt,
|
|
1753
|
+
sourceImages,
|
|
1754
|
+
size,
|
|
1755
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1756
|
+
signal
|
|
1757
|
+
}) : await generateDashScopeImage({
|
|
1758
|
+
apiKey: credential.value,
|
|
1759
|
+
endpoint: active.endpoint,
|
|
1760
|
+
model: active.model,
|
|
1761
|
+
prompt: input.prompt,
|
|
1762
|
+
size,
|
|
1763
|
+
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
1764
|
+
signal
|
|
1765
|
+
});
|
|
1766
|
+
output = size;
|
|
1767
|
+
}
|
|
1768
|
+
if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`当前 DSH 不支持保存 ${generated.mediaType} 图片`);
|
|
1769
|
+
return {
|
|
1770
|
+
attachment: await ctx.attachments.saveImage({
|
|
1771
|
+
data: generated.data,
|
|
1772
|
+
mediaType: generated.mediaType,
|
|
1773
|
+
name: count > 1 ? `studio-image-${index + 1}` : "studio-image"
|
|
1774
|
+
}),
|
|
1775
|
+
output
|
|
1776
|
+
};
|
|
1777
|
+
};
|
|
1778
|
+
if (count === 1) {
|
|
1779
|
+
const single = await generateSingle(0);
|
|
1780
|
+
return {
|
|
1781
|
+
attachment: single.attachment,
|
|
1782
|
+
output: single.output,
|
|
1783
|
+
provider: input.provider,
|
|
1784
|
+
model: input.model,
|
|
1785
|
+
prompt: input.prompt,
|
|
1786
|
+
createdAt: Date.now(),
|
|
1787
|
+
elapsedMs: Date.now() - startedAt,
|
|
1788
|
+
requestedCount: 1,
|
|
1789
|
+
failedCount: 0,
|
|
1790
|
+
items: [single],
|
|
1791
|
+
...single.savedTo ? { savedTo: single.savedTo } : {}
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
const poolResults = await runPool(Array.from({ length: count }, (_, i) => () => generateSingle(i)), 2);
|
|
1795
|
+
const successes = [];
|
|
1796
|
+
const errors = [];
|
|
1797
|
+
for (let i = 0; i < poolResults.length; i++) {
|
|
1798
|
+
const r = poolResults[i];
|
|
1799
|
+
if (r.status === "fulfilled") successes.push(r.value);
|
|
1800
|
+
else errors.push({
|
|
1801
|
+
index: i,
|
|
1802
|
+
message: r.reason instanceof Error ? r.reason.message : String(r.reason)
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
if (successes.length === 0) {
|
|
1806
|
+
const firstReason = poolResults[0] && poolResults[0].status === "rejected" ? poolResults[0].reason : /* @__PURE__ */ new Error("批量生图全部失败");
|
|
1807
|
+
throw firstReason instanceof Error ? firstReason : new Error(String(firstReason));
|
|
1808
|
+
}
|
|
1809
|
+
const first = successes[0];
|
|
1810
|
+
return {
|
|
1811
|
+
attachment: first.attachment,
|
|
1812
|
+
output: first.output,
|
|
1813
|
+
provider: input.provider,
|
|
1814
|
+
model: input.model,
|
|
1815
|
+
prompt: input.prompt,
|
|
1816
|
+
createdAt: Date.now(),
|
|
1817
|
+
elapsedMs: Date.now() - startedAt,
|
|
1818
|
+
requestedCount: count,
|
|
1819
|
+
failedCount: errors.length,
|
|
1820
|
+
items: successes,
|
|
1821
|
+
...errors.length > 0 ? { errors } : {},
|
|
1822
|
+
...first.savedTo ? { savedTo: first.savedTo } : {}
|
|
1823
|
+
};
|
|
1354
1824
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1825
|
+
async function runPool(tasks, concurrency = 2) {
|
|
1826
|
+
const results = new Array(tasks.length);
|
|
1827
|
+
let nextIndex = 0;
|
|
1828
|
+
async function worker() {
|
|
1829
|
+
while (nextIndex < tasks.length) {
|
|
1830
|
+
const currentIndex = nextIndex++;
|
|
1831
|
+
const task = tasks[currentIndex];
|
|
1832
|
+
try {
|
|
1833
|
+
const val = await task();
|
|
1834
|
+
results[currentIndex] = {
|
|
1835
|
+
status: "fulfilled",
|
|
1836
|
+
value: val
|
|
1837
|
+
};
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
results[currentIndex] = {
|
|
1840
|
+
status: "rejected",
|
|
1841
|
+
reason: err
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());
|
|
1847
|
+
await Promise.all(workers);
|
|
1848
|
+
return results;
|
|
1849
|
+
}
|
|
1850
|
+
function studioProfile(config, provider, configured) {
|
|
1851
|
+
const active = resolveProvider(providerConfig(config, provider));
|
|
1852
|
+
if (active.provider === "comfyui") throw new Error("Invalid cloud provider profile");
|
|
1853
|
+
const model = active.model;
|
|
1854
|
+
if (provider === "google") return profile(provider, model, configured, ASPECT_RATIOS.map(option), IMAGE_SIZES.map((value) => ({
|
|
1855
|
+
value,
|
|
1856
|
+
label: value
|
|
1857
|
+
})), "1:1", "1K");
|
|
1858
|
+
if (provider === "openai") return profile(provider, model, configured, [
|
|
1859
|
+
"1:1",
|
|
1860
|
+
"3:2",
|
|
1861
|
+
"2:3"
|
|
1862
|
+
].map(option), [{
|
|
1863
|
+
value: "standard",
|
|
1864
|
+
label: "标准(推荐)"
|
|
1865
|
+
}], "1:1", "standard");
|
|
1866
|
+
if (provider === "seedream") return profile(provider, model, configured, [{
|
|
1867
|
+
value: "auto",
|
|
1868
|
+
label: "模型自动"
|
|
1869
|
+
}], [
|
|
1870
|
+
"1K",
|
|
1871
|
+
"2K",
|
|
1872
|
+
"4K"
|
|
1873
|
+
].map((value) => ({
|
|
1874
|
+
value,
|
|
1875
|
+
label: value
|
|
1876
|
+
})), "auto", "2K");
|
|
1877
|
+
return profile(provider, model, configured, [
|
|
1878
|
+
"1:1",
|
|
1879
|
+
"3:2",
|
|
1880
|
+
"2:3",
|
|
1881
|
+
"16:9",
|
|
1882
|
+
"9:16"
|
|
1883
|
+
].map(option), [{
|
|
1884
|
+
value: "standard",
|
|
1885
|
+
label: "标准(推荐)"
|
|
1886
|
+
}], "1:1", "standard");
|
|
1887
|
+
}
|
|
1888
|
+
function profile(provider, model, configured, ratioOptions, qualityOptions, defaultRatio, defaultQuality) {
|
|
1889
|
+
return {
|
|
1890
|
+
provider,
|
|
1891
|
+
label: PROVIDER_LABELS[provider],
|
|
1892
|
+
model,
|
|
1893
|
+
configured,
|
|
1894
|
+
supportsEditing: true,
|
|
1895
|
+
ratioOptions,
|
|
1896
|
+
qualityOptions,
|
|
1897
|
+
defaultRatio,
|
|
1898
|
+
defaultQuality
|
|
1899
|
+
};
|
|
1359
1900
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1901
|
+
function option(value) {
|
|
1902
|
+
return {
|
|
1903
|
+
value,
|
|
1904
|
+
label: RATIO_LABELS[value] ?? value
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
function providerConfig(config, provider, model) {
|
|
1908
|
+
if (model === void 0) return {
|
|
1909
|
+
...config,
|
|
1910
|
+
provider
|
|
1911
|
+
};
|
|
1912
|
+
switch (provider) {
|
|
1913
|
+
case "google": return {
|
|
1914
|
+
...config,
|
|
1915
|
+
provider,
|
|
1916
|
+
googleModel: model
|
|
1917
|
+
};
|
|
1918
|
+
case "openai": return {
|
|
1919
|
+
...config,
|
|
1920
|
+
provider,
|
|
1921
|
+
openaiModel: model
|
|
1922
|
+
};
|
|
1923
|
+
case "seedream": return {
|
|
1924
|
+
...config,
|
|
1925
|
+
provider,
|
|
1926
|
+
seedreamModel: model
|
|
1927
|
+
};
|
|
1928
|
+
case "dashscope": return {
|
|
1929
|
+
...config,
|
|
1930
|
+
provider,
|
|
1931
|
+
dashscopeModel: model
|
|
1932
|
+
};
|
|
1374
1933
|
}
|
|
1375
1934
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
1935
|
+
function assertAllowed(profile, input) {
|
|
1936
|
+
if (input.model !== profile.model) throw new Error("模型配置已变化,请刷新工作台后重试");
|
|
1937
|
+
if (!profile.ratioOptions.some((option) => option.value === input.ratio)) throw new Error("该 Provider 不支持所选比例");
|
|
1938
|
+
if (!profile.qualityOptions.some((option) => option.value === input.quality)) throw new Error("该 Provider 不支持所选清晰度");
|
|
1939
|
+
if (input.mode === "edit" && !profile.supportsEditing) throw new Error("该 Provider 暂不支持图生图");
|
|
1380
1940
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1941
|
+
async function readStudioReference(ctx, reference, signal) {
|
|
1942
|
+
if (reference === void 0) throw new Error("图生图需要至少一张参考图");
|
|
1943
|
+
if ("attachment" in reference) {
|
|
1944
|
+
const stored = await ctx.attachments.readImage(reference.attachment, signal);
|
|
1945
|
+
return {
|
|
1946
|
+
data: stored.data,
|
|
1947
|
+
mediaType: stored.ref.mediaType
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
const data = decodeCanonicalBase64(reference.data);
|
|
1951
|
+
if (data.byteLength > ctx.attachments.imageLimits.maxImageBytes) throw new Error("参考图超过当前 DSH 的大小限制");
|
|
1952
|
+
await ctx.attachments.validateImage({
|
|
1953
|
+
data,
|
|
1954
|
+
mediaType: reference.mediaType,
|
|
1955
|
+
...reference.name === void 0 ? {} : { name: reference.name }
|
|
1956
|
+
});
|
|
1957
|
+
return {
|
|
1958
|
+
data,
|
|
1959
|
+
mediaType: reference.mediaType
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
function decodeCanonicalBase64(value) {
|
|
1963
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("参考图编码无效");
|
|
1964
|
+
const data = Buffer.from(value, "base64");
|
|
1965
|
+
if (data.byteLength === 0 || data.toString("base64") !== value) throw new Error("参考图编码无效");
|
|
1966
|
+
return new Uint8Array(data);
|
|
1967
|
+
}
|
|
1968
|
+
function openAISize(ratio) {
|
|
1969
|
+
if (ratio === "3:2") return "1536x1024";
|
|
1970
|
+
if (ratio === "2:3") return "1024x1536";
|
|
1971
|
+
return "1024x1024";
|
|
1972
|
+
}
|
|
1973
|
+
function dashScopeSize(ratio) {
|
|
1974
|
+
return {
|
|
1975
|
+
"1:1": "1024*1024",
|
|
1976
|
+
"3:2": "1536*1024",
|
|
1977
|
+
"2:3": "1024*1536",
|
|
1978
|
+
"16:9": "1664*928",
|
|
1979
|
+
"9:16": "928*1664"
|
|
1980
|
+
}[ratio] ?? "1024*1024";
|
|
1981
|
+
}
|
|
1982
|
+
function cloudProvider$1(value) {
|
|
1983
|
+
return CLOUD_IMAGE_PROVIDERS.includes(value);
|
|
1984
|
+
}
|
|
1985
|
+
//#endregion
|
|
1986
|
+
//#region lib/types/studio-route.js
|
|
1987
|
+
/** Serve workbench capabilities and generation requests without exposing provider credentials. */
|
|
1988
|
+
async function serveStudio(req, res, deps) {
|
|
1989
|
+
if (!sameOrigin(req)) return jsonError(res, 403, "origin-rejected");
|
|
1990
|
+
if (req.method === "GET") try {
|
|
1991
|
+
return json(res, 200, await deps.describe());
|
|
1416
1992
|
} catch (error) {
|
|
1417
|
-
|
|
1418
|
-
throw error;
|
|
1993
|
+
return jsonError(res, 500, errorMessage(error, "studio-unavailable"));
|
|
1419
1994
|
}
|
|
1995
|
+
if (req.method !== "POST") return jsonError(res, 405, "method-not-allowed");
|
|
1996
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError(res, 415, "json-required");
|
|
1997
|
+
const controller = new AbortController();
|
|
1998
|
+
const onConnectionClose = () => {
|
|
1999
|
+
if (!res.writableEnded) controller.abort(/* @__PURE__ */ new Error("The browser closed the image generation request."));
|
|
2000
|
+
};
|
|
2001
|
+
const onReqAborted = () => {
|
|
2002
|
+
controller.abort(/* @__PURE__ */ new Error("The browser closed the image generation request."));
|
|
2003
|
+
};
|
|
2004
|
+
res.once("close", onConnectionClose);
|
|
2005
|
+
req.once("aborted", onReqAborted);
|
|
1420
2006
|
try {
|
|
1421
|
-
|
|
2007
|
+
let input;
|
|
2008
|
+
try {
|
|
2009
|
+
input = parseStudioGenerateRequest(JSON.parse(await readBody(req, deps.maxBodyBytes)));
|
|
2010
|
+
} catch (error) {
|
|
2011
|
+
return jsonError(res, 400, errorMessage(error, "invalid-request"));
|
|
2012
|
+
}
|
|
2013
|
+
const output = await deps.generate(input, controller.signal);
|
|
2014
|
+
if (!res.headersSent && !res.writableEnded && !res.destroyed) json(res, 200, output);
|
|
1422
2015
|
} catch (error) {
|
|
1423
|
-
|
|
1424
|
-
|
|
2016
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
|
2017
|
+
jsonError(res, controller.signal.aborted ? 499 : 502, errorMessage(error, "generation-failed"));
|
|
2018
|
+
} finally {
|
|
2019
|
+
res.off("close", onConnectionClose);
|
|
2020
|
+
req.off("aborted", onReqAborted);
|
|
1425
2021
|
}
|
|
1426
|
-
return target;
|
|
1427
2022
|
}
|
|
1428
|
-
/**
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
if (
|
|
1445
|
-
|
|
1446
|
-
if (err?.code === "ENOENT") return true;
|
|
1447
|
-
throw err;
|
|
1448
|
-
}
|
|
1449
|
-
try {
|
|
1450
|
-
const realFile = await realpath(resolved);
|
|
1451
|
-
const realBase = realFile.split(/[/\\]/).pop() ?? "";
|
|
1452
|
-
if (!GENERATED_IMAGE_NAME_REGEX.test(realBase)) return false;
|
|
1453
|
-
const rootsToCheck = /* @__PURE__ */ new Set();
|
|
1454
|
-
if (allowedWorkspaceRoots) {
|
|
1455
|
-
for (const r of allowedWorkspaceRoots) if (typeof r === "string" && r.trim()) rootsToCheck.add(resolve(r));
|
|
1456
|
-
}
|
|
1457
|
-
rootsToCheck.add(resolve(process.cwd()));
|
|
1458
|
-
let withinAllowed = false;
|
|
1459
|
-
for (const root of rootsToCheck) try {
|
|
1460
|
-
if (containsPath(await realpath(root), realFile)) {
|
|
1461
|
-
withinAllowed = true;
|
|
1462
|
-
break;
|
|
1463
|
-
}
|
|
1464
|
-
} catch {}
|
|
1465
|
-
if (!withinAllowed) return false;
|
|
1466
|
-
await unlink(realFile);
|
|
1467
|
-
return true;
|
|
1468
|
-
} catch (err) {
|
|
1469
|
-
if (err?.code === "ENOENT") return true;
|
|
1470
|
-
throw err;
|
|
2023
|
+
/** Strictly validate the small untrusted workbench wire contract. */
|
|
2024
|
+
function parseStudioGenerateRequest(value) {
|
|
2025
|
+
const input = record(value);
|
|
2026
|
+
if (input === void 0) throw new Error("请求格式无效");
|
|
2027
|
+
if (input.mode !== "generate" && input.mode !== "edit") throw new Error("请选择生成类型");
|
|
2028
|
+
if (!cloudProvider(input.provider)) throw new Error("不支持该图像 Provider");
|
|
2029
|
+
const prompt = requiredText(input.prompt, "请输入提示词", 2e3);
|
|
2030
|
+
const model = requiredText(input.model, "请选择模型", 200);
|
|
2031
|
+
const ratio = requiredText(input.ratio, "请选择比例", 32);
|
|
2032
|
+
const quality = requiredText(input.quality, "请选择清晰度", 32);
|
|
2033
|
+
const references = (Array.isArray(input.references) ? input.references : input.reference !== void 0 ? [input.reference] : []).map(parseReference);
|
|
2034
|
+
if (input.mode === "edit" && references.length === 0) throw new Error("图生图需要至少一张参考图");
|
|
2035
|
+
if (references.length > 5) throw new Error("最多支持上传 5 张参考图");
|
|
2036
|
+
const workspaceRoot = typeof input.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0 ? input.workspaceRoot.trim() : void 0;
|
|
2037
|
+
let count;
|
|
2038
|
+
if (input.count !== void 0) {
|
|
2039
|
+
if (typeof input.count !== "number" || !Number.isInteger(input.count) || input.count < 1 || input.count > 4) throw new Error("生成数量仅支持 1 到 4 张");
|
|
2040
|
+
count = input.count;
|
|
1471
2041
|
}
|
|
2042
|
+
return {
|
|
2043
|
+
mode: input.mode,
|
|
2044
|
+
provider: input.provider,
|
|
2045
|
+
model,
|
|
2046
|
+
prompt,
|
|
2047
|
+
ratio,
|
|
2048
|
+
quality,
|
|
2049
|
+
...count !== void 0 ? { count } : {},
|
|
2050
|
+
...references.length > 0 ? {
|
|
2051
|
+
references,
|
|
2052
|
+
reference: references[0]
|
|
2053
|
+
} : {},
|
|
2054
|
+
...workspaceRoot !== void 0 ? { workspaceRoot } : {}
|
|
2055
|
+
};
|
|
1472
2056
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
if (!match || !match[1]) return false;
|
|
1481
|
-
const prefix = match[1].slice(0, 8);
|
|
1482
|
-
const rootsToCheck = /* @__PURE__ */ new Set();
|
|
1483
|
-
if (options.workspaceRoot && options.workspaceRoot.trim()) rootsToCheck.add(resolve(options.workspaceRoot.trim()));
|
|
1484
|
-
else if (options.allowedWorkspaceRoots) {
|
|
1485
|
-
for (const r of options.allowedWorkspaceRoots) if (typeof r === "string" && r.trim()) rootsToCheck.add(resolve(r));
|
|
2057
|
+
function parseReference(value) {
|
|
2058
|
+
const reference = record(value);
|
|
2059
|
+
if (reference === void 0) throw new Error("参考图格式无效");
|
|
2060
|
+
if ("attachment" in reference) {
|
|
2061
|
+
const attachment = parseImageAttachmentRef(reference.attachment);
|
|
2062
|
+
if (attachment === void 0) throw new Error("参考图附件无效");
|
|
2063
|
+
return { attachment };
|
|
1486
2064
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
if (
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
2065
|
+
if (!imageMediaType(reference.mediaType) || typeof reference.data !== "string") throw new Error("参考图格式无效");
|
|
2066
|
+
if (reference.data.length === 0) throw new Error("参考图内容为空");
|
|
2067
|
+
if (reference.name !== void 0 && typeof reference.name !== "string") throw new Error("参考图名称无效");
|
|
2068
|
+
return {
|
|
2069
|
+
mediaType: reference.mediaType,
|
|
2070
|
+
data: reference.data,
|
|
2071
|
+
...typeof reference.name === "string" ? { name: reference.name.slice(0, 240) } : {}
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
function requiredText(value, message, maxLength) {
|
|
2075
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(message);
|
|
2076
|
+
const text = value.trim();
|
|
2077
|
+
if (text.length > maxLength) throw new Error(`${message}(最多 ${String(maxLength)} 个字符)`);
|
|
2078
|
+
return text;
|
|
2079
|
+
}
|
|
2080
|
+
function cloudProvider(value) {
|
|
2081
|
+
return typeof value === "string" && CLOUD_IMAGE_PROVIDERS.includes(value);
|
|
2082
|
+
}
|
|
2083
|
+
function imageMediaType(value) {
|
|
2084
|
+
return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
|
|
2085
|
+
}
|
|
2086
|
+
function sameOrigin(req) {
|
|
2087
|
+
const origin = req.headers.origin;
|
|
2088
|
+
const host = req.headers.host;
|
|
2089
|
+
return origin === void 0 || host === void 0 || origin === `http://${host}` || origin === `https://${host}`;
|
|
2090
|
+
}
|
|
2091
|
+
function record(value) {
|
|
2092
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
2093
|
+
}
|
|
2094
|
+
async function readBody(req, maxBytes) {
|
|
2095
|
+
const chunks = [];
|
|
2096
|
+
let bytes = 0;
|
|
2097
|
+
for await (const chunk of req) {
|
|
2098
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2099
|
+
bytes += buffer.byteLength;
|
|
2100
|
+
if (bytes > maxBytes) throw new Error("参考图过大,请压缩后重试");
|
|
2101
|
+
chunks.push(buffer);
|
|
1507
2102
|
}
|
|
1508
|
-
return
|
|
2103
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2104
|
+
}
|
|
2105
|
+
function errorMessage(error, fallback) {
|
|
2106
|
+
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback;
|
|
2107
|
+
}
|
|
2108
|
+
function json(res, status, value) {
|
|
2109
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
|
2110
|
+
res.writeHead(status, {
|
|
2111
|
+
"content-type": "application/json; charset=utf-8",
|
|
2112
|
+
"cache-control": "no-store"
|
|
2113
|
+
});
|
|
2114
|
+
res.end(JSON.stringify(value));
|
|
2115
|
+
}
|
|
2116
|
+
function jsonError(res, status, message) {
|
|
2117
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
|
2118
|
+
json(res, status, { error: message });
|
|
1509
2119
|
}
|
|
1510
2120
|
//#endregion
|
|
1511
2121
|
//#region lib/types/index.js
|
|
@@ -1533,14 +2143,53 @@ function apply(ctx, config = {}) {
|
|
|
1533
2143
|
ctx.effect(() => ctx.webServer.register({
|
|
1534
2144
|
kind: "exact",
|
|
1535
2145
|
path: DELETE_ROUTE,
|
|
1536
|
-
handler: (req, res) => serveDelete(req, res, {
|
|
1537
|
-
|
|
1538
|
-
|
|
2146
|
+
handler: (req, res) => serveDelete(req, res, { deleteWorkspaceImage: (filePath) => deleteImageFromWorkspace(filePath, knownWorkspaceRoots) })
|
|
2147
|
+
}), "dsh-image-gen: delete route");
|
|
2148
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2149
|
+
kind: "exact",
|
|
2150
|
+
path: SAVE_WORKSPACE_ROUTE,
|
|
2151
|
+
handler: (req, res) => serveSaveWorkspace(req, res, {
|
|
2152
|
+
readImage: (ref) => ctx.attachments.readImage(ref),
|
|
2153
|
+
saveToWorkspace: (options) => saveImageToWorkspace({
|
|
2154
|
+
workspaceRoot: options.workspaceRoot,
|
|
1539
2155
|
folder: current().workspaceFolder,
|
|
1540
|
-
|
|
1541
|
-
|
|
2156
|
+
attachmentId: options.attachmentId,
|
|
2157
|
+
mediaType: options.mediaType,
|
|
2158
|
+
data: options.data
|
|
2159
|
+
}),
|
|
2160
|
+
getActiveWorkspaceRoot: () => Array.from(knownWorkspaceRoots)[0] || process.cwd(),
|
|
2161
|
+
getAllowedWorkspaceRoots: async () => {
|
|
2162
|
+
const discovered = await getDshWorkspaceRoots().catch(() => []);
|
|
2163
|
+
return /* @__PURE__ */ new Set([
|
|
2164
|
+
...knownWorkspaceRoots,
|
|
2165
|
+
...discovered,
|
|
2166
|
+
process.cwd()
|
|
2167
|
+
]);
|
|
2168
|
+
},
|
|
2169
|
+
isSaveEnabled: () => current().saveToWorkspace !== false
|
|
1542
2170
|
})
|
|
1543
|
-
}), "dsh-image-gen:
|
|
2171
|
+
}), "dsh-image-gen: save workspace route");
|
|
2172
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2173
|
+
kind: "exact",
|
|
2174
|
+
path: STUDIO_ROUTE,
|
|
2175
|
+
handler: (req, res) => serveStudio(req, res, {
|
|
2176
|
+
describe: async () => {
|
|
2177
|
+
const base = await describeStudio(ctx, current());
|
|
2178
|
+
const workspaces = await getDshWorkspacesFull().catch(() => []);
|
|
2179
|
+
const activeRoot = Array.from(knownWorkspaceRoots)[0] || workspaces[0]?.path || process.cwd();
|
|
2180
|
+
return {
|
|
2181
|
+
...base,
|
|
2182
|
+
workspaceRoot: activeRoot,
|
|
2183
|
+
workspaces
|
|
2184
|
+
};
|
|
2185
|
+
},
|
|
2186
|
+
generate: (input, signal) => {
|
|
2187
|
+
const fallbackRoot = Array.from(knownWorkspaceRoots)[0] || process.cwd();
|
|
2188
|
+
return generateFromStudio(ctx, current(), input, signal, fallbackRoot);
|
|
2189
|
+
},
|
|
2190
|
+
maxBodyBytes: Math.ceil(ctx.attachments.imageLimits.maxImageBytes * 1.4 * 5) + 262144
|
|
2191
|
+
})
|
|
2192
|
+
}), "dsh-image-gen: studio route");
|
|
1544
2193
|
ctx.tools.register(defineTool({
|
|
1545
2194
|
name: "generate_image",
|
|
1546
2195
|
description: "Generate a new image with the configured provider. Use when the user asks to create or draw a new image; use edit_image instead when they want to change an existing image. Give a complete visual prompt including subject, composition, style, lighting, and any exact text that should appear. A successful image is attached directly to the conversation and may also be saved under the session workspace. Do not call read, glob, or other tools to locate or verify the image.",
|
|
@@ -1839,7 +2488,8 @@ function imageOutput(verb) {
|
|
|
1839
2488
|
required: true
|
|
1840
2489
|
},
|
|
1841
2490
|
savedTo: { type: "string" },
|
|
1842
|
-
saveError: { type: "string" }
|
|
2491
|
+
saveError: { type: "string" },
|
|
2492
|
+
seed: { type: "integer" }
|
|
1843
2493
|
}
|
|
1844
2494
|
},
|
|
1845
2495
|
render: (_args, value) => {
|
|
@@ -1949,4 +2599,4 @@ function installImageSettings(ctx, config, hooks) {
|
|
|
1949
2599
|
ctx.logger.warn("dsh-image-gen: this DSH exposes neither settings API generation; settings UI stays on the composition entry");
|
|
1950
2600
|
}
|
|
1951
2601
|
//#endregion
|
|
1952
|
-
export { Config, DELETE_ROUTE, IMAGE_ROUTE, apply, imageAttachmentFromMeta, inject, name };
|
|
2602
|
+
export { Config, DELETE_ROUTE, IMAGE_ROUTE, SAVE_WORKSPACE_ROUTE, STUDIO_ROUTE, apply, imageAttachmentFromMeta, inject, name };
|