dsh-image-gen 0.3.0 → 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
@@ -12,6 +12,8 @@ const IMAGE_ROUTE = "/plugins/dsh-image-gen/image";
12
12
  const DELETE_ROUTE = "/plugins/dsh-image-gen/delete";
13
13
  /** Same-origin route used by the browser image workbench. */
14
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";
15
17
  /** Namespace persisted through DSH Settings. */
16
18
  const IMAGE_GENERATION_NAMESPACE = "image-generation";
17
19
  /** Supported providers. */
@@ -964,6 +966,230 @@ function record$2(value) {
964
966
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
965
967
  }
966
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
967
1193
  //#region lib/types/image-route.js
968
1194
  const MAX_BODY_BYTES = 65536;
969
1195
  /** Serve one verified durable image reference to a same-origin browser request. */
@@ -994,13 +1220,17 @@ async function serveImage(req, res, deps) {
994
1220
  jsonError$1(res, 404, "image-unavailable");
995
1221
  }
996
1222
  }
1223
+ function sameOrigin$1(req) {
1224
+ const origin = req.headers.origin;
1225
+ const host = req.headers.host;
1226
+ if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
1227
+ return true;
1228
+ }
997
1229
  /** Safely delete one or more generated image files from the workspace disk. */
998
1230
  async function serveDelete(req, res, deps) {
999
1231
  if (req.method !== "POST") return jsonError$1(res, 405, "method-not-allowed");
1000
1232
  if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$1(res, 415, "json-required");
1001
- const origin = req.headers.origin;
1002
- const host = req.headers.host;
1003
- if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$1(res, 403, "origin-rejected");
1233
+ if (!sameOrigin$1(req)) return jsonError$1(res, 403, "origin-rejected");
1004
1234
  let body;
1005
1235
  try {
1006
1236
  body = JSON.parse(await readBody$1(req));
@@ -1033,6 +1263,61 @@ async function serveDelete(req, res, deps) {
1033
1263
  failedFiles
1034
1264
  }));
1035
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
+ }
1036
1321
  /** Validate the persisted reference carried by a tool presentation. */
1037
1322
  function imageAttachmentFromMeta(meta) {
1038
1323
  const value = record$1(meta);
@@ -1337,196 +1622,6 @@ async function readBoundedBytes(response, maxBytes) {
1337
1622
  return joined;
1338
1623
  }
1339
1624
  //#endregion
1340
- //#region lib/types/workspace-save.js
1341
- /** Persist one generated image as a file under the session workspace. */
1342
- /** File extension for each supported image media type. */
1343
- const EXTENSION = {
1344
- "image/png": "png",
1345
- "image/jpeg": "jpg",
1346
- "image/webp": "webp",
1347
- "image/gif": "gif"
1348
- };
1349
- /**
1350
- * Build the deterministic file name for a generated image:
1351
- * `image-<digest-prefix>.<ext>`. The digest prefix comes from the
1352
- * content-addressed attachment id, so the same image bytes always map to the
1353
- * same file name regardless of when they were generated, and re-saving simply
1354
- * overwrites the previous copy in place.
1355
- * @param attachmentId - durable attachment id (`sha256:<hex>`).
1356
- * @param mediaType - verified image media type.
1357
- * @returns the file name (no directory).
1358
- */
1359
- function workspaceImageName(attachmentId, mediaType) {
1360
- return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
1361
- }
1362
- /**
1363
- * Resolve the configured image folder inside the session workspace. The
1364
- * folder may nest, but must stay inside the workspace: absolute paths and
1365
- * parent-traversal segments are rejected for both separator styles.
1366
- *
1367
- * This lexical pass is necessary but not sufficient: `saveImageToWorkspace`
1368
- * additionally verifies the on-disk resolution so symlinked folders cannot
1369
- * escape the workspace.
1370
- * @param workspaceRoot - the session workspace directory.
1371
- * @param folder - configured subfolder; empty/blank means the workspace root.
1372
- * @returns the absolute image directory.
1373
- * @throws when the folder would escape the workspace root.
1374
- */
1375
- function workspaceImageDir(workspaceRoot, folder) {
1376
- const trimmed = (folder ?? "").trim();
1377
- const root = resolve(workspaceRoot);
1378
- const dir = trimmed === "" ? root : resolve(root, trimmed);
1379
- if (!containsPath(root, dir)) throw new Error(`image workspace folder '${folder}' must stay inside the session workspace`);
1380
- return dir;
1381
- }
1382
- /** True when `child` equals `parent` or lives underneath it (lexically). */
1383
- function containsPath(parent, child) {
1384
- const rel = relative(parent, child);
1385
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1386
- }
1387
- /**
1388
- * Real path of the closest existing ancestor of `dir` (inclusive). Walking up
1389
- * lets us validate symlinked folder segments before creating anything under
1390
- * them.
1391
- */
1392
- async function nearestExistingRealPath(dir) {
1393
- let probe = dir;
1394
- for (;;) try {
1395
- return await realpath(probe);
1396
- } catch (error) {
1397
- if (error?.code !== "ENOENT") throw error;
1398
- const parent = dirname(probe);
1399
- if (parent === probe) throw error;
1400
- probe = parent;
1401
- }
1402
- }
1403
- /** Reject a directory whose on-disk resolution lands outside the workspace. */
1404
- function assertInsideWorkspace(realRoot, candidate, folder) {
1405
- if (containsPath(realRoot, candidate)) return;
1406
- throw new Error(`image workspace folder '${folder ?? ""}' must stay inside the session workspace (${candidate} resolves outside ${realRoot})`);
1407
- }
1408
- /**
1409
- * Write one generated image durably under the session workspace.
1410
- *
1411
- * Containment is enforced twice: lexically by `workspaceImageDir`, then
1412
- * against real paths, so a configured folder (or any intermediate segment)
1413
- * that is a symlink pointing outside the workspace is rejected before and
1414
- * after anything is created.
1415
- *
1416
- * The bytes are written to a same-directory staging file and renamed onto the
1417
- * target, so a crash never leaves a half-written image under its final name.
1418
- * Re-saving identical bytes rewrites the same file (the name is content-
1419
- * addressed), which keeps repeated generations idempotent. A cancellation is
1420
- * honoured up to and including the final rename: an aborted save never
1421
- * resolves successfully and never leaves the image behind under its final
1422
- * name.
1423
- * @param options - workspace root, configured folder, attachment identity, and image bytes.
1424
- * @returns the absolute path of the written file.
1425
- */
1426
- async function saveImageToWorkspace(options) {
1427
- const dir = workspaceImageDir(options.workspaceRoot, options.folder);
1428
- options.signal?.throwIfAborted();
1429
- const realRoot = await realpath(resolve(options.workspaceRoot));
1430
- assertInsideWorkspace(realRoot, await nearestExistingRealPath(dir), options.folder);
1431
- const name = workspaceImageName(options.attachmentId, options.mediaType);
1432
- const target = join(dir, name);
1433
- const staging = join(dir, `.${name}.${process.pid}-${randomUUID()}.tmp`);
1434
- await mkdir(dir, { recursive: true });
1435
- assertInsideWorkspace(realRoot, await realpath(dir), options.folder);
1436
- try {
1437
- await writeFile(staging, options.data, {
1438
- flag: "wx",
1439
- signal: options.signal
1440
- });
1441
- options.signal?.throwIfAborted();
1442
- await rename(staging, target);
1443
- } catch (error) {
1444
- await unlink(staging).catch(() => {});
1445
- throw error;
1446
- }
1447
- try {
1448
- options.signal?.throwIfAborted();
1449
- } catch (error) {
1450
- await unlink(target).catch(() => {});
1451
- throw error;
1452
- }
1453
- return target;
1454
- }
1455
- /** Regex pattern strictly matching generated image file names (e.g. image-01234567.png). */
1456
- const GENERATED_IMAGE_NAME_REGEX = /^image-[0-9a-f]{8,}\.(png|jpg|jpeg|webp|gif)$/i;
1457
- /** Discover detailed workspace records from DSH workspace.json storage. */
1458
- async function getDshWorkspacesFull() {
1459
- const list = [];
1460
- try {
1461
- const home = process.env.USERPROFILE || process.env.HOME || "";
1462
- if (home) {
1463
- const workspaceJsonPath = join(home, ".dsh", "storages", "workspace.json");
1464
- const content = await readFile(workspaceJsonPath, "utf8");
1465
- const workspaces = JSON.parse(content)?.tables?.workspaces;
1466
- if (workspaces && typeof workspaces === "object") for (const [id, ws] of Object.entries(workspaces)) {
1467
- const raw = ws;
1468
- if (raw && typeof raw.path === "string") {
1469
- const p = raw.path.trim();
1470
- if (!p) continue;
1471
- const title = typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : basename(p);
1472
- const rawSessions = raw.sessionIds;
1473
- const sessionIds = Array.isArray(rawSessions) ? rawSessions.filter((s) => typeof s === "string") : [];
1474
- list.push({
1475
- workspaceId: id,
1476
- path: p,
1477
- title,
1478
- sessionIds
1479
- });
1480
- }
1481
- }
1482
- }
1483
- } catch {}
1484
- return list;
1485
- }
1486
- /**
1487
- * Safely delete a generated image file from workspace disk by its path.
1488
- * Enforces strict safety gates:
1489
- * 1. Base name must match generated image pattern (`image-<hex>.<ext>`) to avoid touching user files.
1490
- * 2. Path must be a regular file and NOT a symbolic link.
1491
- * 3. Real canonical path's basename must also match the pattern.
1492
- * 4. Real canonical path must reside within an allowed workspace root (or process.cwd()).
1493
- */
1494
- async function deleteImageFromWorkspace(filePath, allowedWorkspaceRoots) {
1495
- const resolved = resolve(filePath);
1496
- const base = resolved.split(/[/\\]/).pop() ?? "";
1497
- if (!GENERATED_IMAGE_NAME_REGEX.test(base)) return false;
1498
- try {
1499
- const fileLstat = await lstat(resolved);
1500
- if (fileLstat.isSymbolicLink() || !fileLstat.isFile()) return false;
1501
- } catch (err) {
1502
- if (err?.code === "ENOENT") return true;
1503
- throw err;
1504
- }
1505
- try {
1506
- const realFile = await realpath(resolved);
1507
- const realBase = realFile.split(/[/\\]/).pop() ?? "";
1508
- if (!GENERATED_IMAGE_NAME_REGEX.test(realBase)) return false;
1509
- const rootsToCheck = /* @__PURE__ */ new Set();
1510
- if (allowedWorkspaceRoots) {
1511
- for (const r of allowedWorkspaceRoots) if (typeof r === "string" && r.trim()) rootsToCheck.add(resolve(r));
1512
- }
1513
- rootsToCheck.add(resolve(process.cwd()));
1514
- let withinAllowed = false;
1515
- for (const root of rootsToCheck) try {
1516
- if (containsPath(await realpath(root), realFile)) {
1517
- withinAllowed = true;
1518
- break;
1519
- }
1520
- } catch {}
1521
- if (!withinAllowed) return false;
1522
- await unlink(realFile);
1523
- return true;
1524
- } catch (err) {
1525
- if (err?.code === "ENOENT") return true;
1526
- throw err;
1527
- }
1528
- }
1529
- //#endregion
1530
1625
  //#region lib/types/studio.js
1531
1626
  const PROVIDER_LABELS = {
1532
1627
  google: "Google",
@@ -1575,130 +1670,183 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
1575
1670
  if (input.mode === "edit" && rawRefs.length === 0) throw new Error("图生图需要至少一张参考图");
1576
1671
  const sourceImages = input.mode === "edit" ? await Promise.all(rawRefs.map((ref) => readStudioReference(ctx, ref, signal))) : [];
1577
1672
  const startedAt = Date.now();
1578
- let generated;
1579
- let output;
1580
- if (active.provider === "google") {
1581
- const aspectRatio = input.ratio;
1582
- const imageSize = input.quality;
1583
- generated = input.mode === "edit" ? await editGoogleImage({
1584
- apiKey: credential.value,
1585
- endpoint: active.endpoint,
1586
- model: active.model,
1587
- prompt: input.prompt,
1588
- sourceImages,
1589
- aspectRatio,
1590
- imageSize,
1591
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1592
- signal
1593
- }) : await generateGoogleImage({
1594
- apiKey: credential.value,
1595
- endpoint: active.endpoint,
1596
- model: active.model,
1597
- prompt: input.prompt,
1598
- aspectRatio,
1599
- imageSize,
1600
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1601
- signal
1602
- });
1603
- output = `${aspectRatio}, ${imageSize}`;
1604
- } else if (active.provider === "openai") {
1605
- const size = openAISize(input.ratio);
1606
- generated = input.mode === "edit" ? await editOpenAICompatibleImage({
1607
- apiKey: credential.value,
1608
- baseURL: active.baseURL,
1609
- model: active.model,
1610
- prompt: input.prompt,
1611
- sourceImages,
1612
- size,
1613
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1614
- signal
1615
- }) : await generateOpenAICompatibleImage({
1616
- provider: "openai",
1617
- apiKey: credential.value,
1618
- baseURL: active.baseURL,
1619
- model: active.model,
1620
- prompt: input.prompt,
1621
- size,
1622
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1623
- signal
1624
- });
1625
- output = size;
1626
- } else if (active.provider === "seedream") {
1627
- const size = input.quality;
1628
- generated = input.mode === "edit" ? await editSeedreamImage({
1629
- apiKey: credential.value,
1630
- baseURL: active.baseURL,
1631
- model: active.model,
1632
- prompt: input.prompt,
1633
- sourceImages,
1634
- size,
1635
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1636
- signal
1637
- }) : await generateOpenAICompatibleImage({
1638
- provider: "seedream",
1639
- apiKey: credential.value,
1640
- baseURL: active.baseURL,
1641
- model: active.model,
1642
- prompt: input.prompt,
1643
- size,
1644
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1645
- signal
1646
- });
1647
- output = size;
1648
- } else {
1649
- if (input.mode === "edit" && sourceImages.length > 3) throw new Error("DashScope (通义万相) 图生图目前最多支持 3 张参考图,请精简后重试");
1650
- const size = dashScopeSize(input.ratio);
1651
- generated = input.mode === "edit" ? await editDashScopeImage({
1652
- apiKey: credential.value,
1653
- endpoint: active.endpoint,
1654
- model: active.model,
1655
- prompt: input.prompt,
1656
- sourceImages,
1657
- size,
1658
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1659
- signal
1660
- }) : await generateDashScopeImage({
1661
- apiKey: credential.value,
1662
- endpoint: active.endpoint,
1663
- model: active.model,
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,
1664
1785
  prompt: input.prompt,
1665
- size,
1666
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1667
- signal
1668
- });
1669
- output = size;
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
+ };
1670
1793
  }
1671
- if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`当前 DSH 不支持保存 ${generated.mediaType} 图片`);
1672
- const attachment = await ctx.attachments.saveImage({
1673
- data: generated.data,
1674
- mediaType: generated.mediaType,
1675
- name: "studio-image"
1676
- });
1677
- let savedTo;
1678
- const targetRoot = input.workspaceRoot || fallbackWorkspaceRoot;
1679
- if (config.saveToWorkspace !== false && targetRoot) try {
1680
- savedTo = await saveImageToWorkspace({
1681
- workspaceRoot: targetRoot,
1682
- folder: config.workspaceFolder,
1683
- attachmentId: attachment.attachmentId,
1684
- mediaType: generated.mediaType,
1685
- data: generated.data,
1686
- signal
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)
1687
1803
  });
1688
- } catch (saveError) {
1689
- ctx.logger.warn(`dsh-image-gen: studio failed to save image to workspace: ${saveError instanceof Error ? saveError.message : String(saveError)}`);
1690
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];
1691
1810
  return {
1692
- attachment,
1811
+ attachment: first.attachment,
1812
+ output: first.output,
1693
1813
  provider: input.provider,
1694
1814
  model: input.model,
1695
1815
  prompt: input.prompt,
1696
- output,
1697
1816
  createdAt: Date.now(),
1698
1817
  elapsedMs: Date.now() - startedAt,
1699
- ...savedTo ? { savedTo } : {}
1818
+ requestedCount: count,
1819
+ failedCount: errors.length,
1820
+ items: successes,
1821
+ ...errors.length > 0 ? { errors } : {},
1822
+ ...first.savedTo ? { savedTo: first.savedTo } : {}
1700
1823
  };
1701
1824
  }
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
+ }
1702
1850
  function studioProfile(config, provider, configured) {
1703
1851
  const active = resolveProvider(providerConfig(config, provider));
1704
1852
  if (active.provider === "comfyui") throw new Error("Invalid cloud provider profile");
@@ -1886,6 +2034,11 @@ function parseStudioGenerateRequest(value) {
1886
2034
  if (input.mode === "edit" && references.length === 0) throw new Error("图生图需要至少一张参考图");
1887
2035
  if (references.length > 5) throw new Error("最多支持上传 5 张参考图");
1888
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;
2041
+ }
1889
2042
  return {
1890
2043
  mode: input.mode,
1891
2044
  provider: input.provider,
@@ -1893,6 +2046,7 @@ function parseStudioGenerateRequest(value) {
1893
2046
  prompt,
1894
2047
  ratio,
1895
2048
  quality,
2049
+ ...count !== void 0 ? { count } : {},
1896
2050
  ...references.length > 0 ? {
1897
2051
  references,
1898
2052
  reference: references[0]
@@ -1991,6 +2145,30 @@ function apply(ctx, config = {}) {
1991
2145
  path: DELETE_ROUTE,
1992
2146
  handler: (req, res) => serveDelete(req, res, { deleteWorkspaceImage: (filePath) => deleteImageFromWorkspace(filePath, knownWorkspaceRoots) })
1993
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,
2155
+ folder: current().workspaceFolder,
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
2170
+ })
2171
+ }), "dsh-image-gen: save workspace route");
1994
2172
  ctx.effect(() => ctx.webServer.register({
1995
2173
  kind: "exact",
1996
2174
  path: STUDIO_ROUTE,
@@ -2310,7 +2488,8 @@ function imageOutput(verb) {
2310
2488
  required: true
2311
2489
  },
2312
2490
  savedTo: { type: "string" },
2313
- saveError: { type: "string" }
2491
+ saveError: { type: "string" },
2492
+ seed: { type: "integer" }
2314
2493
  }
2315
2494
  },
2316
2495
  render: (_args, value) => {
@@ -2420,4 +2599,4 @@ function installImageSettings(ctx, config, hooks) {
2420
2599
  ctx.logger.warn("dsh-image-gen: this DSH exposes neither settings API generation; settings UI stays on the composition entry");
2421
2600
  }
2422
2601
  //#endregion
2423
- export { Config, DELETE_ROUTE, IMAGE_ROUTE, STUDIO_ROUTE, apply, imageAttachmentFromMeta, inject, name };
2602
+ export { Config, DELETE_ROUTE, IMAGE_ROUTE, SAVE_WORKSPACE_ROUTE, STUDIO_ROUTE, apply, imageAttachmentFromMeta, inject, name };