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/README.en.md +171 -166
- package/README.md +187 -169
- package/README.zh-CN.md +171 -169
- package/lib/client.js +80 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +799 -192
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/config.d.ts +6 -0
- package/lib/types/dashscope.d.ts +12 -3
- package/lib/types/google.d.ts +14 -3
- package/lib/types/index.d.ts +0 -3
- package/lib/types/openai-compatible.d.ts +15 -3
- package/lib/types/reference-image.d.ts +38 -0
- package/lib/types/seedream.d.ts +17 -0
- package/lib/types/shared.d.ts +1 -1
- package/lib/types/workspace-save.d.ts +52 -0
- package/package.json +37 -34
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 = "
|
|
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 ?? "
|
|
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
|
|
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 (!
|
|
119
|
-
const errorText = await
|
|
120
|
-
throw new Error(`DashScope image
|
|
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
|
-
|
|
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))
|
|
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$
|
|
182
|
+
const ERROR_LIMIT$2 = 4096;
|
|
184
183
|
const REQUESTED_MEDIA_TYPE = "image/jpeg";
|
|
185
|
-
/** Send one native Google image request
|
|
186
|
-
|
|
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.
|
|
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$
|
|
207
|
-
if (!response.ok) throw new Error(
|
|
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(
|
|
235
|
+
throw new Error(`${label} returned invalid JSON`);
|
|
213
236
|
}
|
|
214
237
|
const image = outputImage(payload);
|
|
215
|
-
if (image === void 0) throw new Error(
|
|
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(
|
|
218
|
-
const data = decodeBase64$
|
|
219
|
-
if (data.byteLength > input.maxBytes) throw new Error(
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
277
|
+
function decodeBase64$2(data, label) {
|
|
255
278
|
const clean = data.replace(/\s+/g, "");
|
|
256
|
-
if (clean.length === 0) throw new Error(
|
|
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(
|
|
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$
|
|
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(
|
|
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
|
|
493
|
+
return parseImageAttachmentRef(value.attachment);
|
|
321
494
|
}
|
|
322
495
|
function attachmentFromRequest(value) {
|
|
323
|
-
return
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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("
|
|
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
|
|
402
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
749
|
+
function decodeBase64(value) {
|
|
423
750
|
const clean = value.replace(/\s+/g, "");
|
|
424
|
-
if (clean.length === 0) throw new Error(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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);
|