@dickpy/dsh-imagegen 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/lib/index.js ADDED
@@ -0,0 +1,896 @@
1
+ import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ import z from "schemastery";
3
+ import { promises } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ //#region src/protocol.ts
7
+ /**
8
+ * Wire contract shared by the host and client halves of dsh-imagegen: the
9
+ * settings namespace, the route paths, and the generate payload/result shapes.
10
+ * Pure types + constants — safe for the client bundle to inline.
11
+ */
12
+ /** Settings namespace this plugin owns (host settings seam + bridge). */
13
+ const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
14
+ /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
15
+ const SETTINGS_API = {
16
+ describe: "/api/dsh-imagegen/settings/describe",
17
+ mutate: "/api/dsh-imagegen/settings/mutate"
18
+ };
19
+ /** The image-generation proxy route. */
20
+ const GENERATE_API = "/api/dsh-imagegen/generate";
21
+ /**
22
+ * Same-origin route family for the host-persisted generation history. Images
23
+ * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
24
+ * the `image` prefix route, so list responses carry metadata only (never
25
+ * base64) and the browser loads thumbnails/previews lazily.
26
+ */
27
+ const HISTORY_API = {
28
+ list: "/api/dsh-imagegen/history/list",
29
+ append: "/api/dsh-imagegen/history/append",
30
+ remove: "/api/dsh-imagegen/history/remove",
31
+ clear: "/api/dsh-imagegen/history/clear",
32
+ image: "/api/dsh-imagegen/history/image"
33
+ };
34
+ //#endregion
35
+ //#region src/engine.ts
36
+ /** A generation failure with a user-presentable message. */
37
+ var ImageGenError = class extends Error {
38
+ /** Stable wire code. */
39
+ code;
40
+ constructor(message, code = "generate-failed") {
41
+ super(message);
42
+ this.name = "ImageGenError";
43
+ this.code = code;
44
+ }
45
+ };
46
+ /** Total budget for the upstream generation call (image models are slow). */
47
+ const UPSTREAM_TIMEOUT_MS = 24e4;
48
+ /** Budget for downloading one result image URL. */
49
+ const IMAGE_FETCH_TIMEOUT_MS = 6e4;
50
+ /** Cap on the reference image payload (edit mode), in bytes. */
51
+ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024;
52
+ /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
53
+ const DALLE3_SIZES = /* @__PURE__ */ new Set([
54
+ "1024x1024",
55
+ "1792x1024",
56
+ "1024x1792"
57
+ ]);
58
+ /** Content-type extension hints for URL-fetched images. */
59
+ function mimeOfExtension(path) {
60
+ const match = /\.([a-z0-9]+)$/i.exec(path);
61
+ if (match === null) return void 0;
62
+ switch (match[1].toLowerCase()) {
63
+ case "png": return "image/png";
64
+ case "jpg":
65
+ case "jpeg": return "image/jpeg";
66
+ case "webp": return "image/webp";
67
+ case "gif": return "image/gif";
68
+ default: return;
69
+ }
70
+ }
71
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
72
+ function parseDataUrl(dataUrl) {
73
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim());
74
+ if (match === null || match[3] === void 0) return void 0;
75
+ if (match[2] === void 0) return;
76
+ return {
77
+ mime: match[1] ?? "application/octet-stream",
78
+ base64: match[3]
79
+ };
80
+ }
81
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
82
+ function bareBase64(value) {
83
+ const parsed = parseDataUrl(value);
84
+ return parsed !== void 0 && parsed.base64 !== void 0 ? parsed.base64 : value;
85
+ }
86
+ /** Clamp the requested image count into the API-accepted range. */
87
+ function clampCount(n) {
88
+ if (!Number.isFinite(n)) return 1;
89
+ return Math.min(4, Math.max(1, Math.round(n)));
90
+ }
91
+ /** Pick the effective per-model request parameters. Never includes `n`: the
92
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
93
+ * so the count is satisfied by parallel single-image requests instead. */
94
+ function effectiveParams(request) {
95
+ const model = request.model.trim() === "" ? "gpt-image-2" : request.model.trim();
96
+ if (model === "dall-e-3") return {
97
+ model,
98
+ size: DALLE3_SIZES.has(request.size) ? request.size : "1024x1024"
99
+ };
100
+ return {
101
+ model,
102
+ ...request.size !== "" && request.size !== "auto" ? { size: request.size } : {},
103
+ ...request.quality !== "" && request.quality !== "auto" ? { quality: request.quality } : {},
104
+ ...request.detail !== "" ? { detail: request.detail } : {}
105
+ };
106
+ }
107
+ /** How many single-image requests to issue for the requested image count. */
108
+ function effectiveCount(request) {
109
+ if ((request.model.trim() === "" ? "gpt-image-2" : request.model.trim()) === "dall-e-3") return 1;
110
+ return clampCount(request.n);
111
+ }
112
+ /** Normalize one upstream data item into a base64 image. */
113
+ async function normalizeItem(item, upstream) {
114
+ const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
115
+ if (typeof item.b64_json === "string") return {
116
+ b64: bareBase64(item.b64_json),
117
+ mime: "image/png",
118
+ revisedPrompt
119
+ };
120
+ if (typeof item.url !== "string" || item.url === "") throw new ImageGenError("upstream image item has neither b64_json nor url");
121
+ const url = item.url;
122
+ if (url.startsWith("data:")) {
123
+ const parsed = parseDataUrl(url);
124
+ if (parsed === void 0) throw new ImageGenError("upstream returned a malformed data: url");
125
+ return {
126
+ b64: parsed.base64,
127
+ mime: parsed.mime,
128
+ revisedPrompt
129
+ };
130
+ }
131
+ let response;
132
+ try {
133
+ response = await fetch(url, {
134
+ headers: { ...upstream.apiKey === "" ? {} : { authorization: `Bearer ${upstream.apiKey}` } },
135
+ signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS)
136
+ });
137
+ } catch (error) {
138
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
141
+ const buffer = Buffer.from(await response.arrayBuffer());
142
+ const contentType = response.headers.get("content-type");
143
+ const mime = contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png";
144
+ return {
145
+ b64: buffer.toString("base64"),
146
+ mime,
147
+ revisedPrompt
148
+ };
149
+ }
150
+ /**
151
+ * Issue one single-image request (never sends `n`). The response is kept as a
152
+ * list so a gateway that happens to return several images per call still works.
153
+ */
154
+ async function requestOneImage(baseUrl, upstream, request, params) {
155
+ const headers = { authorization: `Bearer ${upstream.apiKey.trim()}` };
156
+ let body;
157
+ if (request.mode === "edit") {
158
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
159
+ const parsed = parseDataUrl(request.image);
160
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
161
+ let bytes;
162
+ try {
163
+ bytes = Buffer.from(parsed.base64, "base64");
164
+ } catch {
165
+ throw new ImageGenError("参考图片数据无法解码", "edit-image-invalid");
166
+ }
167
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
168
+ const form = new FormData();
169
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$1(parsed.mime)}`);
170
+ form.append("prompt", request.prompt);
171
+ form.append("model", params.model);
172
+ if (params.size !== void 0) form.append("size", params.size);
173
+ if (params.quality !== void 0) form.append("quality", params.quality);
174
+ if (params.detail !== void 0) form.append("detail", params.detail);
175
+ body = form;
176
+ } else {
177
+ headers["content-type"] = "application/json";
178
+ body = JSON.stringify({
179
+ prompt: request.prompt,
180
+ ...params
181
+ });
182
+ }
183
+ let response;
184
+ try {
185
+ response = await fetch(`${baseUrl}/images/${request.mode === "edit" ? "edits" : "generations"}`, {
186
+ method: "POST",
187
+ headers,
188
+ body,
189
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
190
+ });
191
+ } catch (error) {
192
+ const message = error instanceof Error ? error.message : String(error);
193
+ if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
194
+ throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
195
+ }
196
+ let payload;
197
+ try {
198
+ payload = await response.json();
199
+ } catch {
200
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
201
+ }
202
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
203
+ const record = payload;
204
+ const data = Array.isArray(record.data) ? record.data : Array.isArray(record.images) ? record.images : Array.isArray(record.output) ? record.output : void 0;
205
+ if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
206
+ if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
207
+ return Promise.all(data.map(async (entry) => {
208
+ if (entry === null || typeof entry !== "object") throw new ImageGenError("上游响应包含无效的图片条目", "upstream-invalid");
209
+ return normalizeItem(entry, upstream);
210
+ }));
211
+ }
212
+ /**
213
+ * Forward one generate request to the configured endpoint. The requested image
214
+ * count is satisfied with N parallel single-image requests (the `n` batch
215
+ * parameter is never sent, because Responses-API-based gateways reject it as
216
+ * `tools[0].n`), then the results are flattened in order.
217
+ */
218
+ async function generateImage(upstream, request) {
219
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
220
+ if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
221
+ if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
222
+ const params = effectiveParams(request);
223
+ const count = effectiveCount(request);
224
+ return { images: (await Promise.all(Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params)))).flat() };
225
+ }
226
+ /** Human-readable failure message from an upstream error payload. */
227
+ function upstreamMessage(payload, status) {
228
+ if (payload !== null && typeof payload === "object") {
229
+ const record = payload;
230
+ const error = record.error;
231
+ if (error !== null && typeof error === "object") {
232
+ const message = error.message;
233
+ if (typeof message === "string" && message !== "") return message;
234
+ }
235
+ if (typeof record.message === "string" && record.message !== "") return record.message;
236
+ if (typeof record.error === "string" && record.error !== "") return record.error;
237
+ }
238
+ return `上游接口拒绝请求(HTTP ${status})`;
239
+ }
240
+ /** File extension for a MIME type (multipart reference image). */
241
+ function extensionOf$1(mime) {
242
+ switch (mime.split(";")[0].trim()) {
243
+ case "image/jpeg": return "jpg";
244
+ case "image/webp": return "webp";
245
+ case "image/gif": return "gif";
246
+ default: return "png";
247
+ }
248
+ }
249
+ //#endregion
250
+ //#region src/history-store.ts
251
+ /**
252
+ * Host-persisted generation history: images are stored as individual files
253
+ * under ~/.dsh/dsh-imagegen/images/ and an index.json keeps the metadata +
254
+ * file names. This makes the history survive across browsers/devices that
255
+ * connect to the same DSH host, and keeps list responses small (the browser
256
+ * loads image bytes lazily through the history image route).
257
+ *
258
+ * Framework-free (node:fs only) so the route layer can drive it directly.
259
+ */
260
+ const HISTORY_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
261
+ const INDEX_PATH = path.join(HISTORY_DIR, "index.json");
262
+ const IMAGES_DIR = path.join(HISTORY_DIR, "images");
263
+ /** File extension for a MIME type (image file names). */
264
+ function extensionOf(mime) {
265
+ switch (mime.split(";")[0].trim()) {
266
+ case "image/jpeg": return "jpg";
267
+ case "image/webp": return "webp";
268
+ case "image/gif": return "gif";
269
+ default: return "png";
270
+ }
271
+ }
272
+ /** MIME type for a stored image file name (image route responses). */
273
+ function mimeOfFile(file) {
274
+ switch (path.extname(file).toLowerCase()) {
275
+ case ".jpg":
276
+ case ".jpeg": return "image/jpeg";
277
+ case ".webp": return "image/webp";
278
+ case ".gif": return "image/gif";
279
+ default: return "image/png";
280
+ }
281
+ }
282
+ /** Sanitize an entry id for use as a file-name prefix. */
283
+ function safeId(id) {
284
+ const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
285
+ return cleaned === "" ? "entry" : cleaned;
286
+ }
287
+ /** Ensure the storage directories exist. */
288
+ async function ensureDirs() {
289
+ await promises.mkdir(IMAGES_DIR, { recursive: true });
290
+ }
291
+ /** Read the index, tolerating a missing/corrupt file. */
292
+ async function readIndex() {
293
+ try {
294
+ const raw = await promises.readFile(INDEX_PATH, "utf8");
295
+ const parsed = JSON.parse(raw);
296
+ if (parsed === null || typeof parsed !== "object") return [];
297
+ const entries = parsed.entries;
298
+ if (!Array.isArray(entries)) return [];
299
+ return entries.filter(isStoredEntry);
300
+ } catch {
301
+ return [];
302
+ }
303
+ }
304
+ /** Persist the index. */
305
+ async function writeIndex(entries) {
306
+ await ensureDirs();
307
+ const payload = { entries };
308
+ const tmp = `${INDEX_PATH}.tmp-${process.pid}`;
309
+ await promises.writeFile(tmp, JSON.stringify(payload), "utf8");
310
+ await promises.rename(tmp, INDEX_PATH);
311
+ }
312
+ /** Structural guard for a stored entry. */
313
+ function isStoredEntry(value) {
314
+ if (value === null || typeof value !== "object") return false;
315
+ const entry = value;
316
+ return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
317
+ if (image === null || typeof image !== "object") return false;
318
+ const record = image;
319
+ return typeof record.file === "string" && typeof record.mime === "string";
320
+ });
321
+ }
322
+ /** Remove one entry's image files (best effort). */
323
+ async function removeEntryFiles(entry) {
324
+ for (const image of entry.images) try {
325
+ await promises.rm(path.join(IMAGES_DIR, image.file), { force: true });
326
+ } catch {}
327
+ }
328
+ /** Project a stored entry onto the wire shape (image URLs). */
329
+ function toWire(entry) {
330
+ return {
331
+ id: entry.id,
332
+ createdAt: entry.createdAt,
333
+ mode: entry.mode,
334
+ model: entry.model,
335
+ prompt: entry.prompt,
336
+ size: entry.size,
337
+ quality: entry.quality,
338
+ detail: entry.detail,
339
+ n: entry.n,
340
+ images: entry.images.map((image) => ({
341
+ url: `/api/dsh-imagegen/history/image/${image.file}`,
342
+ mime: image.mime,
343
+ ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
344
+ })),
345
+ ...entry.refName === void 0 ? {} : { refName: entry.refName }
346
+ };
347
+ }
348
+ /** List the persisted history, newest first, as wire entries. */
349
+ async function listHistory() {
350
+ return (await readIndex()).map(toWire);
351
+ }
352
+ /** Append one generation, evicting the oldest beyond HISTORY_MAX. */
353
+ async function appendHistory(input) {
354
+ await ensureDirs();
355
+ const prefix = safeId(input.id);
356
+ const storedImages = [];
357
+ for (let index = 0; index < input.images.length; index++) {
358
+ const image = input.images[index];
359
+ const file = `${prefix}-${index}.${extensionOf(image.mime)}`;
360
+ await promises.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, "base64"));
361
+ storedImages.push({
362
+ file,
363
+ mime: image.mime,
364
+ ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
365
+ });
366
+ }
367
+ const merged = [{
368
+ id: input.id,
369
+ createdAt: input.createdAt,
370
+ mode: input.mode,
371
+ model: input.model,
372
+ prompt: input.prompt,
373
+ size: input.size,
374
+ quality: input.quality,
375
+ detail: input.detail,
376
+ n: input.n,
377
+ images: storedImages,
378
+ ...input.refName === void 0 ? {} : { refName: input.refName }
379
+ }, ...await readIndex()];
380
+ const kept = merged.slice(0, 50);
381
+ for (const dropped of merged.slice(50)) await removeEntryFiles(dropped);
382
+ await writeIndex(kept);
383
+ return kept.map(toWire);
384
+ }
385
+ /** Remove one entry (and its image files). */
386
+ async function removeHistory(id) {
387
+ const previous = await readIndex();
388
+ const target = previous.find((entry) => entry.id === id);
389
+ if (target !== void 0) await removeEntryFiles(target);
390
+ const kept = previous.filter((entry) => entry.id !== id);
391
+ await writeIndex(kept);
392
+ return kept.map(toWire);
393
+ }
394
+ /** Remove every entry (and all image files). */
395
+ async function clearHistory() {
396
+ const previous = await readIndex();
397
+ for (const entry of previous) await removeEntryFiles(entry);
398
+ await writeIndex([]);
399
+ return [];
400
+ }
401
+ /** Read one stored image file by its (validated) file name. */
402
+ async function readHistoryImage(file) {
403
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return void 0;
404
+ try {
405
+ return {
406
+ data: await promises.readFile(path.join(IMAGES_DIR, file)),
407
+ mime: mimeOfFile(file)
408
+ };
409
+ } catch {
410
+ return;
411
+ }
412
+ }
413
+ //#endregion
414
+ //#region src/routes.ts
415
+ /** Cap on JSON request bodies (settings ops and generate payloads are small). */
416
+ const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
417
+ /** Cap on history append bodies (base64 result images can be much larger). */
418
+ const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024;
419
+ /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
420
+ function isLoopbackRequest(request) {
421
+ const address = request.socket.remoteAddress;
422
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
423
+ const host = request.headers.host;
424
+ if (typeof host !== "string") return false;
425
+ let hostUrl;
426
+ try {
427
+ hostUrl = new URL(`http://${host}`);
428
+ } catch {
429
+ return false;
430
+ }
431
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
432
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
433
+ const origin = request.headers.origin;
434
+ if (origin === void 0) return true;
435
+ try {
436
+ return new URL(origin).host === hostUrl.host;
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+ /** One JSON response. */
442
+ function writeJson(res, status, body) {
443
+ const payload = JSON.stringify(body);
444
+ res.writeHead(status, {
445
+ "content-type": "application/json; charset=utf-8",
446
+ "referrer-policy": "no-referrer"
447
+ });
448
+ res.end(payload);
449
+ }
450
+ /** Read a JSON request body (undefined when too large or unparseable). */
451
+ async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
452
+ const chunks = [];
453
+ let size = 0;
454
+ for await (const chunk of req) {
455
+ const buffer = chunk;
456
+ size += buffer.length;
457
+ if (size > maxBytes) return void 0;
458
+ chunks.push(buffer);
459
+ }
460
+ try {
461
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
462
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
463
+ } catch {
464
+ return;
465
+ }
466
+ }
467
+ /** Human-readable text from an unknown thrown value. */
468
+ function messageOf(error) {
469
+ return error instanceof Error ? error.message : String(error);
470
+ }
471
+ /** Validate a submitted history entry (images carry base64). */
472
+ function parseHistoryEntryInput(body) {
473
+ const raw = body.entry;
474
+ if (raw === null || typeof raw !== "object") return void 0;
475
+ const entry = raw;
476
+ if (typeof entry.id !== "string" || typeof entry.createdAt !== "number") return void 0;
477
+ if (entry.mode !== "text" && entry.mode !== "edit") return void 0;
478
+ if (typeof entry.model !== "string" || typeof entry.prompt !== "string") return void 0;
479
+ if (typeof entry.size !== "string" || typeof entry.quality !== "string" || typeof entry.detail !== "string") return void 0;
480
+ if (typeof entry.n !== "number") return void 0;
481
+ if (!Array.isArray(entry.images)) return void 0;
482
+ const images = [];
483
+ for (const item of entry.images) {
484
+ if (item === null || typeof item !== "object") return void 0;
485
+ const image = item;
486
+ if (typeof image.b64 !== "string" || typeof image.mime !== "string") return void 0;
487
+ images.push({
488
+ b64: image.b64,
489
+ mime: image.mime,
490
+ ...typeof image.revisedPrompt === "string" ? { revisedPrompt: image.revisedPrompt } : {}
491
+ });
492
+ }
493
+ return {
494
+ id: entry.id,
495
+ createdAt: entry.createdAt,
496
+ mode: entry.mode,
497
+ model: entry.model,
498
+ prompt: entry.prompt,
499
+ size: entry.size,
500
+ quality: entry.quality,
501
+ detail: entry.detail,
502
+ n: entry.n,
503
+ images,
504
+ ...typeof entry.refName === "string" ? { refName: entry.refName } : {}
505
+ };
506
+ }
507
+ /** Extract the image file name from a history-image request URL. */
508
+ function imageFileFrom(rawUrl, basePath) {
509
+ if (rawUrl === void 0) return void 0;
510
+ let pathname;
511
+ try {
512
+ pathname = new URL(rawUrl, "http://localhost").pathname;
513
+ } catch {
514
+ return;
515
+ }
516
+ if (!pathname.startsWith(`${basePath}/`)) return void 0;
517
+ return decodeURIComponent(pathname.slice(basePath.length + 1));
518
+ }
519
+ /** Project one settings descriptor onto the bridge wire view. */
520
+ function toView(descriptor) {
521
+ return {
522
+ ns: String(descriptor.ns),
523
+ schema: descriptor.schema,
524
+ value: descriptor.value,
525
+ ...descriptor.base === void 0 ? {} : { base: descriptor.base },
526
+ ...descriptor.user === void 0 ? {} : { user: descriptor.user },
527
+ ...descriptor.secrets === void 0 ? {} : { secrets: descriptor.secrets.map((secret) => ({
528
+ path: [...secret.path],
529
+ set: secret.set
530
+ })) },
531
+ revision: descriptor.revision
532
+ };
533
+ }
534
+ /** Map a seam failure onto the bridge refusal envelope. */
535
+ function failureOf(error) {
536
+ if (error instanceof SettingsConflictError) return {
537
+ ok: false,
538
+ code: "settings-conflict",
539
+ message: error.message
540
+ };
541
+ return {
542
+ ok: false,
543
+ code: "settings-rejected",
544
+ message: error instanceof Error ? error.message : String(error)
545
+ };
546
+ }
547
+ /**
548
+ * Build every /api/dsh-imagegen route.
549
+ * @param deps - settings seam + config resolver.
550
+ * @returns the route registrations.
551
+ */
552
+ function makeRoutes(deps) {
553
+ const guard = (req, res, method) => {
554
+ if (!isLoopbackRequest(req)) {
555
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
556
+ return false;
557
+ }
558
+ if (req.method !== method) {
559
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
560
+ return false;
561
+ }
562
+ return true;
563
+ };
564
+ return [
565
+ {
566
+ kind: "exact",
567
+ path: SETTINGS_API.describe,
568
+ handler: async (req, res) => {
569
+ if (!guard(req, res, "POST")) return;
570
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE);
571
+ writeJson(res, 200, {
572
+ ok: true,
573
+ value: {
574
+ namespaces: descriptor === void 0 ? [] : [toView(descriptor)],
575
+ writable: deps.settings.writable !== false
576
+ }
577
+ });
578
+ }
579
+ },
580
+ {
581
+ kind: "exact",
582
+ path: SETTINGS_API.mutate,
583
+ handler: async (req, res) => {
584
+ if (!guard(req, res, "POST")) return;
585
+ const body = await readJsonBody(req);
586
+ if (body === void 0) {
587
+ writeJson(res, 200, {
588
+ ok: false,
589
+ code: "settings-rejected",
590
+ message: "unreadable JSON body"
591
+ });
592
+ return;
593
+ }
594
+ const ns = typeof body.ns === "string" ? body.ns : "";
595
+ if (ns !== "dsh-imagegen" || !Array.isArray(body.ops)) {
596
+ writeJson(res, 200, {
597
+ ok: false,
598
+ code: "settings-rejected",
599
+ message: "malformed bridge settings request"
600
+ });
601
+ return;
602
+ }
603
+ const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : void 0;
604
+ try {
605
+ await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision);
606
+ } catch (error) {
607
+ writeJson(res, 200, failureOf(error));
608
+ return;
609
+ }
610
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns);
611
+ if (descriptor === void 0) {
612
+ writeJson(res, 200, {
613
+ ok: false,
614
+ code: "internal",
615
+ message: `settings namespace "${ns}" was disposed after the mutate`
616
+ });
617
+ return;
618
+ }
619
+ writeJson(res, 200, {
620
+ ok: true,
621
+ value: toView(descriptor)
622
+ });
623
+ }
624
+ },
625
+ {
626
+ kind: "exact",
627
+ path: GENERATE_API,
628
+ handler: async (req, res) => {
629
+ if (!guard(req, res, "POST")) return;
630
+ const body = await readJsonBody(req);
631
+ if (body === void 0) {
632
+ writeJson(res, 200, {
633
+ ok: false,
634
+ code: "bad-request",
635
+ message: "unreadable JSON body"
636
+ });
637
+ return;
638
+ }
639
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
640
+ if (prompt === "") {
641
+ writeJson(res, 200, {
642
+ ok: false,
643
+ code: "bad-request",
644
+ message: "prompt is required"
645
+ });
646
+ return;
647
+ }
648
+ if (prompt.length > 2e3) {
649
+ writeJson(res, 200, {
650
+ ok: false,
651
+ code: "bad-request",
652
+ message: "prompt exceeds 2000 characters"
653
+ });
654
+ return;
655
+ }
656
+ const request = {
657
+ mode: body.mode === "edit" ? "edit" : "text",
658
+ model: typeof body.model === "string" ? body.model : "gpt-image-2",
659
+ prompt,
660
+ size: typeof body.size === "string" ? body.size : "auto",
661
+ quality: typeof body.quality === "string" ? body.quality : "auto",
662
+ n: typeof body.n === "number" ? body.n : 1,
663
+ detail: typeof body.detail === "string" ? body.detail : "",
664
+ ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {}
665
+ };
666
+ try {
667
+ writeJson(res, 200, {
668
+ ok: true,
669
+ ...await generateImage(deps.resolve(), request)
670
+ });
671
+ } catch (error) {
672
+ const message = error instanceof Error ? error.message : String(error);
673
+ writeJson(res, 200, {
674
+ ok: false,
675
+ code: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "generate-failed",
676
+ message
677
+ });
678
+ }
679
+ }
680
+ },
681
+ {
682
+ kind: "exact",
683
+ path: HISTORY_API.list,
684
+ handler: async (req, res) => {
685
+ if (!guard(req, res, "POST")) return;
686
+ try {
687
+ writeJson(res, 200, {
688
+ ok: true,
689
+ entries: await listHistory()
690
+ });
691
+ } catch (error) {
692
+ writeJson(res, 200, {
693
+ ok: false,
694
+ code: "history-failed",
695
+ message: messageOf(error)
696
+ });
697
+ }
698
+ }
699
+ },
700
+ {
701
+ kind: "exact",
702
+ path: HISTORY_API.append,
703
+ handler: async (req, res) => {
704
+ if (!guard(req, res, "POST")) return;
705
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES);
706
+ if (body === void 0) {
707
+ writeJson(res, 200, {
708
+ ok: false,
709
+ code: "bad-request",
710
+ message: "unreadable JSON body"
711
+ });
712
+ return;
713
+ }
714
+ const entry = parseHistoryEntryInput(body);
715
+ if (entry === void 0) {
716
+ writeJson(res, 200, {
717
+ ok: false,
718
+ code: "bad-request",
719
+ message: "malformed history entry"
720
+ });
721
+ return;
722
+ }
723
+ try {
724
+ writeJson(res, 200, {
725
+ ok: true,
726
+ entries: await appendHistory(entry)
727
+ });
728
+ } catch (error) {
729
+ writeJson(res, 200, {
730
+ ok: false,
731
+ code: "history-failed",
732
+ message: messageOf(error)
733
+ });
734
+ }
735
+ }
736
+ },
737
+ {
738
+ kind: "exact",
739
+ path: HISTORY_API.remove,
740
+ handler: async (req, res) => {
741
+ if (!guard(req, res, "POST")) return;
742
+ const body = await readJsonBody(req);
743
+ const id = body !== void 0 && typeof body.id === "string" ? body.id : "";
744
+ if (id === "") {
745
+ writeJson(res, 200, {
746
+ ok: false,
747
+ code: "bad-request",
748
+ message: "history id is required"
749
+ });
750
+ return;
751
+ }
752
+ try {
753
+ writeJson(res, 200, {
754
+ ok: true,
755
+ entries: await removeHistory(id)
756
+ });
757
+ } catch (error) {
758
+ writeJson(res, 200, {
759
+ ok: false,
760
+ code: "history-failed",
761
+ message: messageOf(error)
762
+ });
763
+ }
764
+ }
765
+ },
766
+ {
767
+ kind: "exact",
768
+ path: HISTORY_API.clear,
769
+ handler: async (req, res) => {
770
+ if (!guard(req, res, "POST")) return;
771
+ try {
772
+ writeJson(res, 200, {
773
+ ok: true,
774
+ entries: await clearHistory()
775
+ });
776
+ } catch (error) {
777
+ writeJson(res, 200, {
778
+ ok: false,
779
+ code: "history-failed",
780
+ message: messageOf(error)
781
+ });
782
+ }
783
+ }
784
+ },
785
+ {
786
+ kind: "prefix",
787
+ path: HISTORY_API.image,
788
+ handler: async (req, res) => {
789
+ if (!isLoopbackRequest(req)) {
790
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
791
+ return;
792
+ }
793
+ if (req.method !== "GET") {
794
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
795
+ return;
796
+ }
797
+ const file = imageFileFrom(req.url, HISTORY_API.image);
798
+ if (file === void 0) {
799
+ writeJson(res, 404, { error: "not found" });
800
+ return;
801
+ }
802
+ const found = await readHistoryImage(file);
803
+ if (found === void 0) {
804
+ writeJson(res, 404, { error: "not found" });
805
+ return;
806
+ }
807
+ res.writeHead(200, {
808
+ "content-type": found.mime,
809
+ "content-length": found.data.length,
810
+ "cache-control": "private, max-age=3600"
811
+ });
812
+ res.end(found.data);
813
+ }
814
+ }
815
+ ];
816
+ }
817
+ //#endregion
818
+ //#region src/index.ts
819
+ /** Stable cordis plugin name. */
820
+ const name = "imagegen";
821
+ /** Services required before the surfaces can mount. */
822
+ const inject = ["webServer", "systemPrompt"];
823
+ /** The branded settings namespace of this plugin (the card edits it). */
824
+ const ImageGenSettingsNamespace = settingsNamespace(IMAGEGEN_SETTINGS_NAMESPACE);
825
+ const Config = z.object({
826
+ enabled: z.boolean().default(true),
827
+ announceToAgent: z.boolean().default(true),
828
+ apiUrl: z.string().default(""),
829
+ apiKey: z.string().role("secret").default("")
830
+ });
831
+ /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
832
+ const DEFAULT_ENABLED = true;
833
+ const DEFAULT_ANNOUNCE = true;
834
+ /** Order of the announcement section within the tool-guidance band. */
835
+ const SECTION_ORDER = 150;
836
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
837
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口;本地插件(源码位于 E:\\dsh-plugin,独立于 dsh-web-ui 插件全家桶)。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。";
838
+ /**
839
+ * Mount the settings section, routes, and announcement.
840
+ * @param ctx - host plugin context carrying webServer/systemPrompt.
841
+ * @param config - resolved plugin config (schema defaults applied by the loader).
842
+ */
843
+ function apply(ctx, config) {
844
+ let current = () => config ?? {};
845
+ const resolve = () => {
846
+ const value = current();
847
+ return {
848
+ enabled: value.enabled ?? DEFAULT_ENABLED,
849
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
850
+ apiUrl: value.apiUrl ?? "",
851
+ apiKey: value.apiKey ?? ""
852
+ };
853
+ };
854
+ ctx.inject(["settings"], (sctx) => {
855
+ const seam = sctx.get("settings");
856
+ sctx.effect(() => {
857
+ const disposers = makeRoutes({
858
+ settings: seam,
859
+ resolve: () => {
860
+ const value = resolve();
861
+ return {
862
+ apiUrl: value.apiUrl,
863
+ apiKey: value.apiKey
864
+ };
865
+ }
866
+ }).map((route) => ctx.webServer.register(route));
867
+ return () => {
868
+ for (const dispose of disposers) dispose();
869
+ };
870
+ }, "dsh-imagegen: routes");
871
+ });
872
+ let disposeSection;
873
+ const sync = () => {
874
+ if (disposeSection !== void 0) {
875
+ disposeSection();
876
+ disposeSection = void 0;
877
+ }
878
+ const value = resolve();
879
+ if (!value.enabled || !value.announceToAgent) return;
880
+ disposeSection = ctx.systemPrompt.section({
881
+ name: "plugin:dsh-imagegen",
882
+ order: SECTION_ORDER,
883
+ text: IMAGEGEN_GUIDANCE
884
+ });
885
+ };
886
+ installSettingsSection(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {
887
+ setSource: (source) => {
888
+ current = source;
889
+ sync();
890
+ },
891
+ onChange: sync
892
+ });
893
+ sync();
894
+ }
895
+ //#endregion
896
+ export { Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, generateImage, inject, makeRoutes, name };