dsh-image-gen 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ 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
- import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { lstat, mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
6
6
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
8
  //#region lib/types/shared.js
9
9
  /** Values shared by the Host and browser Bundle faces. */
10
10
  /** Browser route used by the generated-image card. */
11
11
  const IMAGE_ROUTE = "/plugins/dsh-image-gen/image";
12
+ /** Browser route used for deleting generated images and workspace files. */
13
+ const DELETE_ROUTE = "/plugins/dsh-image-gen/delete";
12
14
  /** Namespace persisted through DSH Settings. */
13
15
  const IMAGE_GENERATION_NAMESPACE = "image-generation";
14
16
  /** Supported providers. */
@@ -26,11 +28,58 @@ const DEFAULT_SEEDREAM_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
26
28
  const DEFAULT_DASHSCOPE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1";
27
29
  const DEFAULT_COMFYUI_BASE_URL = "http://127.0.0.1:8188";
28
30
  const DEFAULT_COMFYUI_TIMEOUT_MS = 3e5;
31
+ const DEFAULT_COMFYUI_WORKFLOW_LABEL = "API workflow";
29
32
  /** Default model names. */
30
33
  const DEFAULT_GOOGLE_MODEL = "gemini-3.1-flash-image";
31
34
  const DEFAULT_OPENAI_MODEL = "gpt-image-2";
32
35
  const DEFAULT_SEEDREAM_MODEL = "doubao-seedream-5-0-260128";
33
36
  const DEFAULT_DASHSCOPE_MODEL = "qwen-image-3.0";
37
+ /** Named workflows, falling back to the legacy single-workflow fields when the list is empty. */
38
+ function resolveComfyUIWorkflows(source) {
39
+ const named = [];
40
+ for (const entry of source.comfyuiWorkflows ?? []) {
41
+ const name = typeof entry?.name === "string" ? entry.name.trim() : "";
42
+ const json = typeof entry?.json === "string" ? entry.json : "";
43
+ if (name.length > 0 && json.trim().length > 0) {
44
+ const presetPrompt = typeof entry.presetPrompt === "string" ? entry.presetPrompt.trim() : "";
45
+ named.push(presetPrompt.length > 0 ? {
46
+ name,
47
+ json,
48
+ presetPrompt
49
+ } : {
50
+ name,
51
+ json
52
+ });
53
+ }
54
+ }
55
+ if (named.length > 0) return named;
56
+ const legacyJson = typeof source.comfyuiWorkflowJson === "string" ? source.comfyuiWorkflowJson : "";
57
+ if (legacyJson.trim().length === 0) return [];
58
+ const legacyName = typeof source.comfyuiWorkflowName === "string" ? source.comfyuiWorkflowName.trim() : "";
59
+ return [{
60
+ name: legacyName.length > 0 ? legacyName : DEFAULT_COMFYUI_WORKFLOW_LABEL,
61
+ json: legacyJson
62
+ }];
63
+ }
64
+ /** The workflow ComfyUI calls use by default: the configured active name, else the first entry. */
65
+ function activeComfyUIWorkflow(source) {
66
+ const workflows = resolveComfyUIWorkflows(source);
67
+ if (workflows.length === 0) return void 0;
68
+ const activeName = typeof source.comfyuiActiveWorkflow === "string" ? source.comfyuiActiveWorkflow.trim() : "";
69
+ return workflows.find((workflow) => workflow.name === activeName) ?? workflows[0];
70
+ }
71
+ /**
72
+ * Combine a workflow's preset with the user prompt: preset first, user second,
73
+ * joined by one comma — never doubled when the preset already ends in a
74
+ * separator, and reduced to the non-empty side when the other is blank.
75
+ */
76
+ function mergeComfyUIPrompt(preset, user) {
77
+ const presetText = typeof preset === "string" ? preset.trim().replace(/[,;\s]+$/, "") : "";
78
+ const userText = user.trim();
79
+ if (presetText.length === 0) return userText;
80
+ if (userText.length === 0) return presetText;
81
+ return `${presetText}, ${userText}`;
82
+ }
34
83
  //#endregion
35
84
  //#region lib/types/config.js
36
85
  /** User-facing configuration for supported image providers. */
@@ -56,6 +105,12 @@ const Config = z.object({
56
105
  dashscopeEndpoint: z.string().default(DEFAULT_DASHSCOPE_ENDPOINT),
57
106
  dashscopeModel: z.string().default(DEFAULT_DASHSCOPE_MODEL),
58
107
  comfyuiBaseURL: z.string().default(DEFAULT_COMFYUI_BASE_URL),
108
+ comfyuiWorkflows: z.array(z.object({
109
+ name: z.string(),
110
+ json: z.string(),
111
+ presetPrompt: z.string().default("")
112
+ })).default([]),
113
+ comfyuiActiveWorkflow: z.string().default(""),
59
114
  comfyuiWorkflowJson: z.string().default(""),
60
115
  comfyuiWorkflowName: z.string().default(""),
61
116
  comfyuiTimeoutMs: z.number().min(1e3).max(36e5).default(DEFAULT_COMFYUI_TIMEOUT_MS),
@@ -86,13 +141,17 @@ function resolveProvider(config) {
86
141
  endpoint: config.dashscopeEndpoint ?? "https://dashscope.aliyuncs.com/api/v1",
87
142
  imageSize: "1024*1024"
88
143
  };
89
- case "comfyui": return {
90
- provider: "comfyui",
91
- baseURL: config.comfyuiBaseURL ?? "http://127.0.0.1:8188",
92
- workflowJson: config.comfyuiWorkflowJson ?? "",
93
- workflowName: config.comfyuiWorkflowName?.trim() || "API workflow",
94
- timeoutMs: config.comfyuiTimeoutMs ?? 3e5
95
- };
144
+ case "comfyui": {
145
+ const workflows = resolveComfyUIWorkflows(config);
146
+ const workflow = activeComfyUIWorkflow(config);
147
+ return {
148
+ provider: "comfyui",
149
+ baseURL: config.comfyuiBaseURL ?? "http://127.0.0.1:8188",
150
+ workflows,
151
+ ...workflow === void 0 ? {} : { workflow },
152
+ timeoutMs: config.comfyuiTimeoutMs ?? 3e5
153
+ };
154
+ }
96
155
  case "google": return {
97
156
  provider: "google",
98
157
  apiKeyEnv: GOOGLE_API_KEY_ENV,
@@ -103,43 +162,82 @@ function resolveProvider(config) {
103
162
  };
104
163
  }
105
164
  }
165
+ /** The workflow a ComfyUI call runs: the requested name when given, else the active one. */
166
+ function selectComfyUIWorkflow(active, requested) {
167
+ if (active.workflow === void 0) throw new Error("ComfyUI image generation requires an imported workflow; import one in Settings > Plugins > Image generation.");
168
+ if (typeof requested !== "string" || requested.trim().length === 0) return active.workflow;
169
+ const name = requested.trim();
170
+ const workflow = active.workflows.find((candidate) => candidate.name === name);
171
+ if (workflow === void 0) throw new Error(`No ComfyUI workflow named "${name}" is configured. Available workflows: ${active.workflows.map((entry) => entry.name).join(", ")}.`);
172
+ return workflow;
173
+ }
106
174
  //#endregion
107
175
  //#region lib/types/comfyui-workflow.js
108
176
  /** Pure ComfyUI API-workflow validation and placeholder injection. */
109
177
  const COMFYUI_PROMPT_PLACEHOLDER = "{{prompt}}";
110
178
  const COMFYUI_SEED_PLACEHOLDER = "{{seed}}";
111
- /** Parse, clone, and inject one prompt plus an optional randomized seed. */
112
- function prepareComfyUIWorkflow(workflowJson, prompt, seed = randomSeed()) {
179
+ const COMFYUI_IMAGE_PLACEHOLDER = "{{image}}";
180
+ /** Legacy single-percent placeholders from early releases, still accepted. */
181
+ const LEGACY_PROMPT_PLACEHOLDER = "%prompt%";
182
+ const LEGACY_SEED_PLACEHOLDER = "%seed%";
183
+ const LEGACY_IMAGE_PLACEHOLDER = "%image%";
184
+ /** LoadImage-style input key that receives the uploaded source image name. */
185
+ const IMAGE_INPUT_KEY = "image";
186
+ /**
187
+ * Parse, clone, and inject one prompt plus an optional randomized seed.
188
+ *
189
+ * Prompt and seed placeholders are replaced inside any string so existing
190
+ * workflows that embed them in longer text keep working. The image
191
+ * placeholder is stricter: it only matches a dedicated `inputs.image` field
192
+ * whose value is exactly the placeholder, and at most one may exist, because
193
+ * the field must carry a single uploaded file name.
194
+ */
195
+ function prepareComfyUIWorkflow(workflowJson, prompt, seed = randomSeed(), image) {
113
196
  const workflow = parseWorkflow(workflowJson);
114
197
  let promptReplacements = 0;
115
198
  const inject = (value) => {
116
199
  if (typeof value === "string") {
117
- if (value === "{{prompt}}") {
118
- promptReplacements += 1;
119
- return prompt;
120
- }
121
- if (value === "{{seed}}") return seed;
122
- if (value.includes("{{prompt}}")) {
200
+ let replaced = value;
201
+ if (replaced.includes("{{prompt}}") || replaced.includes(LEGACY_PROMPT_PLACEHOLDER)) {
123
202
  promptReplacements += 1;
124
- return value.replaceAll(COMFYUI_PROMPT_PLACEHOLDER, prompt);
203
+ replaced = replaced.replaceAll(COMFYUI_PROMPT_PLACEHOLDER, prompt).replaceAll(LEGACY_PROMPT_PLACEHOLDER, prompt);
125
204
  }
126
- return value.includes("{{seed}}") ? value.replaceAll(COMFYUI_SEED_PLACEHOLDER, String(seed)) : value;
205
+ if (replaced === "{{seed}}" || replaced === LEGACY_SEED_PLACEHOLDER) return seed;
206
+ if (replaced.includes("{{seed}}") || replaced.includes(LEGACY_SEED_PLACEHOLDER)) replaced = replaced.replaceAll(COMFYUI_SEED_PLACEHOLDER, String(seed)).replaceAll(LEGACY_SEED_PLACEHOLDER, String(seed));
207
+ return replaced;
127
208
  }
128
209
  if (Array.isArray(value)) return value.map(inject);
129
210
  if (!isRecord(value)) return value;
130
211
  return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, inject(child)]));
131
212
  };
213
+ let imageInputs = 0;
132
214
  const prepared = Object.fromEntries(Object.entries(workflow).map(([nodeId, value]) => {
133
215
  const node = record$4(value);
134
216
  const inputs = node === void 0 ? void 0 : record$4(node.inputs);
135
- return inputs === void 0 ? [nodeId, value] : [nodeId, {
217
+ if (inputs === void 0) return [nodeId, value];
218
+ const nextInputs = inject(inputs);
219
+ if (isImagePlaceholder(nextInputs[IMAGE_INPUT_KEY])) {
220
+ imageInputs += 1;
221
+ if (image !== void 0) nextInputs[IMAGE_INPUT_KEY] = image;
222
+ }
223
+ return [nodeId, {
136
224
  ...node,
137
- inputs: inject(inputs)
225
+ inputs: nextInputs
138
226
  }];
139
227
  }));
140
228
  if (promptReplacements === 0) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} in a text input`);
229
+ if (image === void 0) {
230
+ if (imageInputs > 0) throw new Error(`ComfyUI workflow contains an ${COMFYUI_IMAGE_PLACEHOLDER} image input, which requires edit_image with a source image`);
231
+ return prepared;
232
+ }
233
+ if (imageInputs === 0) throw new Error(`ComfyUI workflow must contain exactly one ${COMFYUI_IMAGE_PLACEHOLDER} image input to edit images`);
234
+ if (imageInputs > 1) throw new Error(`ComfyUI workflow must contain exactly one ${COMFYUI_IMAGE_PLACEHOLDER} image input; found ${String(imageInputs)}`);
141
235
  return prepared;
142
236
  }
237
+ /** Random seed in the 32-bit range ComfyUI samplers accept. */
238
+ function randomSeed() {
239
+ return Math.floor(Math.random() * 4294967296);
240
+ }
143
241
  function parseWorkflow(workflowJson) {
144
242
  if (workflowJson.trim().length === 0) throw new Error("Import a ComfyUI API workflow JSON file in Settings before generating");
145
243
  if (new TextEncoder().encode(workflowJson).byteLength > 5242880) throw new Error("ComfyUI workflow file must be no larger than 5 MB");
@@ -153,11 +251,14 @@ function parseWorkflow(workflowJson) {
153
251
  if (!Object.values(value).some((node) => {
154
252
  const inputs = record$4(node)?.inputs;
155
253
  return inputs !== void 0 && containsPromptPlaceholder(inputs);
156
- })) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} in a text input`);
254
+ })) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} (or ${LEGACY_PROMPT_PLACEHOLDER}) in a text input`);
157
255
  return value;
158
256
  }
257
+ function isImagePlaceholder(value) {
258
+ return value === "{{image}}" || value === LEGACY_IMAGE_PLACEHOLDER;
259
+ }
159
260
  function containsPromptPlaceholder(value) {
160
- if (typeof value === "string") return value.includes(COMFYUI_PROMPT_PLACEHOLDER);
261
+ if (typeof value === "string") return value.includes("{{prompt}}") || value.includes(LEGACY_PROMPT_PLACEHOLDER);
161
262
  if (Array.isArray(value)) return value.some(containsPromptPlaceholder);
162
263
  return isRecord(value) && Object.values(value).some(containsPromptPlaceholder);
163
264
  }
@@ -167,19 +268,37 @@ function isRecord(value) {
167
268
  function record$4(value) {
168
269
  return isRecord(value) ? value : void 0;
169
270
  }
170
- function randomSeed() {
171
- return Math.floor(Math.random() * 4294967296);
172
- }
173
271
  //#endregion
174
272
  //#region lib/types/comfyui.js
175
273
  const ERROR_LIMIT$3 = 4096;
176
274
  const POLL_INTERVAL_MS = 500;
177
275
  const MAX_HISTORY_BYTES = 16777216;
178
- /** Run one ComfyUI workflow and return its first final image. */
276
+ /** Run one ComfyUI text-to-image workflow and return its first final image. */
179
277
  async function generateComfyUIImage(input) {
278
+ const seed = randomSeed();
279
+ const workflow = prepareComfyUIWorkflow(input.workflowJson, input.prompt, seed);
280
+ return {
281
+ ...await runJob(input, async (baseURL, signal) => {
282
+ return await downloadOutput(baseURL, await waitForOutput(baseURL, await submitWorkflow(baseURL, workflow, signal), signal), input.maxBytes, signal);
283
+ }),
284
+ seed
285
+ };
286
+ }
287
+ /** Upload one source image, run the workflow, and return its first final image. */
288
+ async function editComfyUIImage(input) {
289
+ const seed = randomSeed();
290
+ return {
291
+ ...await runJob(input, async (baseURL, signal) => {
292
+ const imageName = await uploadSourceImage(baseURL, input.sourceImage, signal);
293
+ return await downloadOutput(baseURL, await waitForOutput(baseURL, await submitWorkflow(baseURL, prepareComfyUIWorkflow(input.workflowJson, input.prompt, seed, imageName), signal), signal), input.maxBytes, signal);
294
+ }),
295
+ seed
296
+ };
297
+ }
298
+ /** Share abort forwarding, the single timeout, and error normalization. */
299
+ async function runJob(input, run) {
180
300
  input.signal.throwIfAborted();
181
301
  const baseURL = comfyUIBaseURL(input.baseURL);
182
- const workflow = prepareComfyUIWorkflow(input.workflowJson, input.prompt);
183
302
  const controller = new AbortController();
184
303
  const forwardAbort = () => {
185
304
  controller.abort(input.signal.reason);
@@ -189,7 +308,7 @@ async function generateComfyUIImage(input) {
189
308
  controller.abort(/* @__PURE__ */ new Error("ComfyUI generation timed out"));
190
309
  }, input.timeoutMs);
191
310
  try {
192
- return await downloadOutput(baseURL, await waitForOutput(baseURL, await submitWorkflow(baseURL, workflow, controller.signal), controller.signal), input.maxBytes, controller.signal);
311
+ return await run(baseURL, controller.signal);
193
312
  } catch (error) {
194
313
  input.signal.throwIfAborted();
195
314
  if (controller.signal.aborted) throw new Error(`ComfyUI generation timed out after ${String(input.timeoutMs)} ms`);
@@ -200,6 +319,33 @@ async function generateComfyUIImage(input) {
200
319
  input.signal.removeEventListener("abort", forwardAbort);
201
320
  }
202
321
  }
322
+ /** Upload the source image and return the LoadImage-compatible name ComfyUI stored it under. */
323
+ async function uploadSourceImage(baseURL, image, signal) {
324
+ const filename = uploadFilename(image.mediaType);
325
+ const bytes = new Uint8Array(image.data.byteLength);
326
+ bytes.set(image.data);
327
+ const form = new FormData();
328
+ form.append("image", new Blob([bytes], { type: image.mediaType }), filename);
329
+ const response = await fetch(endpoint(baseURL, "upload/image"), {
330
+ method: "POST",
331
+ redirect: "error",
332
+ signal,
333
+ headers: { accept: "application/json" },
334
+ body: form
335
+ });
336
+ const text = await readBoundedText$3(response, ERROR_LIMIT$3);
337
+ if (!response.ok) throw new Error(`ComfyUI image upload failed (${response.status}): ${text}`);
338
+ const payload = parseJsonRecord(text, "ComfyUI /upload/image returned invalid JSON");
339
+ if (typeof payload.name !== "string" || payload.name.length === 0) throw new Error(`ComfyUI /upload/image returned no name: ${text}`);
340
+ const subfolder = typeof payload.subfolder === "string" ? payload.subfolder : "";
341
+ return subfolder.length > 0 ? `${subfolder}/${payload.name}` : payload.name;
342
+ }
343
+ /** LoadImage-style inputs need a file name with a decodable extension. */
344
+ function uploadFilename(mediaType) {
345
+ const extension = mediaType === "image/png" ? "png" : mediaType === "image/jpeg" ? "jpg" : mediaType === "image/webp" ? "webp" : void 0;
346
+ if (extension === void 0) throw new Error(`ComfyUI edit_image accepts PNG, JPEG, or WebP source images; got ${mediaType}`);
347
+ return `dsh-image-gen-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
348
+ }
203
349
  async function submitWorkflow(baseURL, workflow, signal) {
204
350
  const response = await fetch(endpoint(baseURL, "prompt"), {
205
351
  method: "POST",
@@ -785,7 +931,7 @@ function record$1(value) {
785
931
  }
786
932
  //#endregion
787
933
  //#region lib/types/image-route.js
788
- const MAX_BODY_BYTES = 4096;
934
+ const MAX_BODY_BYTES = 65536;
789
935
  /** Serve one verified durable image reference to a same-origin browser request. */
790
936
  async function serveImage(req, res, deps) {
791
937
  if (req.method !== "POST") return jsonError(res, 405, "method-not-allowed");
@@ -814,6 +960,54 @@ async function serveImage(req, res, deps) {
814
960
  jsonError(res, 404, "image-unavailable");
815
961
  }
816
962
  }
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");
967
+ const origin = req.headers.origin;
968
+ 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");
970
+ let body;
971
+ try {
972
+ body = JSON.parse(await readBody(req));
973
+ } catch {
974
+ return jsonError(res, 400, "invalid-request");
975
+ }
976
+ 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
+ const deletedFiles = [];
979
+ const failedFiles = [];
980
+ for (const p of rawPaths) if (typeof p === "string" && p.trim()) try {
981
+ if (await deps.deleteWorkspaceImage(p)) deletedFiles.push(p);
982
+ else failedFiles.push({
983
+ path: p,
984
+ error: "File not found or rejected by safety checks"
985
+ });
986
+ } catch (err) {
987
+ failedFiles.push({
988
+ path: p,
989
+ error: err instanceof Error ? err.message : String(err)
990
+ });
991
+ }
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
+ res.writeHead(200, {
1001
+ "content-type": "application/json",
1002
+ "cache-control": "no-store"
1003
+ });
1004
+ res.end(JSON.stringify({
1005
+ ok: failedFiles.length === 0,
1006
+ deletedCount: deletedFiles.length,
1007
+ deletedFiles,
1008
+ failedFiles
1009
+ }));
1010
+ }
817
1011
  /** Validate the persisted reference carried by a tool presentation. */
818
1012
  function imageAttachmentFromMeta(meta) {
819
1013
  const value = record(meta);
@@ -1231,6 +1425,88 @@ async function saveImageToWorkspace(options) {
1231
1425
  }
1232
1426
  return target;
1233
1427
  }
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;
1471
+ }
1472
+ }
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));
1486
+ }
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 {}
1507
+ }
1508
+ return deleted;
1509
+ }
1234
1510
  //#endregion
1235
1511
  //#region lib/types/index.js
1236
1512
  const name = "dsh-image-gen";
@@ -1242,7 +1518,8 @@ const inject = [
1242
1518
  ];
1243
1519
  function apply(ctx, config = {}) {
1244
1520
  let current = () => config;
1245
- installSettingsSection(ctx, settingsNamespace(IMAGE_GENERATION_NAMESPACE), Config, config, {
1521
+ const knownWorkspaceRoots = /* @__PURE__ */ new Set();
1522
+ installImageSettings(ctx, config, {
1246
1523
  setSource: (source) => {
1247
1524
  current = source;
1248
1525
  },
@@ -1253,6 +1530,17 @@ function apply(ctx, config = {}) {
1253
1530
  path: IMAGE_ROUTE,
1254
1531
  handler: (req, res) => serveImage(req, res, { readImage: (ref) => ctx.attachments.readImage(ref) })
1255
1532
  }), "dsh-image-gen: image route");
1533
+ ctx.effect(() => ctx.webServer.register({
1534
+ kind: "exact",
1535
+ path: DELETE_ROUTE,
1536
+ handler: (req, res) => serveDelete(req, res, {
1537
+ deleteWorkspaceImage: (filePath) => deleteImageFromWorkspace(filePath, knownWorkspaceRoots),
1538
+ deleteWorkspaceImageByAttachmentId: (attachmentId) => deleteImageByAttachmentIdFromWorkspace(attachmentId, {
1539
+ folder: current().workspaceFolder,
1540
+ allowedWorkspaceRoots: knownWorkspaceRoots
1541
+ })
1542
+ })
1543
+ }), "dsh-image-gen: delete route");
1256
1544
  ctx.tools.register(defineTool({
1257
1545
  name: "generate_image",
1258
1546
  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.",
@@ -1287,19 +1575,26 @@ function apply(ctx, config = {}) {
1287
1575
  size: {
1288
1576
  type: "string",
1289
1577
  description: "Optional dimensions or size tier for OpenAI, Seedream, or DashScope."
1578
+ },
1579
+ workflow: {
1580
+ type: "string",
1581
+ description: "Optional name of the ComfyUI workflow to run; omit to use the active workflow from settings. Only meaningful when the ComfyUI provider is selected."
1290
1582
  }
1291
1583
  },
1292
1584
  output: imageOutput("Generated"),
1293
1585
  async execute(args, exec) {
1294
1586
  const active = resolveProvider(current());
1295
- if (active.provider === "comfyui") return saveGenerated(ctx, await generateComfyUIImage({
1296
- baseURL: active.baseURL,
1297
- workflowJson: active.workflowJson,
1298
- prompt: args.prompt,
1299
- timeoutMs: active.timeoutMs,
1300
- maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1301
- signal: exec.signal
1302
- }), active.provider, active.workflowName, "API workflow", current(), exec);
1587
+ if (active.provider === "comfyui") {
1588
+ const workflow = selectComfyUIWorkflow(active, args.workflow);
1589
+ return saveGenerated(ctx, await generateComfyUIImage({
1590
+ baseURL: active.baseURL,
1591
+ workflowJson: workflow.json,
1592
+ prompt: mergeComfyUIPrompt(workflow.presetPrompt, args.prompt),
1593
+ timeoutMs: active.timeoutMs,
1594
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1595
+ signal: exec.signal
1596
+ }), active.provider, workflow.name, "API workflow", current(), exec, knownWorkspaceRoots);
1597
+ }
1303
1598
  const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
1304
1599
  if (credential === void 0 || credential.value.length === 0) throw new Error(`generate_image requires the ${active.apiKeyEnv} credential; configure it in Settings > Plugins > Image generation.`);
1305
1600
  if (active.provider === "google") {
@@ -1314,7 +1609,7 @@ function apply(ctx, config = {}) {
1314
1609
  imageSize,
1315
1610
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1316
1611
  signal: exec.signal
1317
- }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec);
1612
+ }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec, knownWorkspaceRoots);
1318
1613
  }
1319
1614
  if (active.provider === "dashscope") {
1320
1615
  const size = args.size ?? active.imageSize;
@@ -1326,7 +1621,7 @@ function apply(ctx, config = {}) {
1326
1621
  size,
1327
1622
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1328
1623
  signal: exec.signal
1329
- }), active.provider, active.model, size, current(), exec);
1624
+ }), active.provider, active.model, size, current(), exec, knownWorkspaceRoots);
1330
1625
  }
1331
1626
  const size = args.size ?? active.imageSize;
1332
1627
  return saveGenerated(ctx, await generateOpenAICompatibleImage({
@@ -1338,7 +1633,7 @@ function apply(ctx, config = {}) {
1338
1633
  size,
1339
1634
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1340
1635
  signal: exec.signal
1341
- }), active.provider, active.model, size, current(), exec);
1636
+ }), active.provider, active.model, size, current(), exec, knownWorkspaceRoots);
1342
1637
  },
1343
1638
  presentResult: (_args, result) => imagePresentation(result)
1344
1639
  }));
@@ -1394,14 +1689,15 @@ function apply(ctx, config = {}) {
1394
1689
  size: {
1395
1690
  type: "string",
1396
1691
  description: "Optional output size for OpenAI, Seedream, or DashScope."
1692
+ },
1693
+ workflow: {
1694
+ type: "string",
1695
+ description: "Optional name of the ComfyUI workflow to run; omit to use the active workflow from settings. Only meaningful when the ComfyUI provider is selected."
1397
1696
  }
1398
1697
  },
1399
1698
  output: imageOutput("Edited"),
1400
1699
  async execute(args, exec) {
1401
1700
  const active = resolveProvider(current());
1402
- if (active.provider === "comfyui") throw new Error("edit_image is not yet supported by the ComfyUI provider; switch providers or use generate_image");
1403
- const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
1404
- if (credential === void 0 || credential.value.length === 0) throw new Error(`edit_image requires the ${active.apiKeyEnv} credential; configure it in Settings > Plugins > Image generation.`);
1405
1701
  const sourceImages = await resolveReferenceImages({
1406
1702
  ...exec.agent === void 0 ? {} : { agent: exec.agent },
1407
1703
  attachments: ctx.attachments,
@@ -1412,6 +1708,26 @@ function apply(ctx, config = {}) {
1412
1708
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1413
1709
  signal: exec.signal
1414
1710
  });
1711
+ if (active.provider === "comfyui") {
1712
+ if (sourceImages.length > 1) throw new Error(`ComfyUI edit_image supports exactly one source image per call; this call resolved ${String(sourceImages.length)} images. Call edit_image again with source_attachment_id set to the single attachment ID of the image to edit.`);
1713
+ const sourceImage = sourceImages[0];
1714
+ if (sourceImage === void 0) throw new Error("edit_image requires a reference image");
1715
+ const workflow = selectComfyUIWorkflow(active, args.workflow);
1716
+ return saveGenerated(ctx, await editComfyUIImage({
1717
+ baseURL: active.baseURL,
1718
+ workflowJson: workflow.json,
1719
+ prompt: mergeComfyUIPrompt(workflow.presetPrompt, args.prompt),
1720
+ sourceImage: {
1721
+ data: sourceImage.data,
1722
+ mediaType: sourceImage.mediaType
1723
+ },
1724
+ timeoutMs: active.timeoutMs,
1725
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1726
+ signal: exec.signal
1727
+ }), active.provider, workflow.name, "API workflow", current(), exec, knownWorkspaceRoots);
1728
+ }
1729
+ const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
1730
+ if (credential === void 0 || credential.value.length === 0) throw new Error(`edit_image requires the ${active.apiKeyEnv} credential; configure it in Settings > Plugins > Image generation.`);
1415
1731
  if (active.provider === "google") {
1416
1732
  const aspectRatio = args.aspect_ratio ?? active.aspectRatio;
1417
1733
  const imageSize = args.image_size ?? active.imageSize;
@@ -1425,7 +1741,7 @@ function apply(ctx, config = {}) {
1425
1741
  imageSize,
1426
1742
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1427
1743
  signal: exec.signal
1428
- }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec);
1744
+ }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec, knownWorkspaceRoots);
1429
1745
  }
1430
1746
  const size = args.size ?? active.imageSize;
1431
1747
  if (active.provider === "openai") return saveGenerated(ctx, await editOpenAICompatibleImage({
@@ -1437,7 +1753,7 @@ function apply(ctx, config = {}) {
1437
1753
  size,
1438
1754
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1439
1755
  signal: exec.signal
1440
- }), active.provider, active.model, size, current(), exec);
1756
+ }), active.provider, active.model, size, current(), exec, knownWorkspaceRoots);
1441
1757
  if (active.provider === "seedream") return saveGenerated(ctx, await editSeedreamImage({
1442
1758
  apiKey: credential.value,
1443
1759
  baseURL: active.baseURL,
@@ -1447,7 +1763,7 @@ function apply(ctx, config = {}) {
1447
1763
  size,
1448
1764
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1449
1765
  signal: exec.signal
1450
- }), active.provider, active.model, size, current(), exec);
1766
+ }), active.provider, active.model, size, current(), exec, knownWorkspaceRoots);
1451
1767
  return saveGenerated(ctx, await editDashScopeImage({
1452
1768
  apiKey: credential.value,
1453
1769
  endpoint: active.endpoint,
@@ -1457,7 +1773,7 @@ function apply(ctx, config = {}) {
1457
1773
  size,
1458
1774
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1459
1775
  signal: exec.signal
1460
- }), active.provider, active.model, size, current(), exec);
1776
+ }), active.provider, active.model, size, current(), exec, knownWorkspaceRoots);
1461
1777
  },
1462
1778
  presentResult: (_args, result) => imagePresentation(result)
1463
1779
  }));
@@ -1545,6 +1861,7 @@ function imageOutput(verb) {
1545
1861
  output: value.output,
1546
1862
  ...verb === "Edited" ? { operation: "edit" } : {},
1547
1863
  ...typeof value.savedTo === "string" ? { savedTo: value.savedTo } : {},
1864
+ ...typeof value.seed === "number" ? { seed: value.seed } : {},
1548
1865
  prompt: args.prompt
1549
1866
  })
1550
1867
  };
@@ -1563,7 +1880,7 @@ function attachmentMeta(ref) {
1563
1880
  } }
1564
1881
  };
1565
1882
  }
1566
- async function saveGenerated(ctx, generated, provider, model, output, config, exec) {
1883
+ async function saveGenerated(ctx, generated, provider, model, output, config, exec, knownRoots) {
1567
1884
  if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`This DSH deployment does not accept ${generated.mediaType} generated images`);
1568
1885
  const attachment = await ctx.attachments.saveImage({
1569
1886
  data: generated.data,
@@ -1574,11 +1891,13 @@ async function saveGenerated(ctx, generated, provider, model, output, config, ex
1574
1891
  attachment,
1575
1892
  provider,
1576
1893
  model,
1577
- output
1894
+ output,
1895
+ ...typeof generated.seed === "number" ? { seed: generated.seed } : {}
1578
1896
  };
1579
1897
  if (config.saveToWorkspace === false) return value;
1580
1898
  const workspaceRoot = exec.agent?.session.header.cwd;
1581
1899
  if (workspaceRoot === void 0) return value;
1900
+ knownRoots?.add(workspaceRoot);
1582
1901
  try {
1583
1902
  value.savedTo = await saveImageToWorkspace({
1584
1903
  workspaceRoot,
@@ -1606,5 +1925,28 @@ function imagePresentation(result) {
1606
1925
  }]
1607
1926
  };
1608
1927
  }
1928
+ /**
1929
+ * Wire the settings namespace across both dsh-settings API generations.
1930
+ * A namespace import keeps module loading safe on either version; the branch
1931
+ * picks the service method (0.1.2+) or the legacy top-level relay (<= rc.2),
1932
+ * and falls back to the composition entry with a warning when neither exists
1933
+ * so an incompatible host degrades the settings UI instead of failing boot.
1934
+ */
1935
+ function installImageSettings(ctx, config, hooks) {
1936
+ const namespace = dshSettings;
1937
+ if (typeof namespace.SettingsProvider?.prototype?.installSection === "function") {
1938
+ ctx.inject(["settings"], (settingsCtx) => {
1939
+ settingsCtx.settings.installSection(ctx, IMAGE_GENERATION_NAMESPACE, Config, config, hooks);
1940
+ });
1941
+ return;
1942
+ }
1943
+ const legacyInstall = namespace.installSettingsSection;
1944
+ const legacyNamespace = namespace.settingsNamespace;
1945
+ if (typeof legacyInstall === "function" && typeof legacyNamespace === "function") {
1946
+ legacyInstall(ctx, legacyNamespace(IMAGE_GENERATION_NAMESPACE), Config, config, hooks);
1947
+ return;
1948
+ }
1949
+ ctx.logger.warn("dsh-image-gen: this DSH exposes neither settings API generation; settings UI stays on the composition entry");
1950
+ }
1609
1951
  //#endregion
1610
- export { Config, IMAGE_ROUTE, apply, imageAttachmentFromMeta, inject, name };
1952
+ export { Config, DELETE_ROUTE, IMAGE_ROUTE, apply, imageAttachmentFromMeta, inject, name };