@dickpy/dsh-imagegen 1.0.7 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { spawn } from "node:child_process";
8
9
  //#region src/protocol.ts
9
10
  /**
10
11
  * Wire contract shared by the host and client halves of dsh-imagegen: the
11
12
  * settings namespace, the route paths, and the generate payload/result shapes.
12
- * Pure types + constants safe for the client bundle to inline.
13
+ * Pure types + constants 鈥?safe for the client bundle to inline.
13
14
  */
14
15
  /** Settings namespace this plugin owns (host settings seam + bridge). */
15
16
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
16
17
  /** Published package version shared by the host updater and the client UI. */
17
- const PLUGIN_VERSION = "1.0.7";
18
+ const PLUGIN_VERSION = "1.0.19";
18
19
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
19
20
  const SETTINGS_API = {
20
21
  describe: "/api/dsh-imagegen/settings/describe",
@@ -40,6 +41,29 @@ const HISTORY_API = {
40
41
  clear: "/api/dsh-imagegen/history/clear",
41
42
  image: "/api/dsh-imagegen/history/image"
42
43
  };
44
+ /**
45
+ * Same-origin route family for the user-curated gallery (favorites). Entries
46
+ * reuse the history wire shape and persist under ~/.dsh/dsh-imagegen/gallery/;
47
+ * unlike history there is no size cap 鈥?the user adds images on purpose.
48
+ */
49
+ const GALLERY_API = {
50
+ list: "/api/dsh-imagegen/gallery/list",
51
+ append: "/api/dsh-imagegen/gallery/append",
52
+ remove: "/api/dsh-imagegen/gallery/remove",
53
+ clear: "/api/dsh-imagegen/gallery/clear",
54
+ image: "/api/dsh-imagegen/gallery/image"
55
+ };
56
+ /**
57
+ * Same-origin route family for the bundled prompt-template library
58
+ * (awesome-gpt-image-2 mirror). The case list ships inside the package and is
59
+ * served by the host; reference images are proxied through the `image` prefix
60
+ * route and cached on disk so repeated views never hit the network again.
61
+ */
62
+ const TEMPLATES_API = {
63
+ list: "/api/dsh-imagegen/templates/list",
64
+ refresh: "/api/dsh-imagegen/templates/refresh",
65
+ image: "/api/dsh-imagegen/templates/image"
66
+ };
43
67
  //#endregion
44
68
  //#region src/engine.ts
45
69
  /** A generation failure with a user-presentable message. */
@@ -64,6 +88,28 @@ const DALLE3_SIZES = /* @__PURE__ */ new Set([
64
88
  "1792x1024",
65
89
  "1024x1792"
66
90
  ]);
91
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
92
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
93
+ * and exposes its own aspect-ratio / response-format knobs instead of the
94
+ * OpenAI size/quality/detail passthrough. */
95
+ function isGrokImagine(model) {
96
+ return /^grok-imagine(?:-|$)/.test(model);
97
+ }
98
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
99
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
100
+ const OPENAI_SIZE_BY_RATIO = {
101
+ "1:1": "1024x1024",
102
+ "3:4": "1024x1536",
103
+ "4:3": "1536x1024",
104
+ "9:16": "1024x1792",
105
+ "2:3": "1024x1536",
106
+ "3:2": "1536x1024",
107
+ "16:9": "1792x1024",
108
+ "21:9": "1792x1024"
109
+ };
110
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
111
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
112
+ const GROK_ASPECT_ALIASES = { "21:9": "20:9" };
67
113
  /** Content-type extension hints for URL-fetched images. */
68
114
  function mimeOfExtension(path) {
69
115
  const match = /\.([a-z0-9]+)$/i.exec(path);
@@ -102,14 +148,25 @@ function clampCount(n) {
102
148
  * so the count is satisfied by parallel single-image requests instead. */
103
149
  function effectiveParams(request) {
104
150
  const model = request.model.trim() === "" ? "gpt-image-2" : request.model.trim();
105
- if (model === "dall-e-3") return {
151
+ if (model === "dall-e-3") {
152
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size];
153
+ return {
154
+ model,
155
+ size: pixel !== void 0 && DALLE3_SIZES.has(pixel) ? pixel : "1024x1024"
156
+ };
157
+ }
158
+ if (isGrokImagine(model)) return {
106
159
  model,
107
- size: DALLE3_SIZES.has(request.size) ? request.size : "1024x1024"
160
+ ...request.size !== "" && request.size !== "auto" ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size } : {},
161
+ ...request.quality !== "" && request.quality !== "auto" ? { resolution: request.quality === "4k" ? "2k" : request.quality } : {},
162
+ response_format: "b64_json"
108
163
  };
109
164
  return {
110
165
  model,
111
- ...request.size !== "" && request.size !== "auto" ? { size: request.size } : {},
112
- ...request.quality !== "" && request.quality !== "auto" ? { quality: request.quality } : {},
166
+ ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
167
+ ...request.quality === "1k" ? { quality: "low" } : {},
168
+ ...request.quality === "2k" ? { quality: "medium" } : {},
169
+ ...request.quality === "4k" ? { quality: "high" } : {},
113
170
  ...request.detail !== "" ? { detail: request.detail } : {}
114
171
  };
115
172
  }
@@ -174,14 +231,28 @@ async function requestOneImage(baseUrl, upstream, request, params) {
174
231
  throw new ImageGenError("参考图片数据无法解码", "edit-image-invalid");
175
232
  }
176
233
  if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
177
- const form = new FormData();
178
- form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$1(parsed.mime)}`);
179
- form.append("prompt", request.prompt);
180
- form.append("model", params.model);
181
- if (params.size !== void 0) form.append("size", params.size);
182
- if (params.quality !== void 0) form.append("quality", params.quality);
183
- if (params.detail !== void 0) form.append("detail", params.detail);
184
- body = form;
234
+ if (isGrokImagine(params.model)) {
235
+ headers["content-type"] = "application/json";
236
+ body = JSON.stringify({
237
+ model: params.model,
238
+ prompt: request.prompt,
239
+ image: {
240
+ url: request.image,
241
+ type: "image_url"
242
+ },
243
+ ...params.aspect_ratio !== void 0 ? { aspect_ratio: params.aspect_ratio } : {},
244
+ response_format: "b64_json"
245
+ });
246
+ } else {
247
+ const form = new FormData();
248
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
249
+ form.append("prompt", request.prompt);
250
+ form.append("model", params.model);
251
+ if (params.size !== void 0) form.append("size", params.size);
252
+ if (params.quality !== void 0) form.append("quality", params.quality);
253
+ if (params.detail !== void 0) form.append("detail", params.detail);
254
+ body = form;
255
+ }
185
256
  } else {
186
257
  headers["content-type"] = "application/json";
187
258
  body = JSON.stringify({
@@ -247,7 +318,7 @@ function upstreamMessage(payload, status) {
247
318
  return `上游接口拒绝请求(HTTP ${status})`;
248
319
  }
249
320
  /** File extension for a MIME type (multipart reference image). */
250
- function extensionOf$1(mime) {
321
+ function extensionOf$2(mime) {
251
322
  switch (mime.split(";")[0].trim()) {
252
323
  case "image/jpeg": return "jpg";
253
324
  case "image/webp": return "webp";
@@ -266,11 +337,193 @@ function extensionOf$1(mime) {
266
337
  *
267
338
  * Framework-free (node:fs only) so the route layer can drive it directly.
268
339
  */
340
+ const HISTORY_DIR$1 = path.join(homedir(), ".dsh", "dsh-imagegen");
341
+ const INDEX_PATH$1 = path.join(HISTORY_DIR$1, "index.json");
342
+ const IMAGES_DIR$1 = path.join(HISTORY_DIR$1, "images");
343
+ let pendingMutation$1 = Promise.resolve();
344
+ function mutateHistory(operation) {
345
+ const next = pendingMutation$1.then(operation, operation);
346
+ pendingMutation$1 = next.then(() => void 0, () => void 0);
347
+ return next;
348
+ }
349
+ /** File extension for a MIME type (image file names). */
350
+ function extensionOf$1(mime) {
351
+ switch (mime.split(";")[0].trim()) {
352
+ case "image/jpeg": return "jpg";
353
+ case "image/webp": return "webp";
354
+ case "image/gif": return "gif";
355
+ default: return "png";
356
+ }
357
+ }
358
+ /** MIME type for a stored image file name (image route responses). */
359
+ function mimeOfFile$2(file) {
360
+ switch (path.extname(file).toLowerCase()) {
361
+ case ".jpg":
362
+ case ".jpeg": return "image/jpeg";
363
+ case ".webp": return "image/webp";
364
+ case ".gif": return "image/gif";
365
+ default: return "image/png";
366
+ }
367
+ }
368
+ /** Sanitize an entry id for use as a file-name prefix. */
369
+ function safeId$1(id) {
370
+ const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
371
+ return cleaned === "" ? "entry" : cleaned;
372
+ }
373
+ /** Ensure the storage directories exist. */
374
+ async function ensureDirs$1() {
375
+ await promises.mkdir(IMAGES_DIR$1, { recursive: true });
376
+ }
377
+ /** Read the index, tolerating a missing/corrupt file. */
378
+ async function readIndex$1() {
379
+ try {
380
+ const raw = await promises.readFile(INDEX_PATH$1, "utf8");
381
+ const parsed = JSON.parse(raw);
382
+ if (parsed === null || typeof parsed !== "object") return [];
383
+ const entries = parsed.entries;
384
+ if (!Array.isArray(entries)) return [];
385
+ return entries.filter(isStoredEntry$1);
386
+ } catch {
387
+ return [];
388
+ }
389
+ }
390
+ /** Persist the index. */
391
+ async function writeIndex$1(entries) {
392
+ await ensureDirs$1();
393
+ const payload = { entries };
394
+ const tmp = `${INDEX_PATH$1}.tmp-${process.pid}`;
395
+ await promises.writeFile(tmp, JSON.stringify(payload), "utf8");
396
+ await promises.rename(tmp, INDEX_PATH$1);
397
+ }
398
+ /** Structural guard for a stored entry. */
399
+ function isStoredEntry$1(value) {
400
+ if (value === null || typeof value !== "object") return false;
401
+ const entry = value;
402
+ 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) => {
403
+ if (image === null || typeof image !== "object") return false;
404
+ const record = image;
405
+ return typeof record.file === "string" && typeof record.mime === "string";
406
+ });
407
+ }
408
+ /** Remove one entry's image files (best effort). */
409
+ async function removeEntryFiles$1(entry) {
410
+ for (const image of entry.images) try {
411
+ await promises.rm(path.join(IMAGES_DIR$1, image.file), { force: true });
412
+ } catch {}
413
+ }
414
+ /** Project a stored entry onto the wire shape (image URLs). */
415
+ function toWire$1(entry) {
416
+ return {
417
+ id: entry.id,
418
+ createdAt: entry.createdAt,
419
+ mode: entry.mode,
420
+ model: entry.model,
421
+ prompt: entry.prompt,
422
+ size: entry.size,
423
+ quality: entry.quality,
424
+ detail: entry.detail,
425
+ n: entry.n,
426
+ images: entry.images.map((image) => ({
427
+ url: `/api/dsh-imagegen/history/image/${image.file}`,
428
+ mime: image.mime,
429
+ ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
430
+ })),
431
+ ...entry.refName === void 0 ? {} : { refName: entry.refName }
432
+ };
433
+ }
434
+ /** List the persisted history, newest first, as wire entries. */
435
+ async function listHistory() {
436
+ return (await readIndex$1()).map(toWire$1);
437
+ }
438
+ /** Append one generation, evicting the oldest beyond HISTORY_MAX. */
439
+ async function appendHistory(input) {
440
+ return mutateHistory(async () => {
441
+ await ensureDirs$1();
442
+ const prefix = safeId$1(input.id);
443
+ const storedImages = [];
444
+ try {
445
+ for (let index = 0; index < input.images.length; index++) {
446
+ const image = input.images[index];
447
+ const file = `${prefix}-${index}.${extensionOf$1(image.mime)}`;
448
+ await promises.writeFile(path.join(IMAGES_DIR$1, file), Buffer.from(image.b64, "base64"));
449
+ storedImages.push({
450
+ file,
451
+ mime: image.mime,
452
+ ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
453
+ });
454
+ }
455
+ } catch (error) {
456
+ await removeEntryFiles$1({ images: storedImages });
457
+ throw error;
458
+ }
459
+ const merged = [{
460
+ id: input.id,
461
+ createdAt: input.createdAt,
462
+ mode: input.mode,
463
+ model: input.model,
464
+ prompt: input.prompt,
465
+ size: input.size,
466
+ quality: input.quality,
467
+ detail: input.detail,
468
+ n: input.n,
469
+ images: storedImages,
470
+ ...input.refName === void 0 ? {} : { refName: input.refName }
471
+ }, ...await readIndex$1()];
472
+ const kept = merged.slice(0, 50);
473
+ for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
474
+ await writeIndex$1(kept);
475
+ return kept.map(toWire$1);
476
+ });
477
+ }
478
+ /** Remove one entry (and its image files). */
479
+ async function removeHistory(id) {
480
+ return mutateHistory(async () => {
481
+ const previous = await readIndex$1();
482
+ const target = previous.find((entry) => entry.id === id);
483
+ if (target !== void 0) await removeEntryFiles$1(target);
484
+ const kept = previous.filter((entry) => entry.id !== id);
485
+ await writeIndex$1(kept);
486
+ return kept.map(toWire$1);
487
+ });
488
+ }
489
+ /** Remove every entry (and all image files). */
490
+ async function clearHistory() {
491
+ return mutateHistory(async () => {
492
+ const previous = await readIndex$1();
493
+ for (const entry of previous) await removeEntryFiles$1(entry);
494
+ await writeIndex$1([]);
495
+ return [];
496
+ });
497
+ }
498
+ /** Read one stored image file by its (validated) file name. */
499
+ async function readHistoryImage(file) {
500
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return void 0;
501
+ try {
502
+ return {
503
+ data: await promises.readFile(path.join(IMAGES_DIR$1, file)),
504
+ mime: mimeOfFile$2(file)
505
+ };
506
+ } catch {
507
+ return;
508
+ }
509
+ }
510
+ //#endregion
511
+ //#region src/gallery-store.ts
512
+ /**
513
+ * Host-persisted gallery (user-curated favorites): mirrors the history store
514
+ * (image files + an index.json under ~/.dsh/dsh-imagegen/gallery/) but with no
515
+ * size cap — every entry is an explicit user choice. Appends are deduplicated
516
+ * by image content so adding the same generated image twice is a no-op.
517
+ *
518
+ * Framework-free (node:fs + node:crypto only) so the route layer can drive it
519
+ * directly.
520
+ */
269
521
  const HISTORY_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
270
- const INDEX_PATH = path.join(HISTORY_DIR, "index.json");
271
- const IMAGES_DIR = path.join(HISTORY_DIR, "images");
522
+ const GALLERY_DIR = path.join(HISTORY_DIR, "gallery");
523
+ const INDEX_PATH = path.join(GALLERY_DIR, "index.json");
524
+ const IMAGES_DIR = path.join(GALLERY_DIR, "images");
272
525
  let pendingMutation = Promise.resolve();
273
- function mutateHistory(operation) {
526
+ function mutateGallery(operation) {
274
527
  const next = pendingMutation.then(operation, operation);
275
528
  pendingMutation = next.then(() => void 0, () => void 0);
276
529
  return next;
@@ -285,7 +538,7 @@ function extensionOf(mime) {
285
538
  }
286
539
  }
287
540
  /** MIME type for a stored image file name (image route responses). */
288
- function mimeOfFile(file) {
541
+ function mimeOfFile$1(file) {
289
542
  switch (path.extname(file).toLowerCase()) {
290
543
  case ".jpg":
291
544
  case ".jpeg": return "image/jpeg";
@@ -299,6 +552,12 @@ function safeId(id) {
299
552
  const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
300
553
  return cleaned === "" ? "entry" : cleaned;
301
554
  }
555
+ /** Short content fingerprint of the entry's first image. */
556
+ function fingerprint(input) {
557
+ const first = input.images[0];
558
+ if (first === void 0) return void 0;
559
+ return createHash("sha1").update(first.b64).digest("hex");
560
+ }
302
561
  /** Ensure the storage directories exist. */
303
562
  async function ensureDirs() {
304
563
  await promises.mkdir(IMAGES_DIR, { recursive: true });
@@ -353,21 +612,31 @@ function toWire(entry) {
353
612
  detail: entry.detail,
354
613
  n: entry.n,
355
614
  images: entry.images.map((image) => ({
356
- url: `/api/dsh-imagegen/history/image/${image.file}`,
615
+ url: `/api/dsh-imagegen/gallery/image/${image.file}`,
357
616
  mime: image.mime,
358
617
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
359
618
  })),
360
619
  ...entry.refName === void 0 ? {} : { refName: entry.refName }
361
620
  };
362
621
  }
363
- /** List the persisted history, newest first, as wire entries. */
364
- async function listHistory() {
622
+ /** List the persisted gallery, newest first, as wire entries. */
623
+ async function listGallery() {
365
624
  return (await readIndex()).map(toWire);
366
625
  }
367
- /** Append one generation, evicting the oldest beyond HISTORY_MAX. */
368
- async function appendHistory(input) {
369
- return mutateHistory(async () => {
626
+ /** Append one image to the gallery. Deduplicates by first-image content —
627
+ * appending an image already in the gallery returns `added: false` with the
628
+ * list unchanged. No size cap: every entry is an explicit user choice. */
629
+ async function appendGallery(input) {
630
+ return mutateGallery(async () => {
370
631
  await ensureDirs();
632
+ const hash = fingerprint(input);
633
+ if (hash !== void 0) {
634
+ const existing = await readIndex();
635
+ if (existing.some((entry) => entry.hash === hash)) return {
636
+ entries: existing.map(toWire),
637
+ added: false
638
+ };
639
+ }
371
640
  const prefix = safeId(input.id);
372
641
  const storedImages = [];
373
642
  try {
@@ -396,17 +665,19 @@ async function appendHistory(input) {
396
665
  detail: input.detail,
397
666
  n: input.n,
398
667
  images: storedImages,
668
+ ...hash === void 0 ? {} : { hash },
399
669
  ...input.refName === void 0 ? {} : { refName: input.refName }
400
670
  }, ...await readIndex()];
401
- const kept = merged.slice(0, 50);
402
- for (const dropped of merged.slice(50)) await removeEntryFiles(dropped);
403
- await writeIndex(kept);
404
- return kept.map(toWire);
671
+ await writeIndex(merged);
672
+ return {
673
+ entries: merged.map(toWire),
674
+ added: true
675
+ };
405
676
  });
406
677
  }
407
678
  /** Remove one entry (and its image files). */
408
- async function removeHistory(id) {
409
- return mutateHistory(async () => {
679
+ async function removeGallery(id) {
680
+ return mutateGallery(async () => {
410
681
  const previous = await readIndex();
411
682
  const target = previous.find((entry) => entry.id === id);
412
683
  if (target !== void 0) await removeEntryFiles(target);
@@ -416,8 +687,8 @@ async function removeHistory(id) {
416
687
  });
417
688
  }
418
689
  /** Remove every entry (and all image files). */
419
- async function clearHistory() {
420
- return mutateHistory(async () => {
690
+ async function clearGallery() {
691
+ return mutateGallery(async () => {
421
692
  const previous = await readIndex();
422
693
  for (const entry of previous) await removeEntryFiles(entry);
423
694
  await writeIndex([]);
@@ -425,18 +696,287 @@ async function clearHistory() {
425
696
  });
426
697
  }
427
698
  /** Read one stored image file by its (validated) file name. */
428
- async function readHistoryImage(file) {
699
+ async function readGalleryImage(file) {
429
700
  if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return void 0;
430
701
  try {
431
702
  return {
432
703
  data: await promises.readFile(path.join(IMAGES_DIR, file)),
433
- mime: mimeOfFile(file)
704
+ mime: mimeOfFile$1(file)
434
705
  };
435
706
  } catch {
436
707
  return;
437
708
  }
438
709
  }
439
710
  //#endregion
711
+ //#region src/templates-store.ts
712
+ /**
713
+ * Prompt-template library store (awesome-gpt-image-2 mirror).
714
+ *
715
+ * The case list ships as a bundled snapshot (src/templates/cases.json, inside
716
+ * the npm package) so the library works offline out of the box; a successful
717
+ * manual refresh writes a runtime copy under ~/.dsh/dsh-imagegen/templates/
718
+ * which then takes precedence. Reference images are not bundled (441 files,
719
+ * ≈100 MB) — they are fetched from the vibeui.top mirror on demand, cached on
720
+ * disk under ~/.dsh/dsh-imagegen/template-images/, and served from there on
721
+ * every later view.
722
+ *
723
+ * Framework-free (node:fs only) so the route layer and tests can drive it
724
+ * directly.
725
+ */
726
+ /** Upstream mirror the library refreshes from (vibeui.top static mirror). */
727
+ const SOURCE_URL = "https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json";
728
+ /** Remote image directory (file names come from the case list). */
729
+ const IMAGE_BASE_URL = "https://vibeui.top/extra/awesome-gpt-image-2/data/images/";
730
+ /** Category label map mirrored from vibeui.top's site.js (zh display names). */
731
+ const CATEGORY_ZH = {
732
+ "Architecture & Spaces": "建筑与空间",
733
+ "Brand & Logos": "品牌与标志",
734
+ "Characters & People": "人物与角色",
735
+ "Charts & Infographics": "图表与信息可视化",
736
+ "Documents & Publishing": "文档与出版物",
737
+ "History & Classical Themes": "历史与古风题材",
738
+ "Illustration & Art": "插画与艺术",
739
+ "Other Use Cases": "其他应用场景",
740
+ "Photography & Realism": "摄影与写实",
741
+ "Posters & Typography": "海报与排版",
742
+ "Products & E-commerce": "商品与电商",
743
+ "Scenes & Storytelling": "场景与叙事",
744
+ "UI & Interfaces": "UI 与界面",
745
+ "Portraits & Fashion": "人像与时尚",
746
+ "Celebrities & Sports": "名人与运动",
747
+ "Characters & IP": "角色与 IP",
748
+ "Food & Beverage": "美食与饮品",
749
+ "Brand & Icons": "品牌与图标",
750
+ "Social Media & Stickers": "社媒与表情包",
751
+ "Infographics & Diagrams": "信息图与图解",
752
+ "UI & App Screens": "UI 与应用界面",
753
+ "Architecture & Interiors": "建筑与室内",
754
+ "Cinematic & Storytelling": "影视与叙事",
755
+ "Illustration & Comics": "插画与漫画",
756
+ "Historical & Fantasy": "历史与幻想",
757
+ "Animals & Nature": "动物与自然",
758
+ "Other Creative Uses": "其他创意用途"
759
+ };
760
+ const DATA_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
761
+ const REFRESHED_CASES_PATH = path.join(DATA_DIR, "templates", "cases.json");
762
+ const IMAGE_CACHE_DIR = path.join(DATA_DIR, "template-images");
763
+ /**
764
+ * Bundled snapshot path. The host bundle emits to lib/index.js while this
765
+ * source file lives at src/templates-store.ts — both exactly one level below
766
+ * the package root — so `../src/templates/cases.json` resolves to the shipped
767
+ * snapshot in development and in the installed package alike.
768
+ */
769
+ const BUNDLED_CASES_PATH = fileURLToPath(new URL("../src/templates/cases.json", import.meta.url));
770
+ /** Budget for one upstream fetch (list refresh or one image). */
771
+ const FETCH_TIMEOUT_MS = 6e4;
772
+ /** Refuse to cache implausibly large "images". */
773
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
774
+ /** Strict reference-image file names this store writes and serves. */
775
+ const IMAGE_FILE_PATTERN = /^case\d+\.(jpg|jpeg|png|webp|gif)$/i;
776
+ /** In-memory memo of the active list (avoid re-parsing on every request). */
777
+ let memo;
778
+ /** Per-file in-flight downloads, so a gallery scroll never double-fetches. */
779
+ const inflightImages = /* @__PURE__ */ new Map();
780
+ /** Validate + normalize one raw upstream case; undefined when unusable. */
781
+ function normalizeCase(raw) {
782
+ if (raw === null || typeof raw !== "object") return void 0;
783
+ const record = raw;
784
+ const id = Number(record.id);
785
+ const title = typeof record.title === "string" ? record.title.trim() : "";
786
+ const prompt = typeof record.prompt === "string" ? record.prompt.trim() : "";
787
+ if (!Number.isInteger(id) || title === "" || prompt === "") return void 0;
788
+ const category = typeof record.category === "string" ? record.category : "";
789
+ const image = imageFileOf(typeof record.image === "string" ? record.image : "");
790
+ return {
791
+ id,
792
+ title,
793
+ prompt,
794
+ category,
795
+ categoryZh: CATEGORY_ZH[category] ?? category,
796
+ styles: Array.isArray(record.styles) ? record.styles.map(String) : [],
797
+ scenes: Array.isArray(record.scenes) ? record.scenes.map(String) : [],
798
+ sourceLabel: typeof record.sourceLabel === "string" ? record.sourceLabel : "",
799
+ sourceUrl: typeof record.sourceUrl === "string" ? record.sourceUrl : "",
800
+ githubUrl: typeof record.githubUrl === "string" ? record.githubUrl : "",
801
+ image,
802
+ featured: record.featured === true
803
+ };
804
+ }
805
+ /** Extract the bare file name from an upstream image path. */
806
+ function imageFileOf(value) {
807
+ const name = value.replace(/^\/+/, "").split("/").pop() ?? "";
808
+ return IMAGE_FILE_PATTERN.test(name) ? name : "";
809
+ }
810
+ /** Parse a snapshot payload (bundled, refreshed cache, or fresh download). */
811
+ function parseSnapshot(payload) {
812
+ if (payload === null || typeof payload !== "object") return void 0;
813
+ const snapshot = payload;
814
+ if (!Array.isArray(snapshot.cases)) return void 0;
815
+ const cases = [];
816
+ for (const raw of snapshot.cases) {
817
+ const normalized = normalizeCase(raw);
818
+ if (normalized !== void 0) cases.push(normalized);
819
+ }
820
+ if (cases.length === 0) return void 0;
821
+ cases.sort((a, b) => b.id - a.id);
822
+ return {
823
+ cases,
824
+ repository: typeof snapshot.repository === "string" && snapshot.repository !== "" ? snapshot.repository : "freestylefly/awesome-gpt-image-2",
825
+ fetchedAt: typeof snapshot.fetchedAt === "string" && snapshot.fetchedAt !== "" ? snapshot.fetchedAt : ""
826
+ };
827
+ }
828
+ /** Read + parse a snapshot file; undefined when missing/corrupt. */
829
+ async function readSnapshotFile(file) {
830
+ try {
831
+ return parseSnapshot(JSON.parse(await promises.readFile(file, "utf8")));
832
+ } catch {
833
+ return;
834
+ }
835
+ }
836
+ /**
837
+ * The active template list: the refreshed runtime copy wins, the bundled
838
+ * snapshot is the always-available fallback. Memoized; a successful refresh
839
+ * replaces the memo.
840
+ */
841
+ async function listTemplates() {
842
+ if (memo !== void 0) return memo;
843
+ const refreshed = await readSnapshotFile(REFRESHED_CASES_PATH);
844
+ if (refreshed !== void 0) {
845
+ memo = {
846
+ ...refreshed,
847
+ total: refreshed.cases.length,
848
+ origin: "refreshed"
849
+ };
850
+ return memo;
851
+ }
852
+ const bundled = await readSnapshotFile(BUNDLED_CASES_PATH);
853
+ if (bundled !== void 0) {
854
+ memo = {
855
+ ...bundled,
856
+ total: bundled.cases.length,
857
+ origin: "bundled"
858
+ };
859
+ return memo;
860
+ }
861
+ memo = {
862
+ cases: [],
863
+ total: 0,
864
+ origin: "bundled",
865
+ repository: "freestylefly/awesome-gpt-image-2",
866
+ fetchedAt: ""
867
+ };
868
+ return memo;
869
+ }
870
+ /**
871
+ * Re-download the case list from the upstream mirror and persist it as the
872
+ * runtime copy. Throws with a user-presentable message on failure; the
873
+ * previous list (refreshed or bundled) stays active.
874
+ */
875
+ async function refreshTemplates() {
876
+ let response;
877
+ try {
878
+ response = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
879
+ } catch (error) {
880
+ throw new Error(`无法连接模板库源站:${error instanceof Error ? error.message : String(error)}`);
881
+ }
882
+ if (!response.ok) throw new Error(`模板库源站拒绝请求(HTTP ${response.status})`);
883
+ let payload;
884
+ try {
885
+ payload = await response.json();
886
+ } catch {
887
+ throw new Error("模板库源站返回了非 JSON 响应");
888
+ }
889
+ const parsed = parseSnapshot(payload);
890
+ if (parsed === void 0) throw new Error("模板库源站数据格式无效");
891
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
892
+ const snapshot = {
893
+ repository: parsed.repository,
894
+ sourceUrl: SOURCE_URL,
895
+ fetchedAt,
896
+ totalCases: parsed.cases.length,
897
+ cases: parsed.cases
898
+ };
899
+ await promises.mkdir(path.dirname(REFRESHED_CASES_PATH), { recursive: true });
900
+ const tmp = `${REFRESHED_CASES_PATH}.tmp-${process.pid}`;
901
+ await promises.writeFile(tmp, JSON.stringify(snapshot), "utf8");
902
+ await promises.rename(tmp, REFRESHED_CASES_PATH);
903
+ memo = {
904
+ cases: parsed.cases,
905
+ total: parsed.cases.length,
906
+ origin: "refreshed",
907
+ repository: parsed.repository,
908
+ fetchedAt
909
+ };
910
+ return {
911
+ total: parsed.cases.length,
912
+ fetchedAt
913
+ };
914
+ }
915
+ /** MIME type for a cached reference-image file name. */
916
+ function mimeOfFile(file) {
917
+ switch (path.extname(file).toLowerCase()) {
918
+ case ".jpg":
919
+ case ".jpeg": return "image/jpeg";
920
+ case ".webp": return "image/webp";
921
+ case ".gif": return "image/gif";
922
+ default: return "image/png";
923
+ }
924
+ }
925
+ /** Download one reference image into the disk cache; undefined on failure. */
926
+ async function fetchTemplateImage(file) {
927
+ let response;
928
+ try {
929
+ response = await fetch(`${IMAGE_BASE_URL}${encodeURIComponent(file)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
930
+ } catch {
931
+ return;
932
+ }
933
+ if (!response.ok) return void 0;
934
+ if (Number(response.headers.get("content-length") ?? 0) > MAX_IMAGE_BYTES) return void 0;
935
+ const data = Buffer.from(await response.arrayBuffer());
936
+ if (data.byteLength === 0 || data.byteLength > MAX_IMAGE_BYTES) return void 0;
937
+ const mime = mimeOfFile(file);
938
+ try {
939
+ await promises.mkdir(IMAGE_CACHE_DIR, { recursive: true });
940
+ const tmp = path.join(IMAGE_CACHE_DIR, `${file}.tmp-${process.pid}`);
941
+ await promises.writeFile(tmp, data);
942
+ await promises.rename(tmp, path.join(IMAGE_CACHE_DIR, file));
943
+ } catch {}
944
+ return {
945
+ data,
946
+ mime
947
+ };
948
+ }
949
+ /**
950
+ * Read one reference image for the template library. Cache hit → disk; miss →
951
+ * fetch from the upstream mirror, cache, and serve. Only file names present in
952
+ * the active case list are served, so the route can never act as an open
953
+ * proxy. Undefined when the name is unknown or the fetch failed.
954
+ */
955
+ async function readTemplateImage(file) {
956
+ if (!IMAGE_FILE_PATTERN.test(file) || file.includes("..")) return void 0;
957
+ if (!(await listTemplates()).cases.some((entry) => entry.image === file)) return void 0;
958
+ const cached = path.join(IMAGE_CACHE_DIR, file);
959
+ try {
960
+ return {
961
+ data: await promises.readFile(cached),
962
+ mime: mimeOfFile(file)
963
+ };
964
+ } catch {}
965
+ const inflight = inflightImages.get(file);
966
+ if (inflight !== void 0) return inflight;
967
+ const pending = fetchTemplateImage(file);
968
+ inflightImages.set(file, pending);
969
+ try {
970
+ return await pending;
971
+ } finally {
972
+ inflightImages.delete(file);
973
+ }
974
+ }
975
+ /** Drop the in-memory list memo (tests). */
976
+ function clearTemplateMemo() {
977
+ memo = void 0;
978
+ }
979
+ //#endregion
440
980
  //#region src/updater.ts
441
981
  /** GitHub Release discovery and explicit, user-triggered plugin updates. */
442
982
  /** Keep this in sync with package.json for each published release. */
@@ -683,6 +1223,18 @@ function makeRoutes(deps) {
683
1223
  clear: clearHistory,
684
1224
  readImage: readHistoryImage
685
1225
  };
1226
+ const gallery = deps.gallery ?? {
1227
+ list: listGallery,
1228
+ append: appendGallery,
1229
+ remove: removeGallery,
1230
+ clear: clearGallery,
1231
+ readImage: readGalleryImage
1232
+ };
1233
+ const templates = deps.templates ?? {
1234
+ list: listTemplates,
1235
+ refresh: refreshTemplates,
1236
+ readImage: readTemplateImage
1237
+ };
686
1238
  const guard = (req, res, method) => {
687
1239
  if (!isLoopbackRequest(req)) {
688
1240
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -1028,6 +1580,213 @@ function makeRoutes(deps) {
1028
1580
  });
1029
1581
  res.end(found.data);
1030
1582
  }
1583
+ },
1584
+ {
1585
+ kind: "exact",
1586
+ path: GALLERY_API.list,
1587
+ handler: async (req, res) => {
1588
+ if (!guard(req, res, "POST")) return;
1589
+ try {
1590
+ writeJson(res, 200, {
1591
+ ok: true,
1592
+ entries: await gallery.list()
1593
+ });
1594
+ } catch (error) {
1595
+ writeJson(res, 200, {
1596
+ ok: false,
1597
+ code: "gallery-failed",
1598
+ message: messageOf(error)
1599
+ });
1600
+ }
1601
+ }
1602
+ },
1603
+ {
1604
+ kind: "exact",
1605
+ path: GALLERY_API.append,
1606
+ handler: async (req, res) => {
1607
+ if (!guard(req, res, "POST")) return;
1608
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES);
1609
+ if (body === void 0) {
1610
+ writeJson(res, 200, {
1611
+ ok: false,
1612
+ code: "bad-request",
1613
+ message: "unreadable JSON body"
1614
+ });
1615
+ return;
1616
+ }
1617
+ const entry = parseHistoryEntryInput(body);
1618
+ if (entry === void 0) {
1619
+ writeJson(res, 200, {
1620
+ ok: false,
1621
+ code: "bad-request",
1622
+ message: "malformed gallery entry"
1623
+ });
1624
+ return;
1625
+ }
1626
+ try {
1627
+ const result = await gallery.append({
1628
+ ...entry,
1629
+ id: randomUUID()
1630
+ });
1631
+ writeJson(res, 200, {
1632
+ ok: true,
1633
+ entries: result.entries,
1634
+ added: result.added
1635
+ });
1636
+ } catch (error) {
1637
+ writeJson(res, 200, {
1638
+ ok: false,
1639
+ code: "gallery-failed",
1640
+ message: messageOf(error)
1641
+ });
1642
+ }
1643
+ }
1644
+ },
1645
+ {
1646
+ kind: "exact",
1647
+ path: GALLERY_API.remove,
1648
+ handler: async (req, res) => {
1649
+ if (!guard(req, res, "POST")) return;
1650
+ const body = await readJsonBody(req);
1651
+ const id = body !== void 0 && typeof body.id === "string" ? body.id : "";
1652
+ if (id === "") {
1653
+ writeJson(res, 200, {
1654
+ ok: false,
1655
+ code: "bad-request",
1656
+ message: "gallery id is required"
1657
+ });
1658
+ return;
1659
+ }
1660
+ try {
1661
+ writeJson(res, 200, {
1662
+ ok: true,
1663
+ entries: await gallery.remove(id)
1664
+ });
1665
+ } catch (error) {
1666
+ writeJson(res, 200, {
1667
+ ok: false,
1668
+ code: "gallery-failed",
1669
+ message: messageOf(error)
1670
+ });
1671
+ }
1672
+ }
1673
+ },
1674
+ {
1675
+ kind: "exact",
1676
+ path: GALLERY_API.clear,
1677
+ handler: async (req, res) => {
1678
+ if (!guard(req, res, "POST")) return;
1679
+ try {
1680
+ writeJson(res, 200, {
1681
+ ok: true,
1682
+ entries: await gallery.clear()
1683
+ });
1684
+ } catch (error) {
1685
+ writeJson(res, 200, {
1686
+ ok: false,
1687
+ code: "gallery-failed",
1688
+ message: messageOf(error)
1689
+ });
1690
+ }
1691
+ }
1692
+ },
1693
+ {
1694
+ kind: "prefix",
1695
+ path: GALLERY_API.image,
1696
+ handler: async (req, res) => {
1697
+ if (!isLoopbackRequest(req)) {
1698
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1699
+ return;
1700
+ }
1701
+ if (req.method !== "GET") {
1702
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1703
+ return;
1704
+ }
1705
+ const file = imageFileFrom(req.url, GALLERY_API.image);
1706
+ if (file === void 0) {
1707
+ writeJson(res, 404, { error: "not found" });
1708
+ return;
1709
+ }
1710
+ const found = await gallery.readImage(file);
1711
+ if (found === void 0) {
1712
+ writeJson(res, 404, { error: "not found" });
1713
+ return;
1714
+ }
1715
+ res.writeHead(200, {
1716
+ "content-type": found.mime,
1717
+ "content-length": found.data.length,
1718
+ "cache-control": "private, max-age=3600"
1719
+ });
1720
+ res.end(found.data);
1721
+ }
1722
+ },
1723
+ {
1724
+ kind: "exact",
1725
+ path: TEMPLATES_API.list,
1726
+ handler: async (req, res) => {
1727
+ if (!guard(req, res, "POST")) return;
1728
+ try {
1729
+ writeJson(res, 200, {
1730
+ ok: true,
1731
+ ...await templates.list()
1732
+ });
1733
+ } catch (error) {
1734
+ writeJson(res, 200, {
1735
+ ok: false,
1736
+ code: "templates-failed",
1737
+ message: messageOf(error)
1738
+ });
1739
+ }
1740
+ }
1741
+ },
1742
+ {
1743
+ kind: "exact",
1744
+ path: TEMPLATES_API.refresh,
1745
+ handler: async (req, res) => {
1746
+ if (!guard(req, res, "POST")) return;
1747
+ try {
1748
+ writeJson(res, 200, {
1749
+ ok: true,
1750
+ ...await templates.refresh()
1751
+ });
1752
+ } catch (error) {
1753
+ writeJson(res, 200, {
1754
+ ok: false,
1755
+ code: "templates-refresh-failed",
1756
+ message: messageOf(error)
1757
+ });
1758
+ }
1759
+ }
1760
+ },
1761
+ {
1762
+ kind: "prefix",
1763
+ path: TEMPLATES_API.image,
1764
+ handler: async (req, res) => {
1765
+ if (!isLoopbackRequest(req)) {
1766
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1767
+ return;
1768
+ }
1769
+ if (req.method !== "GET") {
1770
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1771
+ return;
1772
+ }
1773
+ const file = imageFileFrom(req.url, TEMPLATES_API.image);
1774
+ if (file === void 0) {
1775
+ writeJson(res, 404, { error: "not found" });
1776
+ return;
1777
+ }
1778
+ const found = await templates.readImage(file);
1779
+ if (found === void 0) {
1780
+ writeJson(res, 404, { error: "not found" });
1781
+ return;
1782
+ }
1783
+ res.writeHead(200, {
1784
+ "content-type": found.mime,
1785
+ "content-length": found.data.length,
1786
+ "cache-control": "private, max-age=86400"
1787
+ });
1788
+ res.end(found.data);
1789
+ }
1031
1790
  }
1032
1791
  ];
1033
1792
  }
@@ -1051,7 +1810,7 @@ const DEFAULT_ANNOUNCE = true;
1051
1810
  /** Order of the announcement section within the tool-guidance band. */
1052
1811
  const SECTION_ORDER = 150;
1053
1812
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
1054
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。";
1813
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2 / grok-imagine-image),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok 模型按官方 JSON image_url 协议发送);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载;可一键把满意的图片加入「画廊」(工作台顶部 文生图 / 图生图 / 画廊 三个标签页,画廊在右侧展示,收藏持久化在本地 ~/.dsh/dsh-imagegen/gallery/)。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条 gpt-image-2 提示词案例(含中文标题、分类、参考图,可搜索/按分类筛选),参考图经宿主代理按需缓存到本地;用户可一键把模板提示词填入提示词框再生成。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / grok-imagine-image / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
1055
1814
  /**
1056
1815
  * Mount the settings section, routes, and announcement.
1057
1816
  * @param ctx - host plugin context carrying webServer/systemPrompt.
@@ -1110,4 +1869,4 @@ function apply(ctx, config) {
1110
1869
  sync();
1111
1870
  }
1112
1871
  //#endregion
1113
- export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, checkForUpdate, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, makeRoutes, name, profileFromProcess };
1872
+ export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, appendGallery, apply, checkForUpdate, clearGallery, clearTemplateMemo, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, listGallery, listTemplates, makeRoutes, name, profileFromProcess, readGalleryImage, readTemplateImage, refreshTemplates, removeGallery };