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/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$4(value);
216
- const inputs = node === void 0 ? void 0 : record$4(node.inputs);
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$4(node)?.inputs;
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$4(value) {
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$3(parseJsonRecord(text, "ComfyUI history returned invalid JSON")[promptId]);
401
+ const entry = record$4(parseJsonRecord(text, "ComfyUI history returned invalid JSON")[promptId]);
377
402
  if (entry !== void 0) {
378
- const status = record$3(entry.status);
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$3(value);
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$3(nodeOutput)?.images;
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$3(image);
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$3(response.headers.get("content-type")) ?? imageMediaTypeFromName(output.filename);
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$3(value) {
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$3(value);
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$3(value) {
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$2(value);
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$2(step);
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$2(value);
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$2(value) {
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$1(value);
861
+ const ref = record$2(value);
826
862
  if (ref === void 0) return void 0;
827
- if (typeof ref.attachmentId !== "string" || !imageMediaType$2(ref.mediaType)) return void 0;
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$1(value);
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$2(value) {
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$1(value) {
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
- /** Safely delete one or more generated image files from the workspace disk. */
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 jsonError(res, 403, "origin-rejected");
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 === 1 ? "image" : "image[]";
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
- form.append(imageField, new Blob([uploadBytes], { type: sourceImage.mediaType }), `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`);
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$1(image.mime_type) ?? "image/png"
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$1(parsed.mediaType) ?? "image/png"
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$1(response.headers.get("content-type"));
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$1(value) {
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/workspace-save.js
1314
- /** Persist one generated image as a file under the session workspace. */
1315
- /** File extension for each supported image media type. */
1316
- const EXTENSION = {
1317
- "image/png": "png",
1318
- "image/jpeg": "jpg",
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
- * Build the deterministic file name for a generated image:
1324
- * `image-<digest-prefix>.<ext>`. The digest prefix comes from the
1325
- * content-addressed attachment id, so the same image bytes always map to the
1326
- * same file name regardless of when they were generated, and re-saving simply
1327
- * overwrites the previous copy in place.
1328
- * @param attachmentId - durable attachment id (`sha256:<hex>`).
1329
- * @param mediaType - verified image media type.
1330
- * @returns the file name (no directory).
1331
- */
1332
- function workspaceImageName(attachmentId, mediaType) {
1333
- return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
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
- * Resolve the configured image folder inside the session workspace. The
1337
- * folder may nest, but must stay inside the workspace: absolute paths and
1338
- * parent-traversal segments are rejected for both separator styles.
1339
- *
1340
- * This lexical pass is necessary but not sufficient: `saveImageToWorkspace`
1341
- * additionally verifies the on-disk resolution so symlinked folders cannot
1342
- * escape the workspace.
1343
- * @param workspaceRoot - the session workspace directory.
1344
- * @param folder - configured subfolder; empty/blank means the workspace root.
1345
- * @returns the absolute image directory.
1346
- * @throws when the folder would escape the workspace root.
1347
- */
1348
- function workspaceImageDir(workspaceRoot, folder) {
1349
- const trimmed = (folder ?? "").trim();
1350
- const root = resolve(workspaceRoot);
1351
- const dir = trimmed === "" ? root : resolve(root, trimmed);
1352
- if (!containsPath(root, dir)) throw new Error(`image workspace folder '${folder}' must stay inside the session workspace`);
1353
- return dir;
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
- /** True when `child` equals `parent` or lives underneath it (lexically). */
1356
- function containsPath(parent, child) {
1357
- const rel = relative(parent, child);
1358
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
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
- * Real path of the closest existing ancestor of `dir` (inclusive). Walking up
1362
- * lets us validate symlinked folder segments before creating anything under
1363
- * them.
1364
- */
1365
- async function nearestExistingRealPath(dir) {
1366
- let probe = dir;
1367
- for (;;) try {
1368
- return await realpath(probe);
1369
- } catch (error) {
1370
- if (error?.code !== "ENOENT") throw error;
1371
- const parent = dirname(probe);
1372
- if (parent === probe) throw error;
1373
- probe = parent;
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
- /** Reject a directory whose on-disk resolution lands outside the workspace. */
1377
- function assertInsideWorkspace(realRoot, candidate, folder) {
1378
- if (containsPath(realRoot, candidate)) return;
1379
- throw new Error(`image workspace folder '${folder ?? ""}' must stay inside the session workspace (${candidate} resolves outside ${realRoot})`);
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
- * Write one generated image durably under the session workspace.
1383
- *
1384
- * Containment is enforced twice: lexically by `workspaceImageDir`, then
1385
- * against real paths, so a configured folder (or any intermediate segment)
1386
- * that is a symlink pointing outside the workspace is rejected before and
1387
- * after anything is created.
1388
- *
1389
- * The bytes are written to a same-directory staging file and renamed onto the
1390
- * target, so a crash never leaves a half-written image under its final name.
1391
- * Re-saving identical bytes rewrites the same file (the name is content-
1392
- * addressed), which keeps repeated generations idempotent. A cancellation is
1393
- * honoured up to and including the final rename: an aborted save never
1394
- * resolves successfully and never leaves the image behind under its final
1395
- * name.
1396
- * @param options - workspace root, configured folder, attachment identity, and image bytes.
1397
- * @returns the absolute path of the written file.
1398
- */
1399
- async function saveImageToWorkspace(options) {
1400
- const dir = workspaceImageDir(options.workspaceRoot, options.folder);
1401
- options.signal?.throwIfAborted();
1402
- const realRoot = await realpath(resolve(options.workspaceRoot));
1403
- assertInsideWorkspace(realRoot, await nearestExistingRealPath(dir), options.folder);
1404
- const name = workspaceImageName(options.attachmentId, options.mediaType);
1405
- const target = join(dir, name);
1406
- const staging = join(dir, `.${name}.${process.pid}-${randomUUID()}.tmp`);
1407
- await mkdir(dir, { recursive: true });
1408
- assertInsideWorkspace(realRoot, await realpath(dir), options.folder);
1409
- try {
1410
- await writeFile(staging, options.data, {
1411
- flag: "wx",
1412
- signal: options.signal
1413
- });
1414
- options.signal?.throwIfAborted();
1415
- await rename(staging, target);
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
- await unlink(staging).catch(() => {});
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
- options.signal?.throwIfAborted();
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
- await unlink(target).catch(() => {});
1424
- throw error;
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
- /** Regex pattern strictly matching generated image file names (e.g. image-01234567.png). */
1429
- const GENERATED_IMAGE_NAME_REGEX = /^image-[0-9a-f]{8,}\.(png|jpg|jpeg|webp|gif)$/i;
1430
- /**
1431
- * Safely delete a generated image file from workspace disk by its path.
1432
- * Enforces strict safety gates:
1433
- * 1. Base name must match generated image pattern (`image-<hex>.<ext>`) to avoid touching user files.
1434
- * 2. Path must be a regular file and NOT a symbolic link.
1435
- * 3. Real canonical path's basename must also match the pattern.
1436
- * 4. Real canonical path must reside within an allowed workspace root (or process.cwd()).
1437
- */
1438
- async function deleteImageFromWorkspace(filePath, allowedWorkspaceRoots) {
1439
- const resolved = resolve(filePath);
1440
- const base = resolved.split(/[/\\]/).pop() ?? "";
1441
- if (!GENERATED_IMAGE_NAME_REGEX.test(base)) return false;
1442
- try {
1443
- const fileLstat = await lstat(resolved);
1444
- if (fileLstat.isSymbolicLink() || !fileLstat.isFile()) return false;
1445
- } catch (err) {
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
- * Safely find and delete a generated image file by its attachment ID within a single workspace root.
1475
- * Strictly checks sha256:64hex format and avoids scanning unrelated workspaces.
1476
- * Only used as fallback for historical images where savedTo was not persisted.
1477
- */
1478
- async function deleteImageByAttachmentIdFromWorkspace(attachmentId, options = {}) {
1479
- const match = attachmentId.match(/^sha256:([0-9a-f]{64})$/i);
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
- rootsToCheck.add(resolve(process.cwd()));
1488
- const candidateDirs = /* @__PURE__ */ new Set();
1489
- if (options.folder && options.folder.trim()) candidateDirs.add(options.folder.trim());
1490
- candidateDirs.add("dsh-image-gen");
1491
- candidateDirs.add("image");
1492
- candidateDirs.add("images");
1493
- candidateDirs.add("");
1494
- const candidateExtensions = [
1495
- "png",
1496
- "jpg",
1497
- "jpeg",
1498
- "webp",
1499
- "gif"
1500
- ];
1501
- let deleted = false;
1502
- for (const root of rootsToCheck) for (const dir of candidateDirs) for (const ext of candidateExtensions) {
1503
- const targetPath = join(root, dir, `image-${prefix}.${ext}`);
1504
- try {
1505
- if (await deleteImageFromWorkspace(targetPath, rootsToCheck)) deleted = true;
1506
- } catch {}
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 deleted;
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
- deleteWorkspaceImage: (filePath) => deleteImageFromWorkspace(filePath, knownWorkspaceRoots),
1538
- deleteWorkspaceImageByAttachmentId: (attachmentId) => deleteImageByAttachmentIdFromWorkspace(attachmentId, {
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
- allowedWorkspaceRoots: knownWorkspaceRoots
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: delete route");
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 };