dsh-image-gen 0.2.0 → 0.2.2

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
@@ -16,13 +16,16 @@ const IMAGE_PROVIDERS = [
16
16
  "google",
17
17
  "openai",
18
18
  "seedream",
19
- "dashscope"
19
+ "dashscope",
20
+ "comfyui"
20
21
  ];
21
22
  /** Default endpoints and base URLs. */
22
23
  const DEFAULT_GOOGLE_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/interactions";
23
24
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
24
25
  const DEFAULT_SEEDREAM_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
25
26
  const DEFAULT_DASHSCOPE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1";
27
+ const DEFAULT_COMFYUI_BASE_URL = "http://127.0.0.1:8188";
28
+ const DEFAULT_COMFYUI_TIMEOUT_MS = 3e5;
26
29
  /** Default model names. */
27
30
  const DEFAULT_GOOGLE_MODEL = "gemini-3.1-flash-image";
28
31
  const DEFAULT_OPENAI_MODEL = "gpt-image-2";
@@ -52,6 +55,10 @@ const Config = z.object({
52
55
  seedreamModel: z.string().default(DEFAULT_SEEDREAM_MODEL),
53
56
  dashscopeEndpoint: z.string().default(DEFAULT_DASHSCOPE_ENDPOINT),
54
57
  dashscopeModel: z.string().default(DEFAULT_DASHSCOPE_MODEL),
58
+ comfyuiBaseURL: z.string().default(DEFAULT_COMFYUI_BASE_URL),
59
+ comfyuiWorkflowJson: z.string().default(""),
60
+ comfyuiWorkflowName: z.string().default(""),
61
+ comfyuiTimeoutMs: z.number().min(1e3).max(36e5).default(DEFAULT_COMFYUI_TIMEOUT_MS),
55
62
  saveToWorkspace: z.boolean().default(true),
56
63
  workspaceFolder: z.string().default(DEFAULT_WORKSPACE_FOLDER)
57
64
  });
@@ -79,6 +86,13 @@ function resolveProvider(config) {
79
86
  endpoint: config.dashscopeEndpoint ?? "https://dashscope.aliyuncs.com/api/v1",
80
87
  imageSize: "1024*1024"
81
88
  };
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
+ };
82
96
  case "google": return {
83
97
  provider: "google",
84
98
  apiKeyEnv: GOOGLE_API_KEY_ENV,
@@ -90,6 +104,256 @@ function resolveProvider(config) {
90
104
  }
91
105
  }
92
106
  //#endregion
107
+ //#region lib/types/comfyui-workflow.js
108
+ /** Pure ComfyUI API-workflow validation and placeholder injection. */
109
+ const COMFYUI_PROMPT_PLACEHOLDER = "{{prompt}}";
110
+ const COMFYUI_SEED_PLACEHOLDER = "{{seed}}";
111
+ /** Parse, clone, and inject one prompt plus an optional randomized seed. */
112
+ function prepareComfyUIWorkflow(workflowJson, prompt, seed = randomSeed()) {
113
+ const workflow = parseWorkflow(workflowJson);
114
+ let promptReplacements = 0;
115
+ const inject = (value) => {
116
+ 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}}")) {
123
+ promptReplacements += 1;
124
+ return value.replaceAll(COMFYUI_PROMPT_PLACEHOLDER, prompt);
125
+ }
126
+ return value.includes("{{seed}}") ? value.replaceAll(COMFYUI_SEED_PLACEHOLDER, String(seed)) : value;
127
+ }
128
+ if (Array.isArray(value)) return value.map(inject);
129
+ if (!isRecord(value)) return value;
130
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, inject(child)]));
131
+ };
132
+ const prepared = Object.fromEntries(Object.entries(workflow).map(([nodeId, value]) => {
133
+ const node = record$4(value);
134
+ const inputs = node === void 0 ? void 0 : record$4(node.inputs);
135
+ return inputs === void 0 ? [nodeId, value] : [nodeId, {
136
+ ...node,
137
+ inputs: inject(inputs)
138
+ }];
139
+ }));
140
+ if (promptReplacements === 0) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} in a text input`);
141
+ return prepared;
142
+ }
143
+ function parseWorkflow(workflowJson) {
144
+ if (workflowJson.trim().length === 0) throw new Error("Import a ComfyUI API workflow JSON file in Settings before generating");
145
+ if (new TextEncoder().encode(workflowJson).byteLength > 5242880) throw new Error("ComfyUI workflow file must be no larger than 5 MB");
146
+ let value;
147
+ try {
148
+ value = JSON.parse(workflowJson);
149
+ } catch {
150
+ throw new Error("ComfyUI workflow file is not valid JSON");
151
+ }
152
+ if (!isRecord(value) || Object.keys(value).length === 0) throw new Error("ComfyUI workflow must be a non-empty API-format JSON object");
153
+ if (!Object.values(value).some((node) => {
154
+ const inputs = record$4(node)?.inputs;
155
+ return inputs !== void 0 && containsPromptPlaceholder(inputs);
156
+ })) throw new Error(`ComfyUI workflow must contain ${COMFYUI_PROMPT_PLACEHOLDER} in a text input`);
157
+ return value;
158
+ }
159
+ function containsPromptPlaceholder(value) {
160
+ if (typeof value === "string") return value.includes(COMFYUI_PROMPT_PLACEHOLDER);
161
+ if (Array.isArray(value)) return value.some(containsPromptPlaceholder);
162
+ return isRecord(value) && Object.values(value).some(containsPromptPlaceholder);
163
+ }
164
+ function isRecord(value) {
165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ }
167
+ function record$4(value) {
168
+ return isRecord(value) ? value : void 0;
169
+ }
170
+ function randomSeed() {
171
+ return Math.floor(Math.random() * 4294967296);
172
+ }
173
+ //#endregion
174
+ //#region lib/types/comfyui.js
175
+ const ERROR_LIMIT$3 = 4096;
176
+ const POLL_INTERVAL_MS = 500;
177
+ const MAX_HISTORY_BYTES = 16777216;
178
+ /** Run one ComfyUI workflow and return its first final image. */
179
+ async function generateComfyUIImage(input) {
180
+ input.signal.throwIfAborted();
181
+ const baseURL = comfyUIBaseURL(input.baseURL);
182
+ const workflow = prepareComfyUIWorkflow(input.workflowJson, input.prompt);
183
+ const controller = new AbortController();
184
+ const forwardAbort = () => {
185
+ controller.abort(input.signal.reason);
186
+ };
187
+ input.signal.addEventListener("abort", forwardAbort, { once: true });
188
+ const timeout = setTimeout(() => {
189
+ controller.abort(/* @__PURE__ */ new Error("ComfyUI generation timed out"));
190
+ }, input.timeoutMs);
191
+ try {
192
+ return await downloadOutput(baseURL, await waitForOutput(baseURL, await submitWorkflow(baseURL, workflow, controller.signal), controller.signal), input.maxBytes, controller.signal);
193
+ } catch (error) {
194
+ input.signal.throwIfAborted();
195
+ if (controller.signal.aborted) throw new Error(`ComfyUI generation timed out after ${String(input.timeoutMs)} ms`);
196
+ if (error instanceof TypeError) throw new Error(`Could not connect to ComfyUI at ${baseURL.origin}`);
197
+ throw error;
198
+ } finally {
199
+ clearTimeout(timeout);
200
+ input.signal.removeEventListener("abort", forwardAbort);
201
+ }
202
+ }
203
+ async function submitWorkflow(baseURL, workflow, signal) {
204
+ const response = await fetch(endpoint(baseURL, "prompt"), {
205
+ method: "POST",
206
+ redirect: "error",
207
+ signal,
208
+ headers: {
209
+ "content-type": "application/json",
210
+ accept: "application/json"
211
+ },
212
+ body: JSON.stringify({ prompt: workflow })
213
+ });
214
+ const text = await readBoundedText$3(response, ERROR_LIMIT$3);
215
+ if (!response.ok) throw new Error(`ComfyUI rejected the workflow (${response.status}): ${text}`);
216
+ const payload = parseJsonRecord(text, "ComfyUI /prompt returned invalid JSON");
217
+ if (typeof payload.prompt_id !== "string" || payload.prompt_id.length === 0) throw new Error(`ComfyUI /prompt returned no prompt_id: ${text}`);
218
+ return payload.prompt_id;
219
+ }
220
+ async function waitForOutput(baseURL, promptId, signal) {
221
+ for (;;) {
222
+ signal.throwIfAborted();
223
+ const response = await fetch(endpoint(baseURL, `history/${encodeURIComponent(promptId)}`), {
224
+ redirect: "error",
225
+ signal,
226
+ headers: { accept: "application/json" }
227
+ });
228
+ const text = await readBoundedText$3(response, MAX_HISTORY_BYTES);
229
+ if (!response.ok) throw new Error(`ComfyUI history request failed (${response.status}): ${text.slice(0, ERROR_LIMIT$3)}`);
230
+ const entry = record$3(parseJsonRecord(text, "ComfyUI history returned invalid JSON")[promptId]);
231
+ if (entry !== void 0) {
232
+ const status = record$3(entry.status);
233
+ if (status?.status_str === "error") throw new Error(`ComfyUI workflow failed: ${JSON.stringify(status.messages ?? status).slice(0, ERROR_LIMIT$3)}`);
234
+ const output = firstOutputImage(entry.outputs);
235
+ if (output !== void 0) return output;
236
+ if (status?.completed === true) throw new Error("ComfyUI workflow completed without an output image");
237
+ }
238
+ await delay(POLL_INTERVAL_MS, signal);
239
+ }
240
+ }
241
+ function firstOutputImage(value) {
242
+ const outputs = record$3(value);
243
+ if (outputs === void 0) return void 0;
244
+ for (const nodeOutput of Object.values(outputs)) {
245
+ const images = record$3(nodeOutput)?.images;
246
+ if (!Array.isArray(images)) continue;
247
+ for (const image of images) {
248
+ const item = record$3(image);
249
+ if (typeof item?.filename !== "string" || item.filename.length === 0) continue;
250
+ const output = {
251
+ filename: item.filename,
252
+ subfolder: typeof item.subfolder === "string" ? item.subfolder : "",
253
+ type: typeof item.type === "string" ? item.type : "output"
254
+ };
255
+ if (output.type === "output") return output;
256
+ }
257
+ }
258
+ }
259
+ async function downloadOutput(baseURL, output, maxBytes, signal) {
260
+ const url = endpoint(baseURL, "view");
261
+ url.searchParams.set("filename", output.filename);
262
+ url.searchParams.set("subfolder", output.subfolder);
263
+ url.searchParams.set("type", output.type);
264
+ const response = await fetch(url, {
265
+ redirect: "error",
266
+ signal
267
+ });
268
+ if (!response.ok) throw new Error(`ComfyUI image download failed (${response.status})`);
269
+ const mediaType = imageMediaType$3(response.headers.get("content-type")) ?? imageMediaTypeFromName(output.filename);
270
+ if (mediaType === void 0) throw new Error("ComfyUI image download returned an unsupported content type");
271
+ return {
272
+ data: await readBoundedBytes$2(response, maxBytes),
273
+ mediaType
274
+ };
275
+ }
276
+ function comfyUIBaseURL(value) {
277
+ let url;
278
+ try {
279
+ url = new URL(value.endsWith("/") ? value : `${value}/`);
280
+ } catch {
281
+ throw new Error("ComfyUI URL must be an absolute http:// or https:// URL");
282
+ }
283
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("ComfyUI URL must use http:// or https://");
284
+ return url;
285
+ }
286
+ function endpoint(baseURL, path) {
287
+ return new URL(path, baseURL);
288
+ }
289
+ function record$3(value) {
290
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
291
+ }
292
+ function parseJsonRecord(text, message) {
293
+ let value;
294
+ try {
295
+ value = JSON.parse(text);
296
+ } catch {
297
+ throw new Error(message);
298
+ }
299
+ const parsed = record$3(value);
300
+ if (parsed === void 0) throw new Error(message);
301
+ return parsed;
302
+ }
303
+ function imageMediaType$3(value) {
304
+ const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
305
+ return mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : void 0;
306
+ }
307
+ function imageMediaTypeFromName(filename) {
308
+ const lower = filename.toLowerCase();
309
+ if (lower.endsWith(".png")) return "image/png";
310
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
311
+ if (lower.endsWith(".webp")) return "image/webp";
312
+ if (lower.endsWith(".gif")) return "image/gif";
313
+ }
314
+ async function delay(milliseconds, signal) {
315
+ await new Promise((resolve, reject) => {
316
+ const timer = setTimeout(done, milliseconds);
317
+ const abort = () => {
318
+ clearTimeout(timer);
319
+ signal.removeEventListener("abort", abort);
320
+ reject(signal.reason);
321
+ };
322
+ function done() {
323
+ signal.removeEventListener("abort", abort);
324
+ resolve();
325
+ }
326
+ signal.addEventListener("abort", abort, { once: true });
327
+ });
328
+ }
329
+ async function readBoundedText$3(response, maxBytes) {
330
+ return new TextDecoder().decode(await readBoundedBytes$2(response, maxBytes));
331
+ }
332
+ async function readBoundedBytes$2(response, maxBytes) {
333
+ if (response.body === null) return /* @__PURE__ */ new Uint8Array();
334
+ const reader = response.body.getReader();
335
+ const chunks = [];
336
+ let bytes = 0;
337
+ try {
338
+ for (;;) {
339
+ const next = await reader.read();
340
+ if (next.done) break;
341
+ bytes += next.value.byteLength;
342
+ if (bytes > maxBytes) throw new Error(`ComfyUI response exceeded the ${String(maxBytes)} byte limit`);
343
+ chunks.push(next.value);
344
+ }
345
+ } finally {
346
+ reader.releaseLock();
347
+ }
348
+ const joined = new Uint8Array(bytes);
349
+ let offset = 0;
350
+ for (const chunk of chunks) {
351
+ joined.set(chunk, offset);
352
+ offset += chunk.byteLength;
353
+ }
354
+ return joined;
355
+ }
356
+ //#endregion
93
357
  //#region lib/types/dashscope.js
94
358
  async function generateDashScopeImage(options) {
95
359
  assertQwenImageModel(options.model);
@@ -116,7 +380,7 @@ async function editDashScopeImage(options) {
116
380
  model: options.model,
117
381
  input: { messages: [{
118
382
  role: "user",
119
- content: [{ image: toDataUrl$1(options.sourceImage) }, { text: options.prompt }]
383
+ content: [...options.sourceImages.map((sourceImage) => ({ image: toDataUrl$1(sourceImage) })), { text: options.prompt }]
120
384
  }] },
121
385
  parameters: {
122
386
  prompt_extend: true,
@@ -197,11 +461,11 @@ function editGoogleImage(input) {
197
461
  interactionInput: [{
198
462
  type: "text",
199
463
  text: input.prompt
200
- }, {
464
+ }, ...input.sourceImages.map((sourceImage) => ({
201
465
  type: "image",
202
- mime_type: input.sourceImage.mediaType,
203
- data: Buffer.from(input.sourceImage.data).toString("base64")
204
- }]
466
+ mime_type: sourceImage.mediaType,
467
+ data: Buffer.from(sourceImage.data).toString("base64")
468
+ }))]
205
469
  });
206
470
  }
207
471
  /** Shared Google request, response parsing, decoding, and size enforcement. */
@@ -309,30 +573,67 @@ async function readBoundedText$2(response, maxBytes, label) {
309
573
  //#region lib/types/reference-image.js
310
574
  /** Latest-DSH compatibility boundary for resolving conversation image references. */
311
575
  /**
312
- * Resolve and read the image to edit from the current effective conversation.
313
- *
314
- * This is the only module allowed to know how DSH sessions expose effective
315
- * messages or how nested model-facing content stores durable image refs.
576
+ * Resolve one or more edit references while keeping every DSH-specific detail
577
+ * behind this compatibility boundary. Explicit selectors preserve caller order;
578
+ * without selectors, all images in the newest image-bearing message are used.
316
579
  */
317
- async function resolveReferenceImage(input) {
318
- if (input.sourceAttachmentId !== void 0 && input.sourcePath !== void 0) throw new Error("edit_image accepts either source_attachment_id or source_path, not both");
319
- if (input.sourcePath !== void 0) return readWorkspaceReferenceImage({
320
- sourcePath: input.sourcePath,
580
+ async function resolveReferenceImages(input) {
581
+ const sourceAttachmentIds = mergeSelectors({
582
+ single: input.sourceAttachmentId,
583
+ multiple: input.sourceAttachmentIds,
584
+ singleName: "source_attachment_id",
585
+ multipleName: "source_attachment_ids",
586
+ equal: attachmentIdsEqual
587
+ });
588
+ const sourcePaths = mergeSelectors({
589
+ single: input.sourcePath,
590
+ multiple: input.sourcePaths,
591
+ singleName: "source_path",
592
+ multipleName: "source_paths",
593
+ equal: (left, right) => left.trim() === right.trim()
594
+ });
595
+ if (sourceAttachmentIds !== void 0 && sourcePaths !== void 0) throw new Error("edit_image accepts only one of source_attachment_id, source_attachment_ids, source_path, or source_paths");
596
+ if (sourcePaths !== void 0) return Promise.all(sourcePaths.map((sourcePath) => readWorkspaceReferenceImage({
597
+ sourcePath,
321
598
  ...input.agent?.session.header?.cwd === void 0 ? {} : { workspaceRoot: input.agent.session.header.cwd },
322
599
  ...input.maxBytes === void 0 ? {} : { maxBytes: input.maxBytes },
323
600
  signal: input.signal
324
- });
601
+ })));
325
602
  if (input.agent === void 0) throw new Error("edit_image requires an active DSH agent session to resolve a reference image");
326
- const ref = findReferenceImage(input.agent.session.deriveMessages(), input.sourceAttachmentId);
327
- if (ref === void 0) {
328
- if (input.sourceAttachmentId !== void 0) throw new Error(`edit_image could not find image attachment ${input.sourceAttachmentId} in the current conversation`);
603
+ const refs = findReferenceImages(input.agent.session.deriveMessages(), sourceAttachmentIds);
604
+ if (refs.length === 0) {
605
+ if (sourceAttachmentIds !== void 0) throw new Error(`edit_image could not find image attachment ${sourceAttachmentIds[0]} in the current conversation`);
329
606
  throw new Error("edit_image requires an image in the current conversation; upload or generate an image first");
330
607
  }
331
- const stored = await input.attachments.readImage(ref, input.signal);
332
- return {
333
- data: stored.data,
334
- mediaType: stored.ref.mediaType
335
- };
608
+ if (sourceAttachmentIds !== void 0 && refs.length !== sourceAttachmentIds.length) {
609
+ const missing = sourceAttachmentIds.find((id) => !refs.some((ref) => attachmentIdsEqual(String(ref.attachmentId), id)));
610
+ throw new Error(`edit_image could not find image attachment ${missing ?? "unknown"} in the current conversation`);
611
+ }
612
+ return Promise.all(refs.map(async (ref) => {
613
+ const stored = await input.attachments.readImage(ref, input.signal);
614
+ if (input.maxBytes !== void 0 && stored.data.byteLength > input.maxBytes) throw new Error(`edit_image source image is too large (${stored.data.byteLength} bytes; maximum ${input.maxBytes})`);
615
+ return {
616
+ data: stored.data,
617
+ mediaType: stored.ref.mediaType
618
+ };
619
+ }));
620
+ }
621
+ /** Find images in caller order, or every image in the newest image-bearing message. */
622
+ function findReferenceImages(messages, sourceAttachmentIds) {
623
+ if (sourceAttachmentIds !== void 0) return sourceAttachmentIds.flatMap((id) => {
624
+ const ref = findReferenceImage(messages, id);
625
+ return ref === void 0 ? [] : [ref];
626
+ });
627
+ const latestHumanMessage = [...messages].reverse().find((message) => message.source?.kind === "user");
628
+ if (latestHumanMessage !== void 0) {
629
+ const refs = collectInBlocks(latestHumanMessage.content);
630
+ if (refs.length > 0) return refs;
631
+ }
632
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
633
+ const refs = collectInBlocks(messages[index]?.content ?? []);
634
+ if (refs.length > 0) return refs;
635
+ }
636
+ return [];
336
637
  }
337
638
  /**
338
639
  * Read an explicitly named workspace image without exposing filesystem or DSH
@@ -397,7 +698,7 @@ function findInBlocks(blocks, sourceAttachmentId) {
397
698
  const block = blocks[index];
398
699
  if (block === void 0) continue;
399
700
  if (block.type === "image") {
400
- if (sourceAttachmentId === void 0 || String(block.attachment.attachmentId) === sourceAttachmentId) return block.attachment;
701
+ if (sourceAttachmentId === void 0 || attachmentIdsEqual(String(block.attachment.attachmentId), sourceAttachmentId)) return block.attachment;
401
702
  continue;
402
703
  }
403
704
  if (block.type === "tool-result") {
@@ -417,6 +718,33 @@ function parseDimensions(value) {
417
718
  function imageMediaType$2(value) {
418
719
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
419
720
  }
721
+ function collectInBlocks(blocks) {
722
+ const refs = [];
723
+ for (const block of blocks) {
724
+ if (block.type === "image") refs.push(block.attachment);
725
+ if (block.type === "tool-result") refs.push(...collectInBlocks(block.content));
726
+ }
727
+ return refs;
728
+ }
729
+ function attachmentIdsEqual(actual, requested) {
730
+ if (actual === requested) return true;
731
+ const actualDigest = sha256Digest(actual);
732
+ const requestedDigest = sha256Digest(requested);
733
+ return actualDigest !== void 0 && actualDigest === requestedDigest;
734
+ }
735
+ function sha256Digest(value) {
736
+ return /^(?:sha256:)?([0-9a-f]{64})$/i.exec(value.trim())?.[1]?.toLowerCase();
737
+ }
738
+ function mergeSelectors(input) {
739
+ if (input.multiple === void 0) return input.single === void 0 ? void 0 : [input.single];
740
+ if (input.multiple.length === 0) {
741
+ if (input.single !== void 0) return [input.single];
742
+ throw new Error(`edit_image ${input.multipleName} must not be empty`);
743
+ }
744
+ const single = input.single;
745
+ if (single !== void 0 && !input.multiple.some((value) => input.equal(single, value))) throw new Error(`edit_image ${input.singleName} must also appear in ${input.multipleName} when both are provided`);
746
+ return input.multiple;
747
+ }
420
748
  function detectImageMediaType(data) {
421
749
  if (startsWith(data, [
422
750
  137,
@@ -538,8 +866,11 @@ async function generateOpenAICompatibleImage(input) {
538
866
  }
539
867
  async function editOpenAICompatibleImage(input) {
540
868
  const form = new FormData();
541
- const uploadBytes = new Uint8Array(input.sourceImage.data);
542
- form.append("image", new Blob([uploadBytes], { type: input.sourceImage.mediaType }), `reference.${extensionOf(input.sourceImage.mediaType)}`);
869
+ const imageField = input.sourceImages.length === 1 ? "image" : "image[]";
870
+ input.sourceImages.forEach((sourceImage, index) => {
871
+ const uploadBytes = new Uint8Array(sourceImage.data);
872
+ form.append(imageField, new Blob([uploadBytes], { type: sourceImage.mediaType }), `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`);
873
+ });
543
874
  form.append("prompt", input.prompt);
544
875
  form.append("model", input.model);
545
876
  if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
@@ -684,7 +1015,7 @@ async function editSeedreamImage(input) {
684
1015
  body: JSON.stringify({
685
1016
  model: input.model,
686
1017
  prompt: input.prompt,
687
- image: [toDataUrl(input.sourceImage)],
1018
+ image: input.sourceImages.map(toDataUrl),
688
1019
  ...input.size === void 0 || input.size.length === 0 ? {} : { size: input.size },
689
1020
  response_format: "b64_json"
690
1021
  })
@@ -961,6 +1292,14 @@ function apply(ctx, config = {}) {
961
1292
  output: imageOutput("Generated"),
962
1293
  async execute(args, exec) {
963
1294
  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);
964
1303
  const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
965
1304
  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.`);
966
1305
  if (active.provider === "google") {
@@ -1005,7 +1344,7 @@ function apply(ctx, config = {}) {
1005
1344
  }));
1006
1345
  ctx.tools.register(defineTool({
1007
1346
  name: "edit_image",
1008
- description: "Edit or restyle an existing image with the configured provider. For a named workspace file, pass its exact path as source_path. For a specific image still attached to the current conversation, pass source_attachment_id. Never provide both. Omit both only when the user clearly means the newest conversation image. If the user identifies an older image but its exact path or attachment id is unknown, do not silently edit the newest image: first use an image-reading tool to put the intended file into the conversation, then call edit_image without a source.",
1347
+ description: "Edit, combine, or restyle existing images with the configured provider. Images attached inline to the latest human message are already readable DSH attachments even when no workspace file exists. In that case, call edit_image immediately with prompt only; NEVER call read_image, glob, or shell to locate them, and NEVER invent @ paths. All inline images will be used in upload order. For specific older conversation images use source_attachment_id or source_attachment_ids; both canonical sha256: IDs and full bare SHA-256 digests are accepted. For files the user explicitly names in the workspace use source_path or source_paths. Provide exactly one selector field. Without a selector, images from the latest human message take priority; only when that message has no images does editing fall back to the newest conversation image.",
1009
1348
  parameters: {
1010
1349
  prompt: {
1011
1350
  type: "string",
@@ -1016,10 +1355,20 @@ function apply(ctx, config = {}) {
1016
1355
  type: "string",
1017
1356
  description: "Optional attachment id of a specific image already present in the current conversation."
1018
1357
  },
1358
+ source_attachment_ids: {
1359
+ type: "array",
1360
+ items: { type: "string" },
1361
+ description: "Optional ordered attachment ids of multiple images already present in the current conversation. Prompt references such as image 1 and image 2 follow this order."
1362
+ },
1019
1363
  source_path: {
1020
1364
  type: "string",
1021
1365
  description: "Optional absolute or workspace-relative path of a specific image file inside the active session workspace. Prefer this when the user names a saved file."
1022
1366
  },
1367
+ source_paths: {
1368
+ type: "array",
1369
+ items: { type: "string" },
1370
+ description: "Optional ordered absolute or workspace-relative paths of multiple image files inside the active session workspace."
1371
+ },
1023
1372
  aspect_ratio: {
1024
1373
  type: "string",
1025
1374
  enum: [
@@ -1050,13 +1399,16 @@ function apply(ctx, config = {}) {
1050
1399
  output: imageOutput("Edited"),
1051
1400
  async execute(args, exec) {
1052
1401
  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");
1053
1403
  const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
1054
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.`);
1055
- const sourceImage = await resolveReferenceImage({
1405
+ const sourceImages = await resolveReferenceImages({
1056
1406
  ...exec.agent === void 0 ? {} : { agent: exec.agent },
1057
1407
  attachments: ctx.attachments,
1058
1408
  ...typeof args.source_attachment_id === "string" ? { sourceAttachmentId: args.source_attachment_id } : {},
1409
+ ...Array.isArray(args.source_attachment_ids) ? { sourceAttachmentIds: args.source_attachment_ids } : {},
1059
1410
  ...typeof args.source_path === "string" ? { sourcePath: args.source_path } : {},
1411
+ ...Array.isArray(args.source_paths) ? { sourcePaths: args.source_paths } : {},
1060
1412
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1061
1413
  signal: exec.signal
1062
1414
  });
@@ -1068,7 +1420,7 @@ function apply(ctx, config = {}) {
1068
1420
  endpoint: active.endpoint,
1069
1421
  model: active.model,
1070
1422
  prompt: args.prompt,
1071
- sourceImage,
1423
+ sourceImages,
1072
1424
  aspectRatio,
1073
1425
  imageSize,
1074
1426
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
@@ -1081,7 +1433,7 @@ function apply(ctx, config = {}) {
1081
1433
  baseURL: active.baseURL,
1082
1434
  model: active.model,
1083
1435
  prompt: args.prompt,
1084
- sourceImage,
1436
+ sourceImages,
1085
1437
  size,
1086
1438
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1087
1439
  signal: exec.signal
@@ -1091,7 +1443,7 @@ function apply(ctx, config = {}) {
1091
1443
  baseURL: active.baseURL,
1092
1444
  model: active.model,
1093
1445
  prompt: args.prompt,
1094
- sourceImage,
1446
+ sourceImages,
1095
1447
  size,
1096
1448
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1097
1449
  signal: exec.signal
@@ -1101,7 +1453,7 @@ function apply(ctx, config = {}) {
1101
1453
  endpoint: active.endpoint,
1102
1454
  model: active.model,
1103
1455
  prompt: args.prompt,
1104
- sourceImage,
1456
+ sourceImages,
1105
1457
  size,
1106
1458
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1107
1459
  signal: exec.signal
@@ -1,5 +1,4 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
- import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client';
3
2
  import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client';
4
3
  import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
5
4
  import { type ImageProvider } from '../shared.js';
@@ -15,12 +14,33 @@ interface ImageSettings {
15
14
  seedreamModel?: string;
16
15
  dashscopeEndpoint?: string;
17
16
  dashscopeModel?: string;
17
+ comfyuiBaseURL?: string;
18
+ comfyuiWorkflowJson?: string;
19
+ comfyuiWorkflowName?: string;
20
+ comfyuiTimeoutMs?: number;
18
21
  saveToWorkspace?: boolean;
19
22
  workspaceFolder?: string;
20
23
  }
24
+ interface CredentialInfo {
25
+ configured?: boolean;
26
+ }
27
+ interface CredentialResult {
28
+ ok: boolean;
29
+ value?: Readonly<Record<string, CredentialInfo>>;
30
+ }
31
+ interface CredentialMutationResult {
32
+ ok: boolean;
33
+ error?: {
34
+ message?: string;
35
+ };
36
+ }
37
+ interface CredentialsRemote {
38
+ describe(refs: string[]): Promise<CredentialResult>;
39
+ set(ref: string, value: string): Promise<CredentialMutationResult>;
40
+ }
21
41
  interface SettingsFace {
22
42
  scope: SettingsScope<ImageSettings>;
23
- credentials: ConnectionHandle['api']['credentials'];
43
+ credentials: CredentialsRemote;
24
44
  locale?: LocaleService | undefined;
25
45
  }
26
46
  interface ImageCardFace {
@@ -0,0 +1,8 @@
1
+ export declare const COMFYUI_PROMPT_PLACEHOLDER = "{{prompt}}";
2
+ export declare const COMFYUI_SEED_PLACEHOLDER = "{{seed}}";
3
+ type JsonRecord = Record<string, unknown>;
4
+ /** Validate imported JSON without exposing workflow graph details to callers. */
5
+ export declare function validateComfyUIWorkflowJson(workflowJson: string): void;
6
+ /** Parse, clone, and inject one prompt plus an optional randomized seed. */
7
+ export declare function prepareComfyUIWorkflow(workflowJson: string, prompt: string, seed?: number): JsonRecord;
8
+ export {};
@@ -0,0 +1,15 @@
1
+ /** ComfyUI text-to-image adapter using an imported API-format workflow. */
2
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment';
3
+ export interface GeneratedComfyUIImage {
4
+ data: Uint8Array;
5
+ mediaType: ImageMediaType;
6
+ }
7
+ /** Run one ComfyUI workflow and return its first final image. */
8
+ export declare function generateComfyUIImage(input: {
9
+ baseURL: string;
10
+ workflowJson: string;
11
+ prompt: string;
12
+ timeoutMs: number;
13
+ maxBytes: number;
14
+ signal: AbortSignal;
15
+ }): Promise<GeneratedComfyUIImage>;
@@ -1,7 +1,7 @@
1
1
  /** User-facing configuration for supported image providers. */
2
2
  import z from '@deepseek-ai/schemastery';
3
- import { DEFAULT_DASHSCOPE_ENDPOINT, DEFAULT_DASHSCOPE_MODEL, DEFAULT_GOOGLE_ENDPOINT, DEFAULT_GOOGLE_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_SEEDREAM_BASE_URL, DEFAULT_SEEDREAM_MODEL, IMAGE_PROVIDERS, type ImageProvider } from './shared.js';
4
- export { DEFAULT_DASHSCOPE_ENDPOINT, DEFAULT_DASHSCOPE_MODEL, DEFAULT_GOOGLE_ENDPOINT, DEFAULT_GOOGLE_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_SEEDREAM_BASE_URL, DEFAULT_SEEDREAM_MODEL, IMAGE_PROVIDERS, type ImageProvider, };
3
+ import { DEFAULT_DASHSCOPE_ENDPOINT, DEFAULT_DASHSCOPE_MODEL, DEFAULT_COMFYUI_BASE_URL, DEFAULT_COMFYUI_TIMEOUT_MS, DEFAULT_COMFYUI_WORKFLOW_LABEL, DEFAULT_GOOGLE_ENDPOINT, DEFAULT_GOOGLE_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_SEEDREAM_BASE_URL, DEFAULT_SEEDREAM_MODEL, IMAGE_PROVIDERS, type ImageProvider } from './shared.js';
4
+ export { DEFAULT_DASHSCOPE_ENDPOINT, DEFAULT_DASHSCOPE_MODEL, DEFAULT_COMFYUI_BASE_URL, DEFAULT_COMFYUI_TIMEOUT_MS, DEFAULT_COMFYUI_WORKFLOW_LABEL, DEFAULT_GOOGLE_ENDPOINT, DEFAULT_GOOGLE_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_SEEDREAM_BASE_URL, DEFAULT_SEEDREAM_MODEL, IMAGE_PROVIDERS, type ImageProvider, };
5
5
  /** Default workspace subfolder that receives generated image files. */
6
6
  export declare const DEFAULT_WORKSPACE_FOLDER = "dsh-image-gen";
7
7
  /** Google API credential reference. */
@@ -28,6 +28,12 @@ export interface Config {
28
28
  seedreamModel?: string;
29
29
  dashscopeEndpoint?: string;
30
30
  dashscopeModel?: string;
31
+ comfyuiBaseURL?: string;
32
+ /** ComfyUI API-format workflow JSON imported through the Web settings page. */
33
+ comfyuiWorkflowJson?: string;
34
+ /** Original imported file name, used as a human-readable result label. */
35
+ comfyuiWorkflowName?: string;
36
+ comfyuiTimeoutMs?: number;
31
37
  /** Also write every generated image as a file under the session workspace. */
32
38
  saveToWorkspace?: boolean;
33
39
  /** Workspace subfolder for generated images; empty means the workspace root. */
@@ -61,4 +67,10 @@ export declare function resolveProvider(config: Config): {
61
67
  model: string;
62
68
  endpoint: string;
63
69
  imageSize: string;
70
+ } | {
71
+ provider: 'comfyui';
72
+ baseURL: string;
73
+ workflowJson: string;
74
+ workflowName: string;
75
+ timeoutMs: number;
64
76
  };