dsh-image-gen-plugin 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +296 -2
- package/assets/dsh-image-gen-plugin-logo.png +0 -0
- package/client.js +1323 -0
- package/cordis.patch.yml +19 -0
- package/icon.svg +18 -0
- package/index.js +1047 -0
- package/package.json +45 -4
package/index.js
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, isAbsolute, join, normalize, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
|
|
9
|
+
export const name = 'image-generation';
|
|
10
|
+
|
|
11
|
+
export const inject = ['tools'];
|
|
12
|
+
export const Config = z.object({
|
|
13
|
+
apiUrl: z.string().default('https://api.openai.com/v1').volatile(),
|
|
14
|
+
|
|
15
|
+
secret: z.string().role('secret').default('').volatile(),
|
|
16
|
+
model: z.string().default('gpt-image-2.5-flare').volatile(),
|
|
17
|
+
count: z.number().default(1).volatile(),
|
|
18
|
+
outputFormat: z.string().default('png').volatile(),
|
|
19
|
+
outputCompression: z.number().default(100).volatile(),
|
|
20
|
+
moderation: z.string().default('auto').volatile(),
|
|
21
|
+
background: z.string().default('auto').volatile(),
|
|
22
|
+
user: z.string().default('').volatile(),
|
|
23
|
+
outputDir: z.string().default('').volatile(),
|
|
24
|
+
|
|
25
|
+
fileNameFormat: z.string().default('{time}-{prompt}').volatile(),
|
|
26
|
+
|
|
27
|
+
defaultResolution: z.string().default('auto').volatile(),
|
|
28
|
+
requestTimeoutSec: z.number().default(600).volatile(),
|
|
29
|
+
|
|
30
|
+
maxEdge: z.number().default(3840).volatile(),
|
|
31
|
+
|
|
32
|
+
sendsModeration: z.boolean().default(true).volatile(),
|
|
33
|
+
sendsOutputCompression: z.boolean().default(true).volatile(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const IMAGE_ROUTE = '/dsh-image-generation/file';
|
|
37
|
+
const ROUTE_SEGMENT = `${IMAGE_ROUTE}/`;
|
|
38
|
+
const DEFAULT_OUTPUT_DIR = fileURLToPath(new URL('./assets/', import.meta.url));
|
|
39
|
+
|
|
40
|
+
const BACKGROUNDS = ['auto', 'transparent', 'opaque'];
|
|
41
|
+
const MAX_PROMPT_CHARS = 32000;
|
|
42
|
+
const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
const MAX_BASE64_CHARS = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 8;
|
|
45
|
+
const MAX_NAME_ATTEMPTS = 50;
|
|
46
|
+
const MAX_RETRY_WAIT_MS = 30000;
|
|
47
|
+
const SIZE_STEP = 16;
|
|
48
|
+
|
|
49
|
+
export const QUALITIES = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
50
|
+
|
|
51
|
+
const DEFAULT_QUALITY = 'high';
|
|
52
|
+
|
|
53
|
+
const MIN_PIXELS = 655360;
|
|
54
|
+
const MAX_PIXELS = 8294400;
|
|
55
|
+
|
|
56
|
+
export function inspectSize(value, limits = {}) {
|
|
57
|
+
const maxEdge = limits.maxEdge ?? 3840;
|
|
58
|
+
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(String(value ?? '').trim());
|
|
59
|
+
if (!match) return { ok: false, reason: 'format' };
|
|
60
|
+
const width = Number(match[1]);
|
|
61
|
+
const height = Number(match[2]);
|
|
62
|
+
const sized = { width, height };
|
|
63
|
+
if (width % SIZE_STEP !== 0 || height % SIZE_STEP !== 0) return { ok: false, reason: 'step', ...sized };
|
|
64
|
+
const long = Math.max(width, height);
|
|
65
|
+
const short = Math.min(width, height);
|
|
66
|
+
if (long / short > 3) return { ok: false, reason: 'ratio', ...sized };
|
|
67
|
+
if (long > maxEdge) return { ok: false, reason: 'cap', ...sized, maxEdge };
|
|
68
|
+
const pixels = width * height;
|
|
69
|
+
if (pixels < MIN_PIXELS) return { ok: false, reason: 'floor', ...sized, minPixels: MIN_PIXELS };
|
|
70
|
+
if (pixels > MAX_PIXELS) return { ok: false, reason: 'ceiling', ...sized, maxPixels: MAX_PIXELS };
|
|
71
|
+
return { ok: true, value: `${width}x${height}`, ...sized, pixels };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseSize(value, limits = {}) {
|
|
75
|
+
const verdict = inspectSize(value, limits);
|
|
76
|
+
if (verdict.ok) return verdict.value;
|
|
77
|
+
if (verdict.reason === 'format') {
|
|
78
|
+
fail(`Invalid resolution "${value}". Use "auto" or WIDTHxHEIGHT such as 1536x864.`);
|
|
79
|
+
}
|
|
80
|
+
if (verdict.reason === 'step') {
|
|
81
|
+
fail(`Resolution ${verdict.width}x${verdict.height} is not a multiple of ${SIZE_STEP}; every side must be.`);
|
|
82
|
+
}
|
|
83
|
+
if (verdict.reason === 'cap') {
|
|
84
|
+
fail(`Resolution ${verdict.width}x${verdict.height} exceeds ${verdict.maxEdge} on its longest edge.`);
|
|
85
|
+
}
|
|
86
|
+
if (verdict.reason === 'ratio') {
|
|
87
|
+
fail(`Resolution ${verdict.width}x${verdict.height} is wider than 3:1; the endpoint refuses it.`);
|
|
88
|
+
}
|
|
89
|
+
if (verdict.reason === 'floor') {
|
|
90
|
+
fail(`Resolution ${verdict.width}x${verdict.height} covers ${verdict.width * verdict.height} pixels, under the ${MIN_PIXELS} the endpoint needs.`);
|
|
91
|
+
}
|
|
92
|
+
fail(
|
|
93
|
+
`Resolution ${verdict.width}x${verdict.height} covers ${verdict.width * verdict.height} pixels, over the ${MAX_PIXELS} the endpoint accepts. A ${verdict.width} square sits above that ceiling; 2880x2880 is the largest square that fits.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PRESET_SHAPES = [
|
|
98
|
+
[1, 1],
|
|
99
|
+
[4, 3],
|
|
100
|
+
[3, 4],
|
|
101
|
+
[3, 2],
|
|
102
|
+
[2, 3],
|
|
103
|
+
[16, 9],
|
|
104
|
+
[9, 16],
|
|
105
|
+
[2, 1],
|
|
106
|
+
[1, 2],
|
|
107
|
+
[5, 4],
|
|
108
|
+
[4, 5],
|
|
109
|
+
[21, 9],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export function presetSizes(limits = {}) {
|
|
113
|
+
const maxEdge = limits.maxEdge ?? 3840;
|
|
114
|
+
const sizes = [];
|
|
115
|
+
const add = (size) => {
|
|
116
|
+
if (inspectSize(size, limits).ok && !sizes.includes(size)) sizes.push(size);
|
|
117
|
+
};
|
|
118
|
+
for (const [width, height] of PRESET_SHAPES) {
|
|
119
|
+
for (const edge of [1024, 2048, maxEdge]) {
|
|
120
|
+
const step = Math.max(1, Math.floor(edge / Math.max(width, height) / SIZE_STEP)) * SIZE_STEP;
|
|
121
|
+
add(`${step * width}x${step * height}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return sizes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const isGptImage25 = (model) => String(model).startsWith('gpt-image-2.5-');
|
|
128
|
+
|
|
129
|
+
function plain(value) {
|
|
130
|
+
if (value === null || typeof value !== 'object') return value;
|
|
131
|
+
if (typeof value.get === 'function') return plain(value.get());
|
|
132
|
+
if (Array.isArray(value)) return value.map(plain);
|
|
133
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plain(child)]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function effectiveConfig(config) {
|
|
137
|
+
const raw = plain(config) ?? {};
|
|
138
|
+
return {
|
|
139
|
+
apiUrl: String(raw.apiUrl ?? '').trim(),
|
|
140
|
+
secret: String(raw.secret ?? ''),
|
|
141
|
+
model: String(raw.model ?? 'gpt-image-2.5-flare').trim(),
|
|
142
|
+
count: Number(raw.count ?? 1),
|
|
143
|
+
outputFormat: String(raw.outputFormat ?? 'png').trim(),
|
|
144
|
+
outputCompression: Number(raw.outputCompression ?? 100),
|
|
145
|
+
moderation: String(raw.moderation ?? 'auto').trim(),
|
|
146
|
+
background: String(raw.background ?? 'auto').trim(),
|
|
147
|
+
user: String(raw.user ?? '').trim(),
|
|
148
|
+
outputDir: String(raw.outputDir ?? '').trim(),
|
|
149
|
+
fileNameFormat: String(raw.fileNameFormat ?? '').trim() || DEFAULT_FILE_NAME_PATTERN,
|
|
150
|
+
defaultResolution: String(raw.defaultResolution ?? 'auto').trim() || 'auto',
|
|
151
|
+
requestTimeoutSec: Number(raw.requestTimeoutSec ?? 600),
|
|
152
|
+
maxEdge: Number(raw.maxEdge ?? 3840),
|
|
153
|
+
sendsModeration: raw.sendsModeration !== false,
|
|
154
|
+
sendsOutputCompression: raw.sendsOutputCompression !== false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fail(message) {
|
|
159
|
+
throw new Error(message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeBackground(value, config) {
|
|
163
|
+
const requested = String(value ?? '').trim();
|
|
164
|
+
const background = (requested || config.background || 'auto').toLowerCase();
|
|
165
|
+
if (!BACKGROUNDS.includes(background)) {
|
|
166
|
+
fail(`Unsupported background "${value}". Use one of: ${BACKGROUNDS.join(', ')}.`);
|
|
167
|
+
}
|
|
168
|
+
if (background === 'transparent' && config.outputFormat === 'jpeg') {
|
|
169
|
+
fail('A transparent background needs png or webp output; change the output format in Settings, Image generation.');
|
|
170
|
+
}
|
|
171
|
+
return background;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function outputDirOf(config) {
|
|
175
|
+
return config.outputDir ? resolve(config.outputDir) : DEFAULT_OUTPUT_DIR;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeSourcePaths(value) {
|
|
179
|
+
if (value === undefined || value === null || value === '') return [];
|
|
180
|
+
const list = Array.isArray(value) ? value : [value];
|
|
181
|
+
const paths = [];
|
|
182
|
+
for (const entry of list) {
|
|
183
|
+
if (typeof entry !== 'string') continue;
|
|
184
|
+
for (const piece of entry.split(/[\n;]+/)) {
|
|
185
|
+
const trimmed = piece.trim();
|
|
186
|
+
if (!trimmed) continue;
|
|
187
|
+
if (!paths.includes(trimmed)) paths.push(trimmed);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return paths;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function commonFields(config, prompt, size, count, background, quality) {
|
|
194
|
+
const fields = {
|
|
195
|
+
model: config.model,
|
|
196
|
+
prompt,
|
|
197
|
+
n: count,
|
|
198
|
+
size,
|
|
199
|
+
quality,
|
|
200
|
+
output_format: config.outputFormat,
|
|
201
|
+
background,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (config.sendsModeration && config.moderation && config.moderation !== 'auto') {
|
|
205
|
+
fields.moderation = config.moderation;
|
|
206
|
+
}
|
|
207
|
+
if (config.sendsOutputCompression && (config.outputFormat === 'jpeg' || config.outputFormat === 'webp')) {
|
|
208
|
+
fields.output_compression = Math.min(100, Math.max(0, Math.round(config.outputCompression)));
|
|
209
|
+
}
|
|
210
|
+
if (config.user) fields.user = config.user;
|
|
211
|
+
return fields;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildBody(config, prompt, size, count, background, quality) {
|
|
215
|
+
return commonFields(config, prompt, size, count, background, quality);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildEditForm(config, prompt, size, count, background, quality, sources) {
|
|
219
|
+
return { fields: commonFields(config, prompt, size, count, background, quality), sources };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const MAX_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
223
|
+
|
|
224
|
+
const MAX_SOURCE_IMAGES = 16;
|
|
225
|
+
|
|
226
|
+
function safeFileName(name) {
|
|
227
|
+
const base = basename(String(name ?? ''));
|
|
228
|
+
const cleaned = base
|
|
229
|
+
.replace(/[\u0000-\u001f\u007f"\\/]/g, '_')
|
|
230
|
+
.replace(/^\.+/, '')
|
|
231
|
+
.trim();
|
|
232
|
+
return cleaned || 'image.png';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function readSourceImages(paths) {
|
|
236
|
+
if (!Array.isArray(paths) || paths.length === 0) return [];
|
|
237
|
+
if (paths.length > MAX_SOURCE_IMAGES) {
|
|
238
|
+
fail(`An edit takes at most ${MAX_SOURCE_IMAGES} source images; ${paths.length} were given.`);
|
|
239
|
+
}
|
|
240
|
+
const sources = [];
|
|
241
|
+
for (const entry of paths) {
|
|
242
|
+
const raw = typeof entry === 'string' ? entry.trim() : '';
|
|
243
|
+
if (!raw) fail('A source image path was empty.');
|
|
244
|
+
const filePath = resolve(raw);
|
|
245
|
+
|
|
246
|
+
let info;
|
|
247
|
+
try {
|
|
248
|
+
info = await stat(filePath);
|
|
249
|
+
} catch {
|
|
250
|
+
fail(`Source image not found: ${filePath}`);
|
|
251
|
+
}
|
|
252
|
+
if (!info.isFile()) fail(`Source image is not a file: ${filePath}`);
|
|
253
|
+
if (info.size === 0) fail(`Source image is empty: ${filePath}`);
|
|
254
|
+
if (info.size > MAX_SOURCE_BYTES) {
|
|
255
|
+
fail(`Source image exceeds ${MAX_SOURCE_BYTES / 1024 / 1024}MB: ${filePath}`);
|
|
256
|
+
}
|
|
257
|
+
const bytes = await readFile(filePath);
|
|
258
|
+
|
|
259
|
+
const sniffed = sniffImage(bytes);
|
|
260
|
+
if (!sniffed) {
|
|
261
|
+
fail(`Source image is not a PNG, JPEG, WebP, or GIF: ${filePath}`);
|
|
262
|
+
}
|
|
263
|
+
sources.push({ bytes, name: safeFileName(basename(filePath)), mime: sniffed.mime });
|
|
264
|
+
}
|
|
265
|
+
return sources;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function encodeMultipart(fields, sources, mask) {
|
|
269
|
+
const boundary = `----dsh-image-generation-${randomUUID()}`;
|
|
270
|
+
const chunks = [];
|
|
271
|
+
for (const [name, value] of Object.entries(fields ?? {})) {
|
|
272
|
+
|
|
273
|
+
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) fail(`Refusing to encode a field name that could forge a header: ${JSON.stringify(name)}`);
|
|
274
|
+
|
|
275
|
+
const text = typeof value === 'string' ? value : String(value);
|
|
276
|
+
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${text}\r\n`, 'utf8'));
|
|
277
|
+
}
|
|
278
|
+
for (const source of sources ?? []) {
|
|
279
|
+
|
|
280
|
+
const { name, mime, bytes } = source ?? {};
|
|
281
|
+
if (typeof name !== 'string' || !name || typeof mime !== 'string' || !mime || !Buffer.isBuffer(bytes)) {
|
|
282
|
+
fail(
|
|
283
|
+
`A source image part needs a file name, a content type, and byte content; got ${JSON.stringify({
|
|
284
|
+
name: typeof name === 'string' ? name : null,
|
|
285
|
+
mime: typeof mime === 'string' ? mime : null,
|
|
286
|
+
bytes: Buffer.isBuffer(bytes) ? 'bytes' : null,
|
|
287
|
+
})}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
chunks.push(
|
|
291
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="image"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`, 'utf8'),
|
|
292
|
+
bytes,
|
|
293
|
+
Buffer.from('\r\n', 'utf8'),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (mask) {
|
|
297
|
+
|
|
298
|
+
const { name, mime, bytes } = mask;
|
|
299
|
+
if (typeof name !== 'string' || !name || typeof mime !== 'string' || !mime || !Buffer.isBuffer(bytes)) {
|
|
300
|
+
fail(
|
|
301
|
+
`A mask part needs a file name, a content type, and byte content; got ${JSON.stringify({
|
|
302
|
+
name: typeof name === 'string' ? name : null,
|
|
303
|
+
mime: typeof mime === 'string' ? mime : null,
|
|
304
|
+
bytes: Buffer.isBuffer(bytes) ? 'bytes' : null,
|
|
305
|
+
})}.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
chunks.push(
|
|
309
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="mask"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`, 'utf8'),
|
|
310
|
+
bytes,
|
|
311
|
+
Buffer.from('\r\n', 'utf8'),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'));
|
|
315
|
+
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function apiError(body, status) {
|
|
319
|
+
const error = body?.error;
|
|
320
|
+
if (typeof error === 'string' && error) return `Image API answered HTTP ${status}: ${error}`;
|
|
321
|
+
const message = error?.message ?? body?.message;
|
|
322
|
+
if (typeof message === 'string' && message) {
|
|
323
|
+
const code = error?.code;
|
|
324
|
+
const detail = typeof code === 'string' && code ? `${message} (${code})` : message;
|
|
325
|
+
return `Image API answered HTTP ${status}: ${detail}`;
|
|
326
|
+
}
|
|
327
|
+
return `Image API answered HTTP ${status}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function printableHead(text, max) {
|
|
331
|
+
return [...String(text).slice(0, max)].map((char) => (/[\t\n\r\x20-\x7e]/.test(char) ? char : '·')).join('');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const IMAGE_SIGNATURES = [
|
|
335
|
+
{ format: 'png', mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
336
|
+
{ format: 'jpeg', mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
337
|
+
{ format: 'webp', mime: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46], alsoAt: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } },
|
|
338
|
+
{ format: 'gif', mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
const EXTENSION_BY_FORMAT = { png: 'png', jpeg: 'jpg', webp: 'webp', gif: 'gif' };
|
|
342
|
+
|
|
343
|
+
function sniffImage(bytes) {
|
|
344
|
+
for (const signature of IMAGE_SIGNATURES) {
|
|
345
|
+
const { bytes: head, alsoAt } = signature;
|
|
346
|
+
if (head.some((byte, index) => bytes[index] !== byte)) continue;
|
|
347
|
+
if (alsoAt && alsoAt.bytes.some((byte, index) => bytes[alsoAt.offset + index] !== byte)) continue;
|
|
348
|
+
return { format: signature.format, mime: signature.mime };
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function decodeImage(base64, label) {
|
|
354
|
+
const text = String(base64 ?? '').replace(/\s+/g, '');
|
|
355
|
+
if (!text) fail(`${label} arrived without base64 data. Try again, or switch the gateway model.`);
|
|
356
|
+
|
|
357
|
+
if (text.length > MAX_BASE64_CHARS) {
|
|
358
|
+
fail(`${label} is ${text.length} base64 characters, far past the ${MAX_IMAGE_BYTES / 1024 / 1024}MB this plugin will write.`);
|
|
359
|
+
}
|
|
360
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) fail(`${label} is not valid base64; the gateway answered in an unexpected encoding.`);
|
|
361
|
+
const bytes = Buffer.from(text, 'base64');
|
|
362
|
+
if (bytes.byteLength === 0) fail(`${label} decoded to zero bytes.`);
|
|
363
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) fail(`${label} exceeds ${MAX_IMAGE_BYTES / 1024 / 1024}MB.`);
|
|
364
|
+
const sniffed = sniffImage(bytes);
|
|
365
|
+
if (!sniffed) {
|
|
366
|
+
fail(`${label} did not decode to a PNG, JPEG, WebP, or GIF; the first bytes were ${printableHead(bytes.subarray(0, 16).toString('latin1'), 16)}.`);
|
|
367
|
+
}
|
|
368
|
+
return { bytes, ...sniffed };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function slug(text, limit) {
|
|
372
|
+
const cleaned = String(text)
|
|
373
|
+
.toLowerCase()
|
|
374
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
375
|
+
.replace(/^-+|-+$/g, '');
|
|
376
|
+
return cleaned.slice(0, limit) || 'image';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').replace('Z', '');
|
|
380
|
+
|
|
381
|
+
const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504, 522, 524]);
|
|
382
|
+
const MAX_ATTEMPTS = 3;
|
|
383
|
+
|
|
384
|
+
function parseRetryAfter(headers) {
|
|
385
|
+
const raw = headers?.get?.('retry-after');
|
|
386
|
+
if (!raw) return null;
|
|
387
|
+
const seconds = Number(raw);
|
|
388
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, MAX_RETRY_WAIT_MS);
|
|
389
|
+
const at = Date.parse(raw);
|
|
390
|
+
return Number.isFinite(at) ? Math.min(Math.max(at - Date.now(), 0), MAX_RETRY_WAIT_MS) : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function postGeneration(url, apiKey, payload, signal) {
|
|
394
|
+
try {
|
|
395
|
+
const response = await fetch(url, {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': payload.contentType },
|
|
398
|
+
body: payload.body,
|
|
399
|
+
signal,
|
|
400
|
+
});
|
|
401
|
+
const text = await response.text();
|
|
402
|
+
let parsed = null;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(text);
|
|
405
|
+
} catch {
|
|
406
|
+
|
|
407
|
+
}
|
|
408
|
+
return { ok: response.ok, status: response.status, body: parsed, text, retryAfter: parseRetryAfter(response.headers) };
|
|
409
|
+
} catch (error) {
|
|
410
|
+
|
|
411
|
+
return { ok: false, status: 0, body: null, text: String(error?.message ?? error), transport: true };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function callImageApi(config, payload, signal) {
|
|
416
|
+
if (!config.apiUrl) fail('No API URL is configured. Set it in Settings, Image generation.');
|
|
417
|
+
if (!config.secret) fail('No API key is configured. Set it in Settings, Image generation.');
|
|
418
|
+
|
|
419
|
+
if (signal?.aborted) {
|
|
420
|
+
fail('This call was cancelled before the request was sent, so no generation was started.');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const base = config.apiUrl.replace(/\/+$/, '');
|
|
424
|
+
const url = `${base}${payload.endpoint}`;
|
|
425
|
+
const timeoutMs = Math.max(30, config.requestTimeoutSec || 600) * 1000;
|
|
426
|
+
const attemptSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
|
427
|
+
|
|
428
|
+
let last = null;
|
|
429
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
430
|
+
|
|
431
|
+
if (attempt > 1 && signal?.aborted) break;
|
|
432
|
+
const result = await postGeneration(url, config.secret, payload, attemptSignal);
|
|
433
|
+
last = result;
|
|
434
|
+
|
|
435
|
+
if (result.ok) {
|
|
436
|
+
|
|
437
|
+
if (result.body?.error) fail(apiError(result.body, result.status));
|
|
438
|
+
if (result.body === null) {
|
|
439
|
+
fail(`Image API answered HTTP ${result.status} with a non-JSON body: ${printableHead(result.text, 300)}`);
|
|
440
|
+
}
|
|
441
|
+
if (!Array.isArray(result.body.data)) {
|
|
442
|
+
fail(`Image API returned no data array. Response head: ${printableHead(result.text, 300)}`);
|
|
443
|
+
}
|
|
444
|
+
return { data: result.body.data, usage: result.body.usage ?? null };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const retryable = result.transport || RETRYABLE_STATUS.has(result.status);
|
|
448
|
+
if (!retryable || attempt === MAX_ATTEMPTS) break;
|
|
449
|
+
if (signal?.aborted) break;
|
|
450
|
+
|
|
451
|
+
const wait = result.retryAfter ?? 400 * 3 ** (attempt - 1);
|
|
452
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (signal?.aborted) {
|
|
456
|
+
fail('Image generation was cancelled before the endpoint answered.');
|
|
457
|
+
}
|
|
458
|
+
if (last.transport) fail(`Could not reach the image endpoint at ${url}: ${last.text}`);
|
|
459
|
+
fail(apiError(last.body ?? last.text, last.status));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const servedFiles = new Map();
|
|
463
|
+
const SERVED_FILE_LIMIT = 500;
|
|
464
|
+
|
|
465
|
+
let servedIndexOrder = [];
|
|
466
|
+
|
|
467
|
+
export function resolveOutputDir(config, location, workspace) {
|
|
468
|
+
const requested = String(location ?? '').trim();
|
|
469
|
+
if (!requested) {
|
|
470
|
+
return workspace && !config.outputDir ? resolve(workspace) : outputDirOf(config);
|
|
471
|
+
}
|
|
472
|
+
return isAbsolute(requested)
|
|
473
|
+
? normalize(requested)
|
|
474
|
+
: resolve(workspace ?? outputDirOf(config), requested);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export const FILE_NAME_TOKENS = ['{time}', '{prompt}', '{index}', '{uniq}'];
|
|
478
|
+
const DEFAULT_FILE_NAME_PATTERN = '{time}-{prompt}';
|
|
479
|
+
|
|
480
|
+
const RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
|
481
|
+
|
|
482
|
+
const MAX_STEM_CHARS = 120;
|
|
483
|
+
|
|
484
|
+
export function applyNamePattern(pattern, { prompt, index }) {
|
|
485
|
+
const source = String(pattern ?? '').trim() || DEFAULT_FILE_NAME_PATTERN;
|
|
486
|
+
const slugged = slug(prompt, 60);
|
|
487
|
+
const rendered = source
|
|
488
|
+
.replaceAll('{time}', stamp())
|
|
489
|
+
.replaceAll('{prompt}', slugged)
|
|
490
|
+
.replaceAll('{index}', String(index))
|
|
491
|
+
.replaceAll('{uniq}', randomUUID().slice(0, 8))
|
|
492
|
+
.replace(/[\\/:*?"<>|]+/g, '-')
|
|
493
|
+
.replace(/-{2,}/g, '-')
|
|
494
|
+
.replace(/^[-.\s]+|[-.\s]+$/g, '');
|
|
495
|
+
|
|
496
|
+
const base = (rendered || slugged || 'image').slice(0, MAX_STEM_CHARS).replace(/[-.\s]+$/g, '');
|
|
497
|
+
const safe = base || 'image';
|
|
498
|
+
return RESERVED_DEVICE_NAME.test(safe) ? `${safe}-image` : safe;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const NAME_COLLISION = new Set(['EEXIST', 'EACCES']);
|
|
502
|
+
|
|
503
|
+
async function writeExclusive(dir, stem, extension, bytes) {
|
|
504
|
+
for (let attempt = 1; attempt <= MAX_NAME_ATTEMPTS; attempt += 1) {
|
|
505
|
+
const name = attempt === 1 ? stem : `${stem}-${attempt}`;
|
|
506
|
+
const filePath = join(dir, `${name}.${extension}`);
|
|
507
|
+
try {
|
|
508
|
+
await writeFile(filePath, bytes, { flag: 'wx' });
|
|
509
|
+
return filePath;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (!NAME_COLLISION.has(error?.code)) throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return fail(`Could not find a free file name for "${stem}" after ${MAX_NAME_ATTEMPTS} attempts in ${dir}.`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function persistImages(config, data, prompt, outputDir) {
|
|
518
|
+
await mkdir(outputDir, { recursive: true });
|
|
519
|
+
|
|
520
|
+
const images = [];
|
|
521
|
+
for (const [index, item] of data.entries()) {
|
|
522
|
+
const label = `Image ${index + 1}`;
|
|
523
|
+
const { bytes, format, mime } = decodeImage(item?.b64_json ?? item?.b64Json, label);
|
|
524
|
+
|
|
525
|
+
const extension = EXTENSION_BY_FORMAT[format] ?? EXTENSION_BY_FORMAT[config.outputFormat] ?? 'png';
|
|
526
|
+
|
|
527
|
+
const stem = applyNamePattern(config.fileNameFormat, { prompt, index: index + 1 });
|
|
528
|
+
const filePath = await writeExclusive(outputDir, stem, extension, bytes);
|
|
529
|
+
|
|
530
|
+
rememberFile(filePath);
|
|
531
|
+
const revised = typeof item?.revised_prompt === 'string' && item.revised_prompt ? item.revised_prompt : undefined;
|
|
532
|
+
|
|
533
|
+
images.push({
|
|
534
|
+
index: index + 1,
|
|
535
|
+
path: filePath,
|
|
536
|
+
url: `${IMAGE_ROUTE}/${encodeURIComponent(basename(filePath))}`,
|
|
537
|
+
bytes: bytes.byteLength,
|
|
538
|
+
mime,
|
|
539
|
+
...(revised === undefined ? {} : { revisedPrompt: revised }),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return images;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function generate(params, config, exec) {
|
|
546
|
+
const prompt = String(params.prompt ?? '').trim();
|
|
547
|
+
if (!prompt) throw new Error('A prompt is required.');
|
|
548
|
+
if (prompt.length > MAX_PROMPT_CHARS) throw new Error(`The prompt must stay under ${MAX_PROMPT_CHARS} characters.`);
|
|
549
|
+
|
|
550
|
+
const count = Number(params.count ?? config.count ?? 1);
|
|
551
|
+
if (!Number.isInteger(count) || count < 1 || count > 10) {
|
|
552
|
+
throw new Error('count must be an integer between 1 and 10.');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const requested = String(params.resolution ?? '').trim();
|
|
556
|
+
const configuredDefault = config.defaultResolution === 'auto' ? 'auto' : parseSize(config.defaultResolution, config);
|
|
557
|
+
const size = !requested || requested === 'auto' ? configuredDefault : parseSize(requested, config);
|
|
558
|
+
|
|
559
|
+
const askedQuality = String(params.quality ?? '').trim().toLowerCase();
|
|
560
|
+
if (askedQuality && !QUALITIES.includes(askedQuality)) {
|
|
561
|
+
throw new Error(`"${params.quality}" is not an available quality. Use one of: ${QUALITIES.join(', ')}.`);
|
|
562
|
+
}
|
|
563
|
+
const quality = askedQuality || DEFAULT_QUALITY;
|
|
564
|
+
|
|
565
|
+
const background = normalizeBackground(params.background, config);
|
|
566
|
+
const workspace = exec?.agent?.session?.header?.cwd;
|
|
567
|
+
|
|
568
|
+
rememberWorkspace(workspace);
|
|
569
|
+
const outputDir = resolveOutputDir(config, params.location, workspace);
|
|
570
|
+
|
|
571
|
+
const sourcePaths = normalizeSourcePaths(params.images ?? params.image);
|
|
572
|
+
|
|
573
|
+
const sources = await readSourceImages(sourcePaths);
|
|
574
|
+
const mask = params.mask ? (await readSourceImages([params.mask]))[0] : null;
|
|
575
|
+
if (mask && sources.length !== 1) {
|
|
576
|
+
fail(`A mask applies to exactly one source image; ${sources.length} were given.`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const payload = sources.length
|
|
580
|
+
? (() => {
|
|
581
|
+
const { fields } = buildEditForm(config, prompt, size, count, background, quality, sources);
|
|
582
|
+
const encoded = encodeMultipart(fields, sources, mask);
|
|
583
|
+
return { endpoint: '/images/edits', ...encoded };
|
|
584
|
+
})()
|
|
585
|
+
: (() => {
|
|
586
|
+
const body = JSON.stringify(buildBody(config, prompt, size, count, background, quality));
|
|
587
|
+
return { endpoint: '/images/generations', body, contentType: 'application/json' };
|
|
588
|
+
})();
|
|
589
|
+
|
|
590
|
+
const { data, usage } = await callImageApi(config, payload, exec.signal);
|
|
591
|
+
const images = await persistImages(config, data, prompt, outputDir);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
images,
|
|
595
|
+
savedTo: outputDir,
|
|
596
|
+
model: config.model,
|
|
597
|
+
size,
|
|
598
|
+
quality,
|
|
599
|
+
background,
|
|
600
|
+
requested: count,
|
|
601
|
+
returned: images.length,
|
|
602
|
+
|
|
603
|
+
...(Number.isFinite(usage?.total_tokens) ? { usageTokens: usage.total_tokens } : {}),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function toolText(value) {
|
|
608
|
+
const paths = value.images.map((image) => `${image.index}. ${image.path}`);
|
|
609
|
+
return [
|
|
610
|
+
`Generated ${value.returned} image${value.returned === 1 ? '' : 's'} with ${value.model} (${value.size}, quality ${value.quality}, background ${value.background}), written to ${value.savedTo}.`,
|
|
611
|
+
...paths,
|
|
612
|
+
].join('\n');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const decodeMeta = (value) => {
|
|
616
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
617
|
+
if (typeof value !== 'string') return null;
|
|
618
|
+
try {
|
|
619
|
+
const parsed = JSON.parse(value);
|
|
620
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const contentType = (fileName) =>
|
|
627
|
+
fileName.endsWith('.jpg') || fileName.endsWith('.jpeg')
|
|
628
|
+
? 'image/jpeg'
|
|
629
|
+
: fileName.endsWith('.webp')
|
|
630
|
+
? 'image/webp'
|
|
631
|
+
: 'image/png';
|
|
632
|
+
|
|
633
|
+
const SERVED_INDEX_LIMIT = 2000;
|
|
634
|
+
|
|
635
|
+
function servedIndexPath() {
|
|
636
|
+
return join(DEFAULT_OUTPUT_DIR, 'served-index.json');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function readServedIndex() {
|
|
640
|
+
try {
|
|
641
|
+
const raw = readFileSync(servedIndexPath(), 'utf8');
|
|
642
|
+
const parsed = JSON.parse(raw);
|
|
643
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
|
|
644
|
+
return Object.entries(parsed)
|
|
645
|
+
.filter(([name, target]) => typeof name === 'string' && typeof target === 'string')
|
|
646
|
+
.slice(-SERVED_INDEX_LIMIT);
|
|
647
|
+
} catch {
|
|
648
|
+
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function writeServedIndex(entries) {
|
|
654
|
+
try {
|
|
655
|
+
mkdirSync(DEFAULT_OUTPUT_DIR, { recursive: true });
|
|
656
|
+
const record = {};
|
|
657
|
+
for (const [name, target] of entries.slice(-SERVED_INDEX_LIMIT)) record[name] = target;
|
|
658
|
+
writeFileSync(servedIndexPath(), `${JSON.stringify(record, null, 0)}\n`, 'utf8');
|
|
659
|
+
} catch {
|
|
660
|
+
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const BACKFILL_MAX_DEPTH = 3;
|
|
665
|
+
const BACKFILL_MAX_FILES = 500;
|
|
666
|
+
|
|
667
|
+
async function backfillIndexFrom(root) {
|
|
668
|
+
if (!root || typeof root !== 'string') return 0;
|
|
669
|
+
const found = new Map();
|
|
670
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
671
|
+
while (queue.length > 0 && found.size < BACKFILL_MAX_FILES) {
|
|
672
|
+
const { dir, depth } = queue.shift();
|
|
673
|
+
let entries;
|
|
674
|
+
try {
|
|
675
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
676
|
+
} catch {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
if (found.size >= BACKFILL_MAX_FILES) break;
|
|
681
|
+
const target = join(dir, entry.name);
|
|
682
|
+
if (entry.isDirectory()) {
|
|
683
|
+
|
|
684
|
+
if (depth >= BACKFILL_MAX_DEPTH) continue;
|
|
685
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
686
|
+
queue.push({ dir: target, depth: depth + 1 });
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (!entry.isFile()) continue;
|
|
690
|
+
|
|
691
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-.+\.(png|jpg|webp|gif)$/i.test(entry.name)) continue;
|
|
692
|
+
found.set(entry.name, target);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (found.size === 0) return 0;
|
|
696
|
+
const known = new Map(readServedIndex());
|
|
697
|
+
let added = 0;
|
|
698
|
+
for (const [name, target] of found) {
|
|
699
|
+
if (known.has(name)) continue;
|
|
700
|
+
known.set(name, target);
|
|
701
|
+
added += 1;
|
|
702
|
+
}
|
|
703
|
+
if (added > 0) writeServedIndex([...known.entries()]);
|
|
704
|
+
return added;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function rememberFile(filePath) {
|
|
708
|
+
const name = basename(filePath);
|
|
709
|
+
const paths = servedFiles.get(name) ?? [];
|
|
710
|
+
const withoutDuplicate = paths.filter((candidate) => candidate !== filePath);
|
|
711
|
+
withoutDuplicate.push(filePath);
|
|
712
|
+
servedFiles.set(name, withoutDuplicate);
|
|
713
|
+
servedIndexOrder = servedIndexOrder.filter((candidate) => candidate !== filePath);
|
|
714
|
+
servedIndexOrder.push(filePath);
|
|
715
|
+
while (servedFiles.size > SERVED_FILE_LIMIT) {
|
|
716
|
+
servedFiles.delete(servedFiles.keys().next().value);
|
|
717
|
+
}
|
|
718
|
+
if (servedIndexOrder.length > SERVED_INDEX_LIMIT) servedIndexOrder = servedIndexOrder.slice(-SERVED_INDEX_LIMIT);
|
|
719
|
+
writeServedIndex(servedIndexOrder.map((entry) => [basename(entry), entry]));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function rememberedPaths(fileName) {
|
|
723
|
+
const live = [...(servedFiles.get(fileName) ?? [])].reverse();
|
|
724
|
+
const persisted = readServedIndex()
|
|
725
|
+
.filter(([name]) => name === fileName)
|
|
726
|
+
.map(([, target]) => target)
|
|
727
|
+
.reverse();
|
|
728
|
+
return [...new Set([...live, ...persisted])];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export function indexPathForTests() {
|
|
732
|
+
return servedIndexPath();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function rememberWorkspaceForTests(cwd) {
|
|
736
|
+
rememberWorkspace(cwd);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function serveImageForTests(req, res, config) {
|
|
740
|
+
return serveImage(req, res, config);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function clearServedIndex() {
|
|
744
|
+
const before = readServedIndex().length + servedFiles.size;
|
|
745
|
+
servedFiles.clear();
|
|
746
|
+
servedIndexOrder = [];
|
|
747
|
+
let removed = false;
|
|
748
|
+
try {
|
|
749
|
+
rmSync(servedIndexPath(), { force: true });
|
|
750
|
+
removed = true;
|
|
751
|
+
} catch {
|
|
752
|
+
removed = false;
|
|
753
|
+
}
|
|
754
|
+
return { cleared: before, indexRemoved: removed };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export const RECORDS_ROUTE = '/dsh-image-generation/records';
|
|
758
|
+
const RECORDS_HEADER = 'x-dsh-image-generation-action';
|
|
759
|
+
|
|
760
|
+
async function handleRecords(req, res) {
|
|
761
|
+
const method = String(req.method ?? 'GET').toUpperCase();
|
|
762
|
+
if (method !== 'POST') {
|
|
763
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', allow: 'POST' }).end('{"error":"method not allowed"}');
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (req.headers?.[RECORDS_HEADER] !== 'clear-records') {
|
|
767
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }).end('{"error":"missing action header"}');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
const result = clearServedIndex();
|
|
772
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify(result));
|
|
773
|
+
} catch (error) {
|
|
774
|
+
|
|
775
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }).end(
|
|
776
|
+
JSON.stringify({ error: String(error?.message ?? error) }),
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export function forgetInMemoryFilesForTests() {
|
|
782
|
+
servedFiles.clear();
|
|
783
|
+
servedIndexOrder = [];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function rememberFileForTests(filePath) {
|
|
787
|
+
rememberFile(filePath);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const recentWorkspaces = new Set();
|
|
791
|
+
const MAX_RECENT_WORKSPACES = 20;
|
|
792
|
+
|
|
793
|
+
function rememberWorkspace(cwd) {
|
|
794
|
+
if (typeof cwd !== 'string' || !cwd) return;
|
|
795
|
+
recentWorkspaces.delete(cwd);
|
|
796
|
+
recentWorkspaces.add(cwd);
|
|
797
|
+
while (recentWorkspaces.size > MAX_RECENT_WORKSPACES) {
|
|
798
|
+
recentWorkspaces.delete(recentWorkspaces.values().next().value);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const WORKSPACE_SCAN_DEPTH = 2;
|
|
803
|
+
const WORKSPACE_SCAN_LIMIT = 4000;
|
|
804
|
+
|
|
805
|
+
const WORKSPACE_SCAN_BUDGET_MS = 60;
|
|
806
|
+
const PLUGIN_FRAME_NAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-.+\.(png|jpg|webp|gif)$/i;
|
|
807
|
+
|
|
808
|
+
const OUTPUT_HINT = /^(out|output|outputs|images?|frames?|shots?|assets|generated|exports?|png|webp)$/i;
|
|
809
|
+
|
|
810
|
+
async function findByBasename(root, fileName) {
|
|
811
|
+
const deadline = Date.now() + WORKSPACE_SCAN_BUDGET_MS;
|
|
812
|
+
let level = [{ dir: root, depth: 0 }];
|
|
813
|
+
let seen = 0;
|
|
814
|
+
while (level.length > 0 && seen < WORKSPACE_SCAN_LIMIT && Date.now() < deadline) {
|
|
815
|
+
const next = [];
|
|
816
|
+
for (const { dir, depth } of level) {
|
|
817
|
+
if (Date.now() >= deadline) break;
|
|
818
|
+
let entries;
|
|
819
|
+
try {
|
|
820
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
821
|
+
} catch {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
for (const entry of entries) {
|
|
825
|
+
seen += 1;
|
|
826
|
+
if (seen > WORKSPACE_SCAN_LIMIT) break;
|
|
827
|
+
if (entry.isFile()) {
|
|
828
|
+
if (entry.name === fileName) return join(dir, entry.name);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (entry.isDirectory() && depth < WORKSPACE_SCAN_DEPTH && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
832
|
+
next.push({ dir: join(dir, entry.name), depth: depth + 1, hinted: OUTPUT_HINT.test(entry.name) ? 0 : 1 });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
next.sort((a, b) => a.hinted - b.hinted);
|
|
838
|
+
level = next;
|
|
839
|
+
}
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function serveImage(req, res, config) {
|
|
844
|
+
|
|
845
|
+
let fileName;
|
|
846
|
+
try {
|
|
847
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
848
|
+
fileName = basename(decodeURIComponent(url.pathname.slice(ROUTE_SEGMENT.length)));
|
|
849
|
+
} catch {
|
|
850
|
+
res.writeHead(400).end('bad request');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (!fileName || fileName.includes('..')) {
|
|
854
|
+
res.writeHead(400).end('bad request');
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const workspaces = [...recentWorkspaces].reverse();
|
|
859
|
+
const roots = [...new Set([resolve(DEFAULT_OUTPUT_DIR), outputDirOf(config)])];
|
|
860
|
+
|
|
861
|
+
const remembered = rememberedPaths(fileName);
|
|
862
|
+
|
|
863
|
+
const scanBases = [...new Set([...roots, ...workspaces])];
|
|
864
|
+
|
|
865
|
+
const scanned = PLUGIN_FRAME_NAME.test(fileName) ? scanBases.map((dir) => resolve(dir, fileName)) : [];
|
|
866
|
+
for (const candidate of [...remembered, ...scanned]) {
|
|
867
|
+
if (!candidate) continue;
|
|
868
|
+
try {
|
|
869
|
+
const bytes = await readFile(candidate);
|
|
870
|
+
|
|
871
|
+
if (!remembered.includes(candidate)) rememberFile(candidate);
|
|
872
|
+
res.writeHead(200, {
|
|
873
|
+
'content-type': contentType(fileName),
|
|
874
|
+
'content-length': String(bytes.byteLength),
|
|
875
|
+
'cache-control': 'private, max-age=31536000, immutable',
|
|
876
|
+
});
|
|
877
|
+
res.end(bytes);
|
|
878
|
+
return;
|
|
879
|
+
} catch {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (PLUGIN_FRAME_NAME.test(fileName)) {
|
|
885
|
+
for (const workspace of workspaces) {
|
|
886
|
+
const found = await findByBasename(workspace, fileName);
|
|
887
|
+
if (!found) continue;
|
|
888
|
+
try {
|
|
889
|
+
const bytes = await readFile(found);
|
|
890
|
+
|
|
891
|
+
rememberFile(found);
|
|
892
|
+
res.writeHead(200, {
|
|
893
|
+
'content-type': contentType(fileName),
|
|
894
|
+
'content-length': String(bytes.byteLength),
|
|
895
|
+
'cache-control': 'private, max-age=31536000, immutable',
|
|
896
|
+
});
|
|
897
|
+
res.end(bytes);
|
|
898
|
+
return;
|
|
899
|
+
} catch {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
res.writeHead(404, { 'cache-control': 'no-store' }).end('not found');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function apply(ctx, config) {
|
|
908
|
+
const readConfig = () => effectiveConfig(config);
|
|
909
|
+
|
|
910
|
+
ctx.effect(() => ctx.inject(['webServer'], (scope) => scope.webServer.register({
|
|
911
|
+
kind: 'prefix',
|
|
912
|
+
path: IMAGE_ROUTE,
|
|
913
|
+
handler: (req, res) => serveImage(req, res, readConfig()),
|
|
914
|
+
})));
|
|
915
|
+
|
|
916
|
+
ctx.effect(() => ctx.inject(['webServer'], (scope) => scope.webServer.register({
|
|
917
|
+
kind: 'exact',
|
|
918
|
+
path: RECORDS_ROUTE,
|
|
919
|
+
handler: (req, res) => handleRecords(req, res),
|
|
920
|
+
})));
|
|
921
|
+
|
|
922
|
+
ctx.effect(() => {
|
|
923
|
+
const presets = presetSizes(readConfig());
|
|
924
|
+
|
|
925
|
+
const workspace = process.env.DSH_WORKSPACE || process.cwd();
|
|
926
|
+
backfillIndexFrom(workspace).catch(() => {});
|
|
927
|
+
return ctx.tools.register(defineTool({
|
|
928
|
+
name: 'generate_image',
|
|
929
|
+
description: [
|
|
930
|
+
'Generate one or more images from a prompt, save them to disk, and return their absolute paths. Show them with present, or read one back with read_image.',
|
|
931
|
+
'Write the prompt as ordinary language that says what the picture is for and what it must contain. Do not use weight syntax, quality tags, or parameter flags, and do not pad it with style adjectives: this model reads intent well and has its own taste.',
|
|
932
|
+
'What it is good at: posters, knowledge cards, knowledge maps, infographics, charts, slides, UI mockups, and multi-panel sheets with correct text and real layout; photorealistic people, places, products, food, interiors, and everyday scenes; illustrations and plain scenes; and game or image assets such as sprites, sprite sheets, tilesets, icons, item and character art, textures, and UI elements, on a transparent background when the asset needs one.',
|
|
933
|
+
'Use one call with count for variations of one image. Use separate calls when the frames or the content differ.',
|
|
934
|
+
'A custom frame must satisfy the endpoint exactly: every side a multiple of 16, the longest edge within the cap, 655,360 to 8,294,400 pixels total, and a ratio no wider than 3:1.',
|
|
935
|
+
'Files land in the session workspace unless location points somewhere else. Settings, Image generation owns the endpoint, credentials, model, and output format. Resolution, quality, count, background, location, and the source images are per-call arguments.',
|
|
936
|
+
'Passing images turns this from a generation into an edit: the source pictures are sent to the endpoint as the basis of the result, and the prompt describes the change rather than the whole scene.',
|
|
937
|
+
].join(' '),
|
|
938
|
+
parameters: {
|
|
939
|
+
prompt: {
|
|
940
|
+
type: 'string',
|
|
941
|
+
required: true,
|
|
942
|
+
description:
|
|
943
|
+
"What to make, in ordinary language. Everything the caller specified must survive into this prompt verbatim: the exact text that must appear, names, numbers, and any style they named. Everything else is yours to decide: composition, palette, and finish. Quote every string that must be readable in the image, and say where it sits and how it is set; this model renders the characters it is given. For a poster, card, infographic, or knowledge map, lead with the format and the count of sections, panels, or rows, then say what each one holds. When several images must share a look or a subject, say so in each prompt and name what stays fixed.",
|
|
944
|
+
},
|
|
945
|
+
resolution: {
|
|
946
|
+
type: 'string',
|
|
947
|
+
description: `Output frame, written as WIDTHxHEIGHT, or "auto" to let the model choose a fitting ratio. Omit the parameter to inherit the configured default. The endpoint accepts a custom size when every side is a multiple of 16, the longest edge is at most ${readConfig().maxEdge}, the total is between 655,360 and 8,294,400 pixels, and the ratio is no wider than 3:1. Ready-made sizes that pass: ${presets.join(', ')}.`,
|
|
948
|
+
},
|
|
949
|
+
quality: {
|
|
950
|
+
type: 'string',
|
|
951
|
+
enum: QUALITIES,
|
|
952
|
+
description: `Render quality, lowest effort first: ${QUALITIES.join(', ')}. Omit to use ${DEFAULT_QUALITY}.`,
|
|
953
|
+
},
|
|
954
|
+
count: {
|
|
955
|
+
type: 'integer',
|
|
956
|
+
description:
|
|
957
|
+
'How many images to generate in this call, 1 to 10. Use it for variations of one prompt. Omit to use the configured default.',
|
|
958
|
+
},
|
|
959
|
+
background: {
|
|
960
|
+
type: 'string',
|
|
961
|
+
enum: BACKGROUNDS,
|
|
962
|
+
description:
|
|
963
|
+
'auto keeps the model default, transparent leaves no background and needs png or webp output (use it for an asset meant to be dropped onto another surface), opaque fills it.',
|
|
964
|
+
},
|
|
965
|
+
location: {
|
|
966
|
+
type: 'string',
|
|
967
|
+
description:
|
|
968
|
+
'Where to write the files. Omit it to use the session workspace; a relative path resolves against the workspace, and an absolute path is taken as given. The directory is created when it does not exist. Point this at the project folder the images belong to rather than leaving loose files in the workspace root when a call belongs to one.',
|
|
969
|
+
},
|
|
970
|
+
images: {
|
|
971
|
+
type: 'array',
|
|
972
|
+
items: { type: 'string' },
|
|
973
|
+
description:
|
|
974
|
+
'Source images to edit, as file paths. Giving one or more switches the call to the edit endpoint, where these pictures are the basis of the result and the prompt describes the change: "replace the background with a plain studio wall", "make the fur ginger", "add a red scarf". A path may be absolute or relative to the session workspace. At most 16 images, 50MB each. Omit this entirely and the call is a generation from the prompt alone, which is the right tool when there is nothing to edit.',
|
|
975
|
+
},
|
|
976
|
+
mask: {
|
|
977
|
+
type: 'string',
|
|
978
|
+
description:
|
|
979
|
+
'Optional PNG that marks the region to change, used with exactly one source image in images. Transparent pixels are the area the model is free to repaint and opaque pixels are kept, so it must be the same size as the source and have an alpha channel. Omit it to let the model decide what to change across the whole image.',
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
output: {
|
|
983
|
+
schema: {
|
|
984
|
+
type: 'object',
|
|
985
|
+
additionalProperties: false,
|
|
986
|
+
properties: {
|
|
987
|
+
images: {
|
|
988
|
+
type: 'array',
|
|
989
|
+
required: true,
|
|
990
|
+
items: {
|
|
991
|
+
type: 'object',
|
|
992
|
+
additionalProperties: false,
|
|
993
|
+
properties: {
|
|
994
|
+
index: { type: 'integer', required: true },
|
|
995
|
+
path: { type: 'string', required: true },
|
|
996
|
+
url: { type: 'string', required: true },
|
|
997
|
+
bytes: { type: 'integer', required: true },
|
|
998
|
+
mime: { type: 'string', required: true },
|
|
999
|
+
revisedPrompt: { type: 'string' },
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
model: { type: 'string', required: true },
|
|
1004
|
+
savedTo: { type: 'string', required: true },
|
|
1005
|
+
size: { type: 'string', required: true },
|
|
1006
|
+
quality: { type: 'string', required: true },
|
|
1007
|
+
background: { type: 'string', required: true },
|
|
1008
|
+
requested: { type: 'integer', required: true },
|
|
1009
|
+
returned: { type: 'integer', required: true },
|
|
1010
|
+
usageTokens: { type: 'integer' },
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
render: (_args, value) => [{ type: 'text', text: toolText(value) }],
|
|
1014
|
+
presentationMeta: (_args, value) => ({
|
|
1015
|
+
title: `Generated ${value.returned} image${value.returned === 1 ? '' : 's'}`,
|
|
1016
|
+
summary: `${value.model} · ${value.size} · quality ${value.quality} · ${value.background}`,
|
|
1017
|
+
savedTo: value.savedTo,
|
|
1018
|
+
images: value.images.map((image) => ({
|
|
1019
|
+
url: image.url,
|
|
1020
|
+
path: image.path,
|
|
1021
|
+
bytes: image.bytes,
|
|
1022
|
+
})),
|
|
1023
|
+
}),
|
|
1024
|
+
},
|
|
1025
|
+
presentCall: (args) => ({
|
|
1026
|
+
card: 'generic',
|
|
1027
|
+
title: `Generate image: ${String(args.prompt ?? '').slice(0, 80)}`,
|
|
1028
|
+
kind: 'other',
|
|
1029
|
+
}),
|
|
1030
|
+
presentResult: (_args, result) => {
|
|
1031
|
+
if (result.isError) return undefined;
|
|
1032
|
+
const meta = decodeMeta(result.meta);
|
|
1033
|
+
if (!meta || !Array.isArray(meta.images)) return undefined;
|
|
1034
|
+
const lines = meta.images.map((image) => `${image.path} (${Math.round(image.bytes / 1024)} KB)`);
|
|
1035
|
+
const heading = meta.savedTo ? `${meta.summary} · written to ${meta.savedTo}` : meta.summary;
|
|
1036
|
+
return {
|
|
1037
|
+
card: 'generic',
|
|
1038
|
+
title: meta.title,
|
|
1039
|
+
content: [{ type: 'text', text: [heading, ...lines].join('\n') }],
|
|
1040
|
+
};
|
|
1041
|
+
},
|
|
1042
|
+
execute: (args, exec) => generate(args, readConfig(), exec),
|
|
1043
|
+
}));
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export { BACKGROUNDS, backfillIndexFrom, buildBody, buildEditForm, effectiveConfig, encodeMultipart, normalizeBackground, normalizeSourcePaths, readSourceImages, safeFileName };
|