dsh-image-gen 0.1.6 → 0.2.0

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
@@ -2,6 +2,9 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
2
  import { installSettingsSection, settingsNamespace } 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";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { randomUUID } from "node:crypto";
5
8
  //#region lib/types/shared.js
6
9
  /** Values shared by the Host and browser Bundle faces. */
7
10
  /** Browser route used by the generated-image card. */
@@ -24,10 +27,12 @@ const DEFAULT_DASHSCOPE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1";
24
27
  const DEFAULT_GOOGLE_MODEL = "gemini-3.1-flash-image";
25
28
  const DEFAULT_OPENAI_MODEL = "gpt-image-2";
26
29
  const DEFAULT_SEEDREAM_MODEL = "doubao-seedream-5-0-260128";
27
- const DEFAULT_DASHSCOPE_MODEL = "wanx2.1-t2i-turbo";
30
+ const DEFAULT_DASHSCOPE_MODEL = "qwen-image-3.0";
28
31
  //#endregion
29
32
  //#region lib/types/config.js
30
33
  /** User-facing configuration for supported image providers. */
34
+ /** Default workspace subfolder that receives generated image files. */
35
+ const DEFAULT_WORKSPACE_FOLDER = "dsh-image-gen";
31
36
  /** Google API credential reference. */
32
37
  const GOOGLE_API_KEY_ENV = "GEMINI_API_KEY";
33
38
  /** OpenAI Platform or compatible relay credential reference. */
@@ -46,7 +51,9 @@ const Config = z.object({
46
51
  seedreamBaseURL: z.string().default(DEFAULT_SEEDREAM_BASE_URL),
47
52
  seedreamModel: z.string().default(DEFAULT_SEEDREAM_MODEL),
48
53
  dashscopeEndpoint: z.string().default(DEFAULT_DASHSCOPE_ENDPOINT),
49
- dashscopeModel: z.string().default(DEFAULT_DASHSCOPE_MODEL)
54
+ dashscopeModel: z.string().default(DEFAULT_DASHSCOPE_MODEL),
55
+ saveToWorkspace: z.boolean().default(true),
56
+ workspaceFolder: z.string().default(DEFAULT_WORKSPACE_FOLDER)
50
57
  });
51
58
  /** Resolve exactly one provider profile for a tool call. */
52
59
  function resolveProvider(config) {
@@ -68,7 +75,7 @@ function resolveProvider(config) {
68
75
  case "dashscope": return {
69
76
  provider: "dashscope",
70
77
  apiKeyEnv: DASHSCOPE_API_KEY_ENV,
71
- model: config.dashscopeModel ?? "wanx2.1-t2i-turbo",
78
+ model: config.dashscopeModel ?? "qwen-image-3.0",
72
79
  endpoint: config.dashscopeEndpoint ?? "https://dashscope.aliyuncs.com/api/v1",
73
80
  imageSize: "1024*1024"
74
81
  };
@@ -84,83 +91,75 @@ function resolveProvider(config) {
84
91
  }
85
92
  //#endregion
86
93
  //#region lib/types/dashscope.js
87
- /** Call DashScope image synthesis/generation API and handle both synchronous output & async task polling. */
88
94
  async function generateDashScopeImage(options) {
95
+ assertQwenImageModel(options.model);
96
+ const formattedSize = formatSize(options.size);
97
+ return requestQwenImage({
98
+ ...options,
99
+ requestBody: {
100
+ model: options.model,
101
+ input: { messages: [{
102
+ role: "user",
103
+ content: [{ text: options.prompt }]
104
+ }] },
105
+ parameters: { ...formattedSize === void 0 ? {} : { size: formattedSize } }
106
+ },
107
+ operation: "generation"
108
+ });
109
+ }
110
+ async function editDashScopeImage(options) {
111
+ assertQwenImageModel(options.model);
112
+ const formattedSize = formatSize(options.size);
113
+ return requestQwenImage({
114
+ ...options,
115
+ requestBody: {
116
+ model: options.model,
117
+ input: { messages: [{
118
+ role: "user",
119
+ content: [{ image: toDataUrl$1(options.sourceImage) }, { text: options.prompt }]
120
+ }] },
121
+ parameters: {
122
+ prompt_extend: true,
123
+ ...formattedSize === void 0 ? {} : { size: formattedSize }
124
+ }
125
+ },
126
+ operation: "editing"
127
+ });
128
+ }
129
+ function assertQwenImageModel(model) {
130
+ if (!model.toLowerCase().startsWith("qwen-image")) throw new Error(`Unsupported DashScope image model ${model}. Configure a qwen-image model.`);
131
+ }
132
+ function formatSize(size) {
133
+ if (size === void 0 || size.length === 0) return void 0;
134
+ return size.replace("x", "*");
135
+ }
136
+ function toDataUrl$1(image) {
137
+ return `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
138
+ }
139
+ async function requestQwenImage(options) {
89
140
  const base = options.endpoint.replace(/\/+$/, "");
90
- const formattedSize = (options.size ?? "1024*1024").replace("x", "*");
91
- const isQwenImage = options.model.toLowerCase().startsWith("qwen-image");
92
- const submitUrl = isQwenImage ? `${base}/services/aigc/multimodal-generation/generation` : `${base}/services/aigc/text2image/image-synthesis`;
93
- const requestBody = isQwenImage ? {
94
- model: options.model,
95
- input: { messages: [{
96
- role: "user",
97
- content: [{ text: options.prompt }]
98
- }] },
99
- parameters: { size: formattedSize }
100
- } : {
101
- model: options.model,
102
- input: { prompt: options.prompt },
103
- parameters: {
104
- size: formattedSize,
105
- n: 1
106
- }
107
- };
108
- const submitResponse = await fetch(submitUrl, {
141
+ const response = await fetch(`${base}/services/aigc/multimodal-generation/generation`, {
109
142
  method: "POST",
110
143
  ...options.signal ? { signal: options.signal } : {},
111
144
  headers: {
112
145
  "content-type": "application/json",
113
- authorization: `Bearer ${options.apiKey}`,
114
- "X-DashScope-Async": "enable"
146
+ authorization: `Bearer ${options.apiKey}`
115
147
  },
116
- body: JSON.stringify(requestBody)
148
+ body: JSON.stringify(options.requestBody)
117
149
  });
118
- if (!submitResponse.ok) {
119
- const errorText = await submitResponse.text();
120
- throw new Error(`DashScope image generation failed (${String(submitResponse.status)}): ${errorText}`);
121
- }
122
- const submitResult = await submitResponse.json();
123
- const directImageUrl = extractImageUrl(submitResult);
124
- if (directImageUrl !== void 0) return downloadImageBlob(directImageUrl, options);
125
- const taskId = submitResult.output?.task_id;
126
- if (taskId === void 0 || taskId.length === 0) throw new Error(`DashScope did not return an image URL or task_id: ${submitResult.message ?? JSON.stringify(submitResult)}`);
127
- const taskQueryUrl = `${base}/tasks/${taskId}`;
128
- const startTime = Date.now();
129
- const timeoutMs = 6e4;
130
- while (Date.now() - startTime < timeoutMs) {
131
- if (options.signal?.aborted) throw new Error("DashScope image task polling aborted");
132
- await new Promise((resolve, reject) => {
133
- const timer = setTimeout(resolve, 1500);
134
- if (options.signal !== void 0) options.signal.addEventListener("abort", () => {
135
- clearTimeout(timer);
136
- reject(/* @__PURE__ */ new Error("DashScope image generation aborted"));
137
- }, { once: true });
138
- });
139
- const taskResponse = await fetch(taskQueryUrl, {
140
- method: "GET",
141
- ...options.signal ? { signal: options.signal } : {},
142
- headers: { authorization: `Bearer ${options.apiKey}` }
143
- });
144
- if (!taskResponse.ok) {
145
- const errorText = await taskResponse.text();
146
- throw new Error(`DashScope task query failed (${String(taskResponse.status)}): ${errorText}`);
147
- }
148
- const taskResult = await taskResponse.json();
149
- const status = taskResult.output?.task_status;
150
- if (status === "SUCCEEDED") {
151
- const imageUrl = extractImageUrl(taskResult);
152
- if (imageUrl === void 0 || imageUrl.length === 0) throw new Error("DashScope task succeeded but returned no image URL");
153
- return downloadImageBlob(imageUrl, options);
154
- }
155
- if (status === "FAILED") throw new Error(`DashScope image generation failed: ${taskResult.output?.message ?? taskResult.message ?? "Unknown error"}`);
150
+ if (!response.ok) {
151
+ const errorText = await response.text();
152
+ throw new Error(`DashScope image ${options.operation} failed (${String(response.status)}): ${errorText}`);
156
153
  }
157
- throw new Error(`DashScope image generation timed out after ${String(timeoutMs / 1e3)} seconds`);
154
+ const payload = await response.json();
155
+ const imageUrl = extractImageUrl(payload);
156
+ if (imageUrl === void 0) throw new Error(`DashScope image ${options.operation} returned no image URL: ${payload.message ?? JSON.stringify(payload)}`);
157
+ return downloadImageBlob(imageUrl, options);
158
158
  }
159
159
  function extractImageUrl(response) {
160
- const resultUrl = response.output?.results?.[0]?.url;
161
- if (resultUrl !== void 0 && resultUrl.length > 0) return resultUrl;
162
160
  const contents = response.output?.choices?.[0]?.message?.content;
163
- if (Array.isArray(contents)) for (const item of contents) {
161
+ if (!Array.isArray(contents)) return void 0;
162
+ for (const item of contents) {
164
163
  if (item.image !== void 0 && item.image.length > 0) return item.image;
165
164
  if (item.image_url !== void 0 && item.image_url.length > 0) return item.image_url;
166
165
  if (item.url !== void 0 && item.url.length > 0) return item.url;
@@ -180,10 +179,34 @@ async function downloadImageBlob(imageUrl, options) {
180
179
  }
181
180
  //#endregion
182
181
  //#region lib/types/google.js
183
- const ERROR_LIMIT$1 = 4096;
182
+ const ERROR_LIMIT$2 = 4096;
184
183
  const REQUESTED_MEDIA_TYPE = "image/jpeg";
185
- /** Send one native Google image request and validate its inline image response. */
186
- async function generateGoogleImage(input) {
184
+ /** Send one native Google text-to-image request. */
185
+ function generateGoogleImage(input) {
186
+ return requestGoogleImage({
187
+ ...input,
188
+ operation: "generation",
189
+ interactionInput: input.prompt
190
+ });
191
+ }
192
+ /** Send one native Google image-editing request using already-resolved bytes. */
193
+ function editGoogleImage(input) {
194
+ return requestGoogleImage({
195
+ ...input,
196
+ operation: "editing",
197
+ interactionInput: [{
198
+ type: "text",
199
+ text: input.prompt
200
+ }, {
201
+ type: "image",
202
+ mime_type: input.sourceImage.mediaType,
203
+ data: Buffer.from(input.sourceImage.data).toString("base64")
204
+ }]
205
+ });
206
+ }
207
+ /** Shared Google request, response parsing, decoding, and size enforcement. */
208
+ async function requestGoogleImage(input) {
209
+ const label = `Google image ${input.operation}`;
187
210
  const response = await fetch(input.endpoint, {
188
211
  method: "POST",
189
212
  redirect: "error",
@@ -194,7 +217,7 @@ async function generateGoogleImage(input) {
194
217
  },
195
218
  body: JSON.stringify({
196
219
  model: input.model,
197
- input: input.prompt,
220
+ input: input.interactionInput,
198
221
  response_format: {
199
222
  type: "image",
200
223
  mime_type: REQUESTED_MEDIA_TYPE,
@@ -203,33 +226,33 @@ async function generateGoogleImage(input) {
203
226
  }
204
227
  })
205
228
  });
206
- const text = await readBoundedText$1(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT$1);
207
- if (!response.ok) throw new Error(`Google image generation failed (${response.status}): ${text.slice(0, ERROR_LIMIT$1)}`);
229
+ const text = await readBoundedText$2(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT$2, label);
230
+ if (!response.ok) throw new Error(`${label} failed (${response.status}): ${text.slice(0, ERROR_LIMIT$2)}`);
208
231
  let payload;
209
232
  try {
210
233
  payload = JSON.parse(text);
211
234
  } catch {
212
- throw new Error("Google image generation returned invalid JSON");
235
+ throw new Error(`${label} returned invalid JSON`);
213
236
  }
214
237
  const image = outputImage(payload);
215
- if (image === void 0) throw new Error(`Google image generation returned no image: ${text.slice(0, ERROR_LIMIT$1)}`);
238
+ if (image === void 0) throw new Error(`${label} returned no image: ${text.slice(0, ERROR_LIMIT$2)}`);
216
239
  const mediaType = mediaTypeOf(image.mime_type ?? REQUESTED_MEDIA_TYPE);
217
- if (mediaType === void 0) throw new Error(`Google image generation returned unsupported media type ${JSON.stringify(image.mime_type)}`);
218
- const data = decodeBase64$1(image.data);
219
- if (data.byteLength > input.maxBytes) throw new Error(`Google image generation exceeded the ${String(input.maxBytes)} byte image limit`);
240
+ if (mediaType === void 0) throw new Error(`${label} returned unsupported media type ${JSON.stringify(image.mime_type)}`);
241
+ const data = decodeBase64$2(image.data, label);
242
+ if (data.byteLength > input.maxBytes) throw new Error(`${label} exceeded the ${String(input.maxBytes)} byte image limit`);
220
243
  return {
221
244
  data,
222
245
  mediaType
223
246
  };
224
247
  }
225
248
  function outputImage(value) {
226
- const interaction = record$1(value);
249
+ const interaction = record$2(value);
227
250
  if (interaction === void 0) return void 0;
228
251
  const direct = imageContent(interaction.output_image, false);
229
252
  if (direct !== void 0) return direct;
230
253
  if (!Array.isArray(interaction.steps)) return void 0;
231
254
  for (const step of interaction.steps) {
232
- const modelOutput = record$1(step);
255
+ const modelOutput = record$2(step);
233
256
  if (modelOutput?.type !== "model_output" || !Array.isArray(modelOutput.content)) continue;
234
257
  for (const content of modelOutput.content) {
235
258
  const image = imageContent(content, true);
@@ -238,27 +261,27 @@ function outputImage(value) {
238
261
  }
239
262
  }
240
263
  function imageContent(value, requiresImageType) {
241
- const image = record$1(value);
264
+ const image = record$2(value);
242
265
  if (image === void 0 || requiresImageType && image.type !== "image" || typeof image.data !== "string") return void 0;
243
266
  return {
244
267
  data: image.data,
245
268
  mime_type: image.mime_type
246
269
  };
247
270
  }
248
- function record$1(value) {
271
+ function record$2(value) {
249
272
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
250
273
  }
251
274
  function mediaTypeOf(value) {
252
275
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif" ? value : void 0;
253
276
  }
254
- function decodeBase64$1(data) {
277
+ function decodeBase64$2(data, label) {
255
278
  const clean = data.replace(/\s+/g, "");
256
- if (clean.length === 0) throw new Error("Google image generation returned invalid base64 image data");
279
+ if (clean.length === 0) throw new Error(`${label} returned invalid base64 image data`);
257
280
  const decoded = Buffer.from(clean, "base64");
258
- if (decoded.length === 0) throw new Error("Google image generation returned invalid base64 image data");
281
+ if (decoded.length === 0) throw new Error(`${label} returned invalid base64 image data`);
259
282
  return new Uint8Array(decoded);
260
283
  }
261
- async function readBoundedText$1(response, maxBytes) {
284
+ async function readBoundedText$2(response, maxBytes, label) {
262
285
  if (response.body === null) return "";
263
286
  const reader = response.body.getReader();
264
287
  const chunks = [];
@@ -268,7 +291,7 @@ async function readBoundedText$1(response, maxBytes) {
268
291
  const next = await reader.read();
269
292
  if (next.done) break;
270
293
  bytes += next.value.byteLength;
271
- if (bytes > maxBytes) throw new Error(`Google image generation response exceeded the ${String(maxBytes)} byte limit`);
294
+ if (bytes > maxBytes) throw new Error(`${label} response exceeded the ${String(maxBytes)} byte limit`);
272
295
  chunks.push(next.value);
273
296
  }
274
297
  } finally {
@@ -283,6 +306,156 @@ async function readBoundedText$1(response, maxBytes) {
283
306
  return new TextDecoder().decode(joined);
284
307
  }
285
308
  //#endregion
309
+ //#region lib/types/reference-image.js
310
+ /** Latest-DSH compatibility boundary for resolving conversation image references. */
311
+ /**
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.
316
+ */
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,
321
+ ...input.agent?.session.header?.cwd === void 0 ? {} : { workspaceRoot: input.agent.session.header.cwd },
322
+ ...input.maxBytes === void 0 ? {} : { maxBytes: input.maxBytes },
323
+ signal: input.signal
324
+ });
325
+ 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`);
329
+ throw new Error("edit_image requires an image in the current conversation; upload or generate an image first");
330
+ }
331
+ const stored = await input.attachments.readImage(ref, input.signal);
332
+ return {
333
+ data: stored.data,
334
+ mediaType: stored.ref.mediaType
335
+ };
336
+ }
337
+ /**
338
+ * Read an explicitly named workspace image without exposing filesystem or DSH
339
+ * details to provider adapters. Both lexical and real-path containment are
340
+ * enforced so absolute paths, parent traversal, and symlink escapes fail.
341
+ */
342
+ async function readWorkspaceReferenceImage(input) {
343
+ const requested = input.sourcePath.trim();
344
+ if (requested.length === 0) throw new Error("edit_image source_path must not be empty");
345
+ if (input.workspaceRoot === void 0) throw new Error("edit_image source_path requires an active DSH session workspace");
346
+ const root = resolve(input.workspaceRoot);
347
+ const candidate = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);
348
+ if (!containsPath$1(root, candidate)) throw new Error("edit_image source_path must stay inside the session workspace: " + requested);
349
+ let realRoot;
350
+ let realCandidate;
351
+ try {
352
+ [realRoot, realCandidate] = await Promise.all([realpath(root), realpath(candidate)]);
353
+ } catch (error) {
354
+ if (error.code === "ENOENT") throw new Error("edit_image could not find workspace image: " + requested);
355
+ throw error;
356
+ }
357
+ if (!containsPath$1(realRoot, realCandidate)) throw new Error("edit_image source_path resolves outside the session workspace: " + requested);
358
+ const file = await stat(realCandidate);
359
+ if (!file.isFile()) throw new Error("edit_image source_path is not a file: " + requested);
360
+ if (input.maxBytes !== void 0 && file.size > input.maxBytes) throw new Error("edit_image source image is too large (" + file.size + " bytes; maximum " + input.maxBytes + ")");
361
+ const data = await readFile(realCandidate, { signal: input.signal });
362
+ const mediaType = detectImageMediaType(data);
363
+ if (mediaType === void 0) throw new Error("edit_image source_path is not a supported PNG, JPEG, WebP, or GIF image: " + requested);
364
+ return {
365
+ data: new Uint8Array(data),
366
+ mediaType
367
+ };
368
+ }
369
+ /** Find the newest matching image in the effective, replacement-aware history. */
370
+ function findReferenceImage(messages, sourceAttachmentId) {
371
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
372
+ const found = findInBlocks(messages[index]?.content ?? [], sourceAttachmentId);
373
+ if (found !== void 0) return found;
374
+ }
375
+ }
376
+ /** Validate an untrusted serialized image reference at an HTTP/UI boundary. */
377
+ function parseImageAttachmentRef(value) {
378
+ const ref = record$1(value);
379
+ if (ref === void 0) return void 0;
380
+ if (typeof ref.attachmentId !== "string" || !imageMediaType$2(ref.mediaType)) return void 0;
381
+ if (!nonNegativeInteger(ref.bytes) || !positiveInteger(ref.width) || !positiveInteger(ref.height)) return void 0;
382
+ if (ref.name !== void 0 && typeof ref.name !== "string") return void 0;
383
+ const originalDimensions = ref.originalDimensions === void 0 ? void 0 : parseDimensions(ref.originalDimensions);
384
+ if (ref.originalDimensions !== void 0 && originalDimensions === void 0) return void 0;
385
+ return {
386
+ attachmentId: ref.attachmentId,
387
+ mediaType: ref.mediaType,
388
+ bytes: ref.bytes,
389
+ width: ref.width,
390
+ height: ref.height,
391
+ ...typeof ref.name === "string" ? { name: ref.name } : {},
392
+ ...originalDimensions === void 0 ? {} : { originalDimensions }
393
+ };
394
+ }
395
+ function findInBlocks(blocks, sourceAttachmentId) {
396
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
397
+ const block = blocks[index];
398
+ if (block === void 0) continue;
399
+ if (block.type === "image") {
400
+ if (sourceAttachmentId === void 0 || String(block.attachment.attachmentId) === sourceAttachmentId) return block.attachment;
401
+ continue;
402
+ }
403
+ if (block.type === "tool-result") {
404
+ const nested = findInBlocks(block.content, sourceAttachmentId);
405
+ if (nested !== void 0) return nested;
406
+ }
407
+ }
408
+ }
409
+ function parseDimensions(value) {
410
+ const dimensions = record$1(value);
411
+ if (dimensions === void 0 || !positiveInteger(dimensions.width) || !positiveInteger(dimensions.height)) return void 0;
412
+ return {
413
+ width: dimensions.width,
414
+ height: dimensions.height
415
+ };
416
+ }
417
+ function imageMediaType$2(value) {
418
+ return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
419
+ }
420
+ function detectImageMediaType(data) {
421
+ if (startsWith(data, [
422
+ 137,
423
+ 80,
424
+ 78,
425
+ 71,
426
+ 13,
427
+ 10,
428
+ 26,
429
+ 10
430
+ ])) return "image/png";
431
+ if (startsWith(data, [
432
+ 255,
433
+ 216,
434
+ 255
435
+ ])) return "image/jpeg";
436
+ if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
437
+ if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 4) === "WEBP") return "image/webp";
438
+ }
439
+ function startsWith(data, signature) {
440
+ return signature.every((byte, index) => data[index] === byte);
441
+ }
442
+ function ascii(data, offset, length) {
443
+ return String.fromCharCode(...data.subarray(offset, offset + length));
444
+ }
445
+ function containsPath$1(parent, child) {
446
+ const rel = relative(parent, child);
447
+ return rel === "" || rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
448
+ }
449
+ function positiveInteger(value) {
450
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
451
+ }
452
+ function nonNegativeInteger(value) {
453
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
454
+ }
455
+ function record$1(value) {
456
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
457
+ }
458
+ //#endregion
286
459
  //#region lib/types/image-route.js
287
460
  const MAX_BODY_BYTES = 4096;
288
461
  /** Serve one verified durable image reference to a same-origin browser request. */
@@ -317,20 +490,10 @@ async function serveImage(req, res, deps) {
317
490
  function imageAttachmentFromMeta(meta) {
318
491
  const value = record(meta);
319
492
  if (value?.kind !== "dsh-image-gen") return void 0;
320
- return imageAttachment(value.attachment);
493
+ return parseImageAttachmentRef(value.attachment);
321
494
  }
322
495
  function attachmentFromRequest(value) {
323
- return imageAttachment(record(value)?.attachment);
324
- }
325
- function imageAttachment(value) {
326
- const ref = record(value);
327
- if (ref === void 0) return void 0;
328
- if (typeof ref.attachmentId !== "string" || !mediaType(ref.mediaType) || typeof ref.bytes !== "number" || typeof ref.width !== "number" || typeof ref.height !== "number") return void 0;
329
- if (ref.name !== void 0 && typeof ref.name !== "string") return void 0;
330
- return ref;
331
- }
332
- function mediaType(value) {
333
- return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
496
+ return parseImageAttachmentRef(record(value)?.attachment);
334
497
  }
335
498
  function record(value) {
336
499
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
@@ -355,10 +518,9 @@ function jsonError(res, status, code) {
355
518
  }
356
519
  //#endregion
357
520
  //#region lib/types/openai-compatible.js
358
- const ERROR_LIMIT = 4096;
359
- /** Generate an image from an OpenAI-compatible endpoint. */
521
+ const ERROR_LIMIT$1 = 4096;
360
522
  async function generateOpenAICompatibleImage(input) {
361
- const response = await fetch(imageEndpoint(input.baseURL), {
523
+ return parseImageResponse(await fetch(imageEndpoint$1(input.baseURL, "generations"), {
362
524
  method: "POST",
363
525
  redirect: "error",
364
526
  signal: input.signal,
@@ -372,20 +534,174 @@ async function generateOpenAICompatibleImage(input) {
372
534
  size: input.size,
373
535
  ...input.provider === "seedream" ? { response_format: "url" } : {}
374
536
  })
537
+ }), input.provider, input);
538
+ }
539
+ async function editOpenAICompatibleImage(input) {
540
+ 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)}`);
543
+ form.append("prompt", input.prompt);
544
+ form.append("model", input.model);
545
+ if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
546
+ return parseImageResponse(await fetch(imageEndpoint$1(input.baseURL, "edits"), {
547
+ method: "POST",
548
+ redirect: "error",
549
+ signal: input.signal,
550
+ headers: { authorization: `Bearer ${input.apiKey}` },
551
+ body: form
552
+ }), "openai", input);
553
+ }
554
+ async function parseImageResponse(response, provider, input) {
555
+ const text = await readBoundedText$1(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT$1);
556
+ if (!response.ok) throw new Error(`${provider} image request failed (${response.status}): ${text.slice(0, ERROR_LIMIT$1)}`);
557
+ let payload;
558
+ try {
559
+ payload = JSON.parse(text);
560
+ } catch {
561
+ throw new Error(`${provider} image request returned invalid JSON`);
562
+ }
563
+ const image = firstImage$1(payload);
564
+ if (image === void 0) throw new Error(`${provider} image request returned no image: ${text.slice(0, ERROR_LIMIT$1)}`);
565
+ if (image.b64_json !== void 0) return {
566
+ data: decodeBase64$1(image.b64_json, provider),
567
+ mediaType: imageMediaType$1(image.mime_type) ?? "image/png"
568
+ };
569
+ return downloadImage$1(image.url, provider, input);
570
+ }
571
+ function imageEndpoint$1(baseURL, operation) {
572
+ try {
573
+ return new URL(`images/${operation}`, baseURL.endsWith("/") ? baseURL : `${baseURL}/`).toString();
574
+ } catch {
575
+ throw new Error("Image endpoint must be an absolute URL");
576
+ }
577
+ }
578
+ function firstImage$1(value) {
579
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
580
+ const record = value;
581
+ const data = Array.isArray(record.data) ? record.data : Array.isArray(record.images) ? record.images : Array.isArray(record.output) ? record.output : void 0;
582
+ if (data === void 0 || data.length === 0) return void 0;
583
+ const candidate = data[0];
584
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return void 0;
585
+ const item = candidate;
586
+ const mime = typeof item.mime_type === "string" ? item.mime_type : typeof item.mime === "string" ? item.mime : void 0;
587
+ return typeof item.b64_json === "string" ? {
588
+ b64_json: item.b64_json,
589
+ ...mime === void 0 ? {} : { mime_type: mime }
590
+ } : typeof item.url === "string" ? {
591
+ url: item.url,
592
+ ...mime === void 0 ? {} : { mime_type: mime }
593
+ } : void 0;
594
+ }
595
+ async function downloadImage$1(url, provider, input) {
596
+ if (url === void 0) throw new Error(`${provider} image request returned no image data`);
597
+ if (url.startsWith("data:")) {
598
+ const parsed = parseDataUrl(url);
599
+ if (parsed === void 0) throw new Error(`${provider} image request returned invalid data URL`);
600
+ return {
601
+ data: decodeBase64$1(parsed.base64, provider),
602
+ mediaType: imageMediaType$1(parsed.mediaType) ?? "image/png"
603
+ };
604
+ }
605
+ const response = await fetch(url, {
606
+ redirect: "follow",
607
+ signal: input.signal,
608
+ ...input.apiKey === void 0 ? {} : { headers: { authorization: `Bearer ${input.apiKey}` } }
609
+ });
610
+ if (!response.ok) throw new Error(`${provider} image download failed (${response.status})`);
611
+ const mediaType = imageMediaType$1(response.headers.get("content-type"));
612
+ if (mediaType === void 0) throw new Error(`${provider} image download returned unsupported content type`);
613
+ return {
614
+ data: await readBoundedBytes$1(response, input.maxBytes),
615
+ mediaType
616
+ };
617
+ }
618
+ function parseDataUrl(value) {
619
+ const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.trim());
620
+ return match?.[1] !== void 0 && match[2] !== void 0 ? {
621
+ mediaType: match[1],
622
+ base64: match[2]
623
+ } : void 0;
624
+ }
625
+ function extensionOf(mediaType) {
626
+ switch (mediaType) {
627
+ case "image/jpeg": return "jpg";
628
+ case "image/webp": return "webp";
629
+ case "image/gif": return "gif";
630
+ case "image/png": return "png";
631
+ }
632
+ }
633
+ function decodeBase64$1(value, provider) {
634
+ const clean = (parseDataUrl(value)?.base64 ?? value).replace(/\s+/g, "");
635
+ if (clean.length === 0) throw new Error(`${provider} image request returned invalid base64 image data`);
636
+ const decoded = Buffer.from(clean, "base64");
637
+ if (decoded.length === 0) throw new Error(`${provider} image request returned invalid base64 image data`);
638
+ return new Uint8Array(decoded);
639
+ }
640
+ function imageMediaType$1(value) {
641
+ const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
642
+ return mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : void 0;
643
+ }
644
+ async function readBoundedText$1(response, maxBytes) {
645
+ return new TextDecoder().decode(await readBoundedBytes$1(response, maxBytes));
646
+ }
647
+ async function readBoundedBytes$1(response, maxBytes) {
648
+ if (response.body === null) return /* @__PURE__ */ new Uint8Array();
649
+ const reader = response.body.getReader();
650
+ const chunks = [];
651
+ let bytes = 0;
652
+ try {
653
+ for (;;) {
654
+ const next = await reader.read();
655
+ if (next.done) break;
656
+ bytes += next.value.byteLength;
657
+ if (bytes > maxBytes) throw new Error(`Image response exceeded the ${String(maxBytes)} byte limit`);
658
+ chunks.push(next.value);
659
+ }
660
+ } finally {
661
+ reader.releaseLock();
662
+ }
663
+ const joined = new Uint8Array(bytes);
664
+ let offset = 0;
665
+ for (const chunk of chunks) {
666
+ joined.set(chunk, offset);
667
+ offset += chunk.byteLength;
668
+ }
669
+ return joined;
670
+ }
671
+ //#endregion
672
+ //#region lib/types/seedream.js
673
+ const ERROR_LIMIT = 4096;
674
+ /** Edit one image through Ark ImageGenerations using a data-URL reference. */
675
+ async function editSeedreamImage(input) {
676
+ const response = await fetch(imageEndpoint(input.baseURL), {
677
+ method: "POST",
678
+ redirect: "error",
679
+ signal: input.signal,
680
+ headers: {
681
+ authorization: `Bearer ${input.apiKey}`,
682
+ "content-type": "application/json"
683
+ },
684
+ body: JSON.stringify({
685
+ model: input.model,
686
+ prompt: input.prompt,
687
+ image: [toDataUrl(input.sourceImage)],
688
+ ...input.size === void 0 || input.size.length === 0 ? {} : { size: input.size },
689
+ response_format: "b64_json"
690
+ })
375
691
  });
376
692
  const text = await readBoundedText(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT);
377
- if (!response.ok) throw new Error(`${input.provider} image generation failed (${response.status}): ${text.slice(0, ERROR_LIMIT)}`);
693
+ if (!response.ok) throw new Error(`seedream image editing failed (${response.status}): ${text.slice(0, ERROR_LIMIT)}`);
378
694
  let payload;
379
695
  try {
380
696
  payload = JSON.parse(text);
381
697
  } catch {
382
- throw new Error(`${input.provider} image generation returned invalid JSON`);
698
+ throw new Error("seedream image editing returned invalid JSON");
383
699
  }
384
700
  const image = firstImage(payload);
385
- if (image === void 0) throw new Error(`${input.provider} image generation returned no image: ${text.slice(0, ERROR_LIMIT)}`);
701
+ if (image === void 0) throw new Error(`seedream image editing returned no image: ${text.slice(0, ERROR_LIMIT)}`);
386
702
  if (image.b64_json !== void 0) return {
387
- data: decodeBase64(image.b64_json, input.provider),
388
- mediaType: "image/png"
703
+ data: decodeBase64(image.b64_json),
704
+ mediaType: imageMediaType(image.mime_type) ?? "image/png"
389
705
  };
390
706
  return downloadImage(image.url, input);
391
707
  }
@@ -393,37 +709,48 @@ function imageEndpoint(baseURL) {
393
709
  try {
394
710
  return new URL("images/generations", baseURL.endsWith("/") ? baseURL : `${baseURL}/`).toString();
395
711
  } catch {
396
- throw new Error("Image generation endpoint must be an absolute URL");
712
+ throw new Error("Seedream image endpoint must be an absolute URL");
397
713
  }
398
714
  }
715
+ function toDataUrl(image) {
716
+ return `data:${image.mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
717
+ }
399
718
  function firstImage(value) {
400
719
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
401
- const data = value.data;
402
- if (!Array.isArray(data) || data.length === 0) return void 0;
720
+ const record = value;
721
+ const data = Array.isArray(record.data) ? record.data : Array.isArray(record.images) ? record.images : Array.isArray(record.output) ? record.output : void 0;
722
+ if (data === void 0 || data.length === 0) return void 0;
403
723
  const candidate = data[0];
404
724
  if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return void 0;
405
725
  const item = candidate;
406
- return typeof item.b64_json === "string" ? { b64_json: item.b64_json } : typeof item.url === "string" ? { url: item.url } : void 0;
726
+ const mime = typeof item.mime_type === "string" ? item.mime_type : typeof item.mime === "string" ? item.mime : void 0;
727
+ return typeof item.b64_json === "string" ? {
728
+ b64_json: item.b64_json,
729
+ ...mime === void 0 ? {} : { mime_type: mime }
730
+ } : typeof item.url === "string" ? {
731
+ url: item.url,
732
+ ...mime === void 0 ? {} : { mime_type: mime }
733
+ } : void 0;
407
734
  }
408
735
  async function downloadImage(url, input) {
409
- if (url === void 0) throw new Error(`${input.provider} image generation returned no image data`);
736
+ if (url === void 0) throw new Error("seedream image editing returned no image data");
410
737
  const response = await fetch(url, {
411
738
  redirect: "follow",
412
739
  signal: input.signal
413
740
  });
414
- if (!response.ok) throw new Error(`${input.provider} image download failed (${response.status})`);
741
+ if (!response.ok) throw new Error(`seedream image download failed (${response.status})`);
415
742
  const mediaType = imageMediaType(response.headers.get("content-type"));
416
- if (mediaType === void 0) throw new Error(`${input.provider} image download returned unsupported content type`);
743
+ if (mediaType === void 0) throw new Error("seedream image download returned unsupported content type");
417
744
  return {
418
745
  data: await readBoundedBytes(response, input.maxBytes),
419
746
  mediaType
420
747
  };
421
748
  }
422
- function decodeBase64(value, provider) {
749
+ function decodeBase64(value) {
423
750
  const clean = value.replace(/\s+/g, "");
424
- if (clean.length === 0) throw new Error(`${provider} image generation returned invalid base64 image data`);
751
+ if (clean.length === 0) throw new Error("seedream image editing returned invalid base64 image data");
425
752
  const decoded = Buffer.from(clean, "base64");
426
- if (decoded.length === 0) throw new Error(`${provider} image generation returned invalid base64 image data`);
753
+ if (decoded.length === 0) throw new Error("seedream image editing returned invalid base64 image data");
427
754
  return new Uint8Array(decoded);
428
755
  }
429
756
  function imageMediaType(value) {
@@ -458,17 +785,130 @@ async function readBoundedBytes(response, maxBytes) {
458
785
  return joined;
459
786
  }
460
787
  //#endregion
788
+ //#region lib/types/workspace-save.js
789
+ /** Persist one generated image as a file under the session workspace. */
790
+ /** File extension for each supported image media type. */
791
+ const EXTENSION = {
792
+ "image/png": "png",
793
+ "image/jpeg": "jpg",
794
+ "image/webp": "webp",
795
+ "image/gif": "gif"
796
+ };
797
+ /**
798
+ * Build the deterministic file name for a generated image:
799
+ * `image-<digest-prefix>.<ext>`. The digest prefix comes from the
800
+ * content-addressed attachment id, so the same image bytes always map to the
801
+ * same file name regardless of when they were generated, and re-saving simply
802
+ * overwrites the previous copy in place.
803
+ * @param attachmentId - durable attachment id (`sha256:<hex>`).
804
+ * @param mediaType - verified image media type.
805
+ * @returns the file name (no directory).
806
+ */
807
+ function workspaceImageName(attachmentId, mediaType) {
808
+ return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
809
+ }
810
+ /**
811
+ * Resolve the configured image folder inside the session workspace. The
812
+ * folder may nest, but must stay inside the workspace: absolute paths and
813
+ * parent-traversal segments are rejected for both separator styles.
814
+ *
815
+ * This lexical pass is necessary but not sufficient: `saveImageToWorkspace`
816
+ * additionally verifies the on-disk resolution so symlinked folders cannot
817
+ * escape the workspace.
818
+ * @param workspaceRoot - the session workspace directory.
819
+ * @param folder - configured subfolder; empty/blank means the workspace root.
820
+ * @returns the absolute image directory.
821
+ * @throws when the folder would escape the workspace root.
822
+ */
823
+ function workspaceImageDir(workspaceRoot, folder) {
824
+ const trimmed = (folder ?? "").trim();
825
+ const root = resolve(workspaceRoot);
826
+ const dir = trimmed === "" ? root : resolve(root, trimmed);
827
+ if (!containsPath(root, dir)) throw new Error(`image workspace folder '${folder}' must stay inside the session workspace`);
828
+ return dir;
829
+ }
830
+ /** True when `child` equals `parent` or lives underneath it (lexically). */
831
+ function containsPath(parent, child) {
832
+ const rel = relative(parent, child);
833
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
834
+ }
835
+ /**
836
+ * Real path of the closest existing ancestor of `dir` (inclusive). Walking up
837
+ * lets us validate symlinked folder segments before creating anything under
838
+ * them.
839
+ */
840
+ async function nearestExistingRealPath(dir) {
841
+ let probe = dir;
842
+ for (;;) try {
843
+ return await realpath(probe);
844
+ } catch (error) {
845
+ if (error?.code !== "ENOENT") throw error;
846
+ const parent = dirname(probe);
847
+ if (parent === probe) throw error;
848
+ probe = parent;
849
+ }
850
+ }
851
+ /** Reject a directory whose on-disk resolution lands outside the workspace. */
852
+ function assertInsideWorkspace(realRoot, candidate, folder) {
853
+ if (containsPath(realRoot, candidate)) return;
854
+ throw new Error(`image workspace folder '${folder ?? ""}' must stay inside the session workspace (${candidate} resolves outside ${realRoot})`);
855
+ }
856
+ /**
857
+ * Write one generated image durably under the session workspace.
858
+ *
859
+ * Containment is enforced twice: lexically by `workspaceImageDir`, then
860
+ * against real paths, so a configured folder (or any intermediate segment)
861
+ * that is a symlink pointing outside the workspace is rejected before and
862
+ * after anything is created.
863
+ *
864
+ * The bytes are written to a same-directory staging file and renamed onto the
865
+ * target, so a crash never leaves a half-written image under its final name.
866
+ * Re-saving identical bytes rewrites the same file (the name is content-
867
+ * addressed), which keeps repeated generations idempotent. A cancellation is
868
+ * honoured up to and including the final rename: an aborted save never
869
+ * resolves successfully and never leaves the image behind under its final
870
+ * name.
871
+ * @param options - workspace root, configured folder, attachment identity, and image bytes.
872
+ * @returns the absolute path of the written file.
873
+ */
874
+ async function saveImageToWorkspace(options) {
875
+ const dir = workspaceImageDir(options.workspaceRoot, options.folder);
876
+ options.signal?.throwIfAborted();
877
+ const realRoot = await realpath(resolve(options.workspaceRoot));
878
+ assertInsideWorkspace(realRoot, await nearestExistingRealPath(dir), options.folder);
879
+ const name = workspaceImageName(options.attachmentId, options.mediaType);
880
+ const target = join(dir, name);
881
+ const staging = join(dir, `.${name}.${process.pid}-${randomUUID()}.tmp`);
882
+ await mkdir(dir, { recursive: true });
883
+ assertInsideWorkspace(realRoot, await realpath(dir), options.folder);
884
+ try {
885
+ await writeFile(staging, options.data, {
886
+ flag: "wx",
887
+ signal: options.signal
888
+ });
889
+ options.signal?.throwIfAborted();
890
+ await rename(staging, target);
891
+ } catch (error) {
892
+ await unlink(staging).catch(() => {});
893
+ throw error;
894
+ }
895
+ try {
896
+ options.signal?.throwIfAborted();
897
+ } catch (error) {
898
+ await unlink(target).catch(() => {});
899
+ throw error;
900
+ }
901
+ return target;
902
+ }
903
+ //#endregion
461
904
  //#region lib/types/index.js
462
- /** Cordis plugin name. */
463
905
  const name = "dsh-image-gen";
464
- /** Host services required by the Bundle. */
465
906
  const inject = [
466
907
  "tools",
467
908
  "attachments",
468
909
  "credentials",
469
910
  "webServer"
470
911
  ];
471
- /** Register settings, the image route, and the model-callable tool. */
472
912
  function apply(ctx, config = {}) {
473
913
  let current = () => config;
474
914
  installSettingsSection(ctx, settingsNamespace(IMAGE_GENERATION_NAMESPACE), Config, config, {
@@ -484,7 +924,7 @@ function apply(ctx, config = {}) {
484
924
  }), "dsh-image-gen: image route");
485
925
  ctx.tools.register(defineTool({
486
926
  name: "generate_image",
487
- description: "Generate one image with the configured image provider. Use when the user explicitly asks to create or draw an image. Give a complete visual prompt including subject, composition, style, lighting, and any exact text that should appear. A successful image is already attached directly to the conversation and has no local file path; do not call read, glob, or other tools to locate or verify it.",
927
+ 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.",
488
928
  parameters: {
489
929
  prompt: {
490
930
  type: "string",
@@ -518,66 +958,7 @@ function apply(ctx, config = {}) {
518
958
  description: "Optional dimensions or size tier for OpenAI, Seedream, or DashScope."
519
959
  }
520
960
  },
521
- output: {
522
- schema: {
523
- type: "object",
524
- additionalProperties: false,
525
- properties: {
526
- attachment: {
527
- type: "object",
528
- required: true,
529
- additionalProperties: false,
530
- properties: {
531
- attachmentId: {
532
- type: "string",
533
- required: true
534
- },
535
- mediaType: {
536
- type: "string",
537
- required: true
538
- },
539
- bytes: {
540
- type: "integer",
541
- required: true
542
- },
543
- width: {
544
- type: "integer",
545
- required: true
546
- },
547
- height: {
548
- type: "integer",
549
- required: true
550
- },
551
- name: { type: "string" }
552
- }
553
- },
554
- provider: {
555
- type: "string",
556
- required: true
557
- },
558
- model: {
559
- type: "string",
560
- required: true
561
- },
562
- output: {
563
- type: "string",
564
- required: true
565
- }
566
- }
567
- },
568
- render: (_args, value) => [{
569
- type: "text",
570
- text: `Generated one image with ${value.provider}/${value.model} (${value.output}). It is already attached to the conversation with no local file path; respond to the user without reading or searching for it.`
571
- }],
572
- presentationMeta: (args, value) => ({
573
- kind: "dsh-image-gen",
574
- attachment: value.attachment,
575
- provider: value.provider,
576
- model: value.model,
577
- output: value.output,
578
- prompt: args.prompt
579
- })
580
- },
961
+ output: imageOutput("Generated"),
581
962
  async execute(args, exec) {
582
963
  const active = resolveProvider(current());
583
964
  const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
@@ -594,7 +975,7 @@ function apply(ctx, config = {}) {
594
975
  imageSize,
595
976
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
596
977
  signal: exec.signal
597
- }), active.provider, active.model, `${aspectRatio}, ${imageSize}`);
978
+ }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec);
598
979
  }
599
980
  if (active.provider === "dashscope") {
600
981
  const size = args.size ?? active.imageSize;
@@ -606,7 +987,7 @@ function apply(ctx, config = {}) {
606
987
  size,
607
988
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
608
989
  signal: exec.signal
609
- }), active.provider, active.model, size);
990
+ }), active.provider, active.model, size, current(), exec);
610
991
  }
611
992
  const size = args.size ?? active.imageSize;
612
993
  return saveGenerated(ctx, await generateOpenAICompatibleImage({
@@ -618,23 +999,249 @@ function apply(ctx, config = {}) {
618
999
  size,
619
1000
  maxBytes: ctx.attachments.imageLimits.maxImageBytes,
620
1001
  signal: exec.signal
621
- }), active.provider, active.model, size);
1002
+ }), active.provider, active.model, size, current(), exec);
1003
+ },
1004
+ presentResult: (_args, result) => imagePresentation(result)
1005
+ }));
1006
+ ctx.tools.register(defineTool({
1007
+ 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.",
1009
+ parameters: {
1010
+ prompt: {
1011
+ type: "string",
1012
+ required: true,
1013
+ description: "Describe the changes to make while preserving everything else that should remain."
1014
+ },
1015
+ source_attachment_id: {
1016
+ type: "string",
1017
+ description: "Optional attachment id of a specific image already present in the current conversation."
1018
+ },
1019
+ source_path: {
1020
+ type: "string",
1021
+ 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
+ },
1023
+ aspect_ratio: {
1024
+ type: "string",
1025
+ enum: [
1026
+ "1:1",
1027
+ "3:2",
1028
+ "2:3",
1029
+ "4:3",
1030
+ "3:4",
1031
+ "16:9",
1032
+ "9:16"
1033
+ ],
1034
+ description: "Optional output aspect ratio for Google Gemini."
1035
+ },
1036
+ image_size: {
1037
+ type: "string",
1038
+ enum: [
1039
+ "1K",
1040
+ "2K",
1041
+ "4K"
1042
+ ],
1043
+ description: "Optional output resolution for Google Gemini."
1044
+ },
1045
+ size: {
1046
+ type: "string",
1047
+ description: "Optional output size for OpenAI, Seedream, or DashScope."
1048
+ }
1049
+ },
1050
+ output: imageOutput("Edited"),
1051
+ async execute(args, exec) {
1052
+ const active = resolveProvider(current());
1053
+ const credential = await ctx.credentials.resolve(credentialRef(active.apiKeyEnv));
1054
+ 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({
1056
+ ...exec.agent === void 0 ? {} : { agent: exec.agent },
1057
+ attachments: ctx.attachments,
1058
+ ...typeof args.source_attachment_id === "string" ? { sourceAttachmentId: args.source_attachment_id } : {},
1059
+ ...typeof args.source_path === "string" ? { sourcePath: args.source_path } : {},
1060
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1061
+ signal: exec.signal
1062
+ });
1063
+ if (active.provider === "google") {
1064
+ const aspectRatio = args.aspect_ratio ?? active.aspectRatio;
1065
+ const imageSize = args.image_size ?? active.imageSize;
1066
+ return saveGenerated(ctx, await editGoogleImage({
1067
+ apiKey: credential.value,
1068
+ endpoint: active.endpoint,
1069
+ model: active.model,
1070
+ prompt: args.prompt,
1071
+ sourceImage,
1072
+ aspectRatio,
1073
+ imageSize,
1074
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1075
+ signal: exec.signal
1076
+ }), active.provider, active.model, `${aspectRatio}, ${imageSize}`, current(), exec);
1077
+ }
1078
+ const size = args.size ?? active.imageSize;
1079
+ if (active.provider === "openai") return saveGenerated(ctx, await editOpenAICompatibleImage({
1080
+ apiKey: credential.value,
1081
+ baseURL: active.baseURL,
1082
+ model: active.model,
1083
+ prompt: args.prompt,
1084
+ sourceImage,
1085
+ size,
1086
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1087
+ signal: exec.signal
1088
+ }), active.provider, active.model, size, current(), exec);
1089
+ if (active.provider === "seedream") return saveGenerated(ctx, await editSeedreamImage({
1090
+ apiKey: credential.value,
1091
+ baseURL: active.baseURL,
1092
+ model: active.model,
1093
+ prompt: args.prompt,
1094
+ sourceImage,
1095
+ size,
1096
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1097
+ signal: exec.signal
1098
+ }), active.provider, active.model, size, current(), exec);
1099
+ return saveGenerated(ctx, await editDashScopeImage({
1100
+ apiKey: credential.value,
1101
+ endpoint: active.endpoint,
1102
+ model: active.model,
1103
+ prompt: args.prompt,
1104
+ sourceImage,
1105
+ size,
1106
+ maxBytes: ctx.attachments.imageLimits.maxImageBytes,
1107
+ signal: exec.signal
1108
+ }), active.provider, active.model, size, current(), exec);
622
1109
  },
623
1110
  presentResult: (_args, result) => imagePresentation(result)
624
1111
  }));
625
1112
  }
626
- async function saveGenerated(ctx, generated, provider, model, output) {
627
- if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`This DSH deployment does not accept ${generated.mediaType} generated images`);
1113
+ function imageOutput(verb) {
628
1114
  return {
629
- attachment: await ctx.attachments.saveImage({
630
- data: generated.data,
631
- mediaType: generated.mediaType,
632
- name: "generated-image"
633
- }),
1115
+ schema: {
1116
+ type: "object",
1117
+ additionalProperties: false,
1118
+ properties: {
1119
+ attachment: {
1120
+ type: "object",
1121
+ required: true,
1122
+ additionalProperties: false,
1123
+ properties: {
1124
+ attachmentId: {
1125
+ type: "string",
1126
+ required: true
1127
+ },
1128
+ mediaType: {
1129
+ type: "string",
1130
+ required: true
1131
+ },
1132
+ bytes: {
1133
+ type: "integer",
1134
+ required: true
1135
+ },
1136
+ width: {
1137
+ type: "integer",
1138
+ required: true
1139
+ },
1140
+ height: {
1141
+ type: "integer",
1142
+ required: true
1143
+ },
1144
+ name: { type: "string" },
1145
+ originalDimensions: {
1146
+ type: "object",
1147
+ additionalProperties: false,
1148
+ properties: {
1149
+ width: {
1150
+ type: "integer",
1151
+ required: true
1152
+ },
1153
+ height: {
1154
+ type: "integer",
1155
+ required: true
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ },
1161
+ provider: {
1162
+ type: "string",
1163
+ required: true
1164
+ },
1165
+ model: {
1166
+ type: "string",
1167
+ required: true
1168
+ },
1169
+ output: {
1170
+ type: "string",
1171
+ required: true
1172
+ },
1173
+ savedTo: { type: "string" },
1174
+ saveError: { type: "string" }
1175
+ }
1176
+ },
1177
+ render: (_args, value) => {
1178
+ const saved = typeof value.savedTo === "string" ? ` It was also saved to the workspace as ${value.savedTo}.` : typeof value.saveError === "string" ? ` Saving it to the workspace failed: ${value.saveError}.` : " It has no local file path.";
1179
+ const action = verb === "Generated" ? "It is already attached to the conversation." : "The edited image is attached to the conversation.";
1180
+ return [{
1181
+ type: "text",
1182
+ text: `${verb} one image with ${value.provider}/${value.model} (${value.output}). Attachment ID: ${String(value.attachment.attachmentId)}. ${action}${saved} Respond to the user without reading or searching for the image.`
1183
+ }, {
1184
+ type: "image",
1185
+ attachment: value.attachment
1186
+ }];
1187
+ },
1188
+ presentationMeta: (args, value) => ({
1189
+ kind: "dsh-image-gen",
1190
+ attachment: attachmentMeta(value.attachment),
1191
+ provider: value.provider,
1192
+ model: value.model,
1193
+ output: value.output,
1194
+ ...verb === "Edited" ? { operation: "edit" } : {},
1195
+ ...typeof value.savedTo === "string" ? { savedTo: value.savedTo } : {},
1196
+ prompt: args.prompt
1197
+ })
1198
+ };
1199
+ }
1200
+ function attachmentMeta(ref) {
1201
+ return {
1202
+ attachmentId: String(ref.attachmentId),
1203
+ mediaType: ref.mediaType,
1204
+ bytes: ref.bytes,
1205
+ width: ref.width,
1206
+ height: ref.height,
1207
+ ...ref.name === void 0 ? {} : { name: ref.name },
1208
+ ...ref.originalDimensions === void 0 ? {} : { originalDimensions: {
1209
+ width: ref.originalDimensions.width,
1210
+ height: ref.originalDimensions.height
1211
+ } }
1212
+ };
1213
+ }
1214
+ async function saveGenerated(ctx, generated, provider, model, output, config, exec) {
1215
+ if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`This DSH deployment does not accept ${generated.mediaType} generated images`);
1216
+ const attachment = await ctx.attachments.saveImage({
1217
+ data: generated.data,
1218
+ mediaType: generated.mediaType,
1219
+ name: "generated-image"
1220
+ });
1221
+ const value = {
1222
+ attachment,
634
1223
  provider,
635
1224
  model,
636
1225
  output
637
1226
  };
1227
+ if (config.saveToWorkspace === false) return value;
1228
+ const workspaceRoot = exec.agent?.session.header.cwd;
1229
+ if (workspaceRoot === void 0) return value;
1230
+ try {
1231
+ value.savedTo = await saveImageToWorkspace({
1232
+ workspaceRoot,
1233
+ folder: config.workspaceFolder,
1234
+ attachmentId: attachment.attachmentId,
1235
+ mediaType: generated.mediaType,
1236
+ data: generated.data,
1237
+ signal: exec.signal
1238
+ });
1239
+ } catch (error) {
1240
+ exec.signal.throwIfAborted();
1241
+ ctx.logger.warn(`dsh-image-gen: failed to save image to workspace: ${error instanceof Error ? error.message : String(error)}`);
1242
+ value.saveError = error instanceof Error ? error.message : String(error);
1243
+ }
1244
+ return value;
638
1245
  }
639
1246
  function imagePresentation(result) {
640
1247
  const attachment = imageAttachmentFromMeta(result.meta);