@dickpy/dsh-imagegen 1.5.1 → 1.5.3

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
@@ -40,7 +40,7 @@ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
40
40
  /** Settings namespace this plugin owns (host settings seam + bridge). */
41
41
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
42
42
  /** Published package version shared by the host updater and the client UI. */
43
- const PLUGIN_VERSION = "1.5.1";
43
+ const PLUGIN_VERSION = "1.5.3";
44
44
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
45
45
  const SETTINGS_API = {
46
46
  describe: "/api/dsh-imagegen/settings/describe",
@@ -106,16 +106,48 @@ const GALLERY_API = {
106
106
  image: "/api/dsh-imagegen/gallery/image"
107
107
  };
108
108
  /**
109
- * Same-origin route family for the bundled prompt-template library
110
- * (awesome-gpt-image-2 mirror). The case list ships inside the package and is
111
- * served by the host; reference images are proxied through the `image` prefix
112
- * route and cached on disk so repeated views never hit the network again.
109
+ * Same-origin route family for the prompt-template libraries. The library is
110
+ * multi-source: every request names a source id from {@link TEMPLATE_SOURCES},
111
+ * each source keeps an independent snapshot/image cache host-side, and
112
+ * reference images are proxied through the source-scoped `image` prefix route
113
+ * (`…/image/<sourceId>/<file>`) and cached on disk so repeated views never hit
114
+ * the network again.
113
115
  */
114
116
  const TEMPLATES_API = {
115
117
  list: "/api/dsh-imagegen/templates/list",
116
118
  refresh: "/api/dsh-imagegen/templates/refresh",
119
+ sample: "/api/dsh-imagegen/templates/sample",
117
120
  image: "/api/dsh-imagegen/templates/image"
118
121
  };
122
+ /** Same-origin route family for the user's saved (favorited) templates. */
123
+ const TEMPLATE_FAVORITES_API = {
124
+ list: "/api/dsh-imagegen/templates/favorites/list",
125
+ add: "/api/dsh-imagegen/templates/favorites/add",
126
+ remove: "/api/dsh-imagegen/templates/favorites/remove"
127
+ };
128
+ /**
129
+ * The template-library source registry. Each entry is fully independent (own
130
+ * upstream JSON, own image pool, own refresh state) and renders as its own
131
+ * tab; adding a source later means appending an entry here plus a host-side
132
+ * fetch definition in templates-store.ts and an optional bundled snapshot.
133
+ */
134
+ const TEMPLATE_SOURCES = [{
135
+ id: "vibeui",
136
+ label: "精选案例库",
137
+ homepage: "https://vibeui.top/",
138
+ description: "awesome-gpt-image-2 精选提示词案例(vibeui.top 镜像)"
139
+ }, {
140
+ id: "canghe",
141
+ label: "沧河案例库",
142
+ homepage: "https://gpt-image2.canghe.ai/",
143
+ description: "GPT-Image2 Prompt Gallery(gpt-image2.canghe.ai,定期更新)"
144
+ }];
145
+ /** Default source id when a request does not name one (legacy clients). */
146
+ const DEFAULT_TEMPLATE_SOURCE_ID = TEMPLATE_SOURCES[0].id;
147
+ /** True when the id names a registered template source. */
148
+ function isTemplateSourceId(id) {
149
+ return TEMPLATE_SOURCES.some((source) => source.id === id);
150
+ }
119
151
  //#endregion
120
152
  //#region src/model-catalog.ts
121
153
  const ENTRIES = {
@@ -174,6 +206,14 @@ const ENTRIES = {
174
206
  supportsEdit: false,
175
207
  supportsAspectRatio: false,
176
208
  qualityTiers: ["HD"]
209
+ },
210
+ qwen: {
211
+ label: "qwen-image",
212
+ labelZh: "千问图像",
213
+ known: true,
214
+ supportsEdit: true,
215
+ supportsAspectRatio: true,
216
+ qualityTiers: ["auto"]
177
217
  }
178
218
  };
179
219
  /** Official Gemini image ids served by Nano Banana gateways. */
@@ -212,6 +252,10 @@ function describeModel(model) {
212
252
  family: "zhipu",
213
253
  ...ENTRIES.zhipu
214
254
  };
255
+ if (/^qwen-image(?:[-_.]|$)/i.test(id)) return {
256
+ family: "qwen",
257
+ ...ENTRIES.qwen
258
+ };
215
259
  return {
216
260
  family: "unknown",
217
261
  label: "unknown",
@@ -439,6 +483,12 @@ function isSeedream(model) {
439
483
  function isZhipuImage(model) {
440
484
  return modelFamily(model) === "zhipu";
441
485
  }
486
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
487
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
488
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
489
+ function isQwenImage(model) {
490
+ return modelFamily(model) === "qwen";
491
+ }
442
492
  function isGlmImage(model) {
443
493
  return /^glm-image(?:-|$)/i.test(model.trim());
444
494
  }
@@ -451,6 +501,38 @@ function seedreamSize(quality) {
451
501
  if (quality === "1k") return "1K";
452
502
  return "2K";
453
503
  }
504
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
505
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
506
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
507
+ * recommend the larger set. */
508
+ const QWEN_SIZE_CLASSIC = {
509
+ "16:9": "1664*928",
510
+ "21:9": "1664*928",
511
+ "4:3": "1472*1104",
512
+ "3:2": "1472*1104",
513
+ "1:1": "1328*1328",
514
+ "3:4": "1104*1472",
515
+ "2:3": "1104*1472",
516
+ "9:16": "928*1664"
517
+ };
518
+ const QWEN_SIZE_HD = {
519
+ "16:9": "2688*1536",
520
+ "21:9": "2688*1536",
521
+ "4:3": "2368*1728",
522
+ "3:2": "2368*1728",
523
+ "1:1": "2048*2048",
524
+ "3:4": "1728*2368",
525
+ "2:3": "1728*2368",
526
+ "9:16": "1536*2688"
527
+ };
528
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
529
+ function isVersionedQwenImage(model) {
530
+ return /^qwen-image-\d+\.\d/i.test(model.trim());
531
+ }
532
+ function qwenSize(model, ratio) {
533
+ if (ratio === "" || ratio === "auto") return void 0;
534
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio];
535
+ }
454
536
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
455
537
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
456
538
  const OPENAI_SIZE_BY_RATIO = {
@@ -727,6 +809,83 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
727
809
  }));
728
810
  }
729
811
  /**
812
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
813
+ * carries the prompt (plus the reference image for edit mode) and answers
814
+ * synchronously with image URLs in the reply content. The versioned series
815
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
816
+ * single-image per call.
817
+ */
818
+ async function generateQwenImage(baseUrl, upstream, request, options) {
819
+ const model = wireModel(request);
820
+ const content = [];
821
+ if (request.mode === "edit") {
822
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
823
+ const parsed = parseDataUrl(request.image);
824
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
825
+ if (Buffer.from(parsed.base64, "base64").byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
826
+ content.push({ image: request.image });
827
+ }
828
+ content.push({ text: request.prompt });
829
+ const count = isVersionedQwenImage(model) ? clampCount(request.n) : 1;
830
+ const size = qwenSize(model, request.size);
831
+ const body = {
832
+ model,
833
+ input: { messages: [{
834
+ role: "user",
835
+ content
836
+ }] },
837
+ parameters: {
838
+ ...size !== void 0 ? { size } : {},
839
+ ...count > 1 ? { n: count } : {}
840
+ }
841
+ };
842
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
843
+ let response;
844
+ try {
845
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
846
+ method: "POST",
847
+ headers: {
848
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
849
+ "content-type": "application/json"
850
+ },
851
+ body: JSON.stringify(body),
852
+ signal: budget.signal
853
+ });
854
+ } catch (error) {
855
+ const message = error instanceof Error ? error.message : String(error);
856
+ if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
857
+ throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
858
+ } finally {
859
+ budget.dispose();
860
+ }
861
+ let payload;
862
+ try {
863
+ payload = await response.json();
864
+ } catch {
865
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
866
+ }
867
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
868
+ const output = payload.output;
869
+ const choices = output !== void 0 && Array.isArray(output.choices) ? output.choices : [];
870
+ const urls = [];
871
+ for (const choice of choices) {
872
+ const message = choice !== null && typeof choice === "object" ? choice.message : void 0;
873
+ const items = message !== null && typeof message === "object" && Array.isArray(message.content) ? message.content : [];
874
+ for (const item of items) if (item !== null && typeof item === "object") {
875
+ const image = item.image;
876
+ if (typeof image === "string" && image !== "") urls.push(image);
877
+ }
878
+ }
879
+ if (urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
880
+ return { images: await Promise.all(urls.map(async (url) => {
881
+ const normalized = await normalizeItem({ url }, upstream);
882
+ return {
883
+ b64: normalized.b64,
884
+ mime: normalized.mime
885
+ };
886
+ })) };
887
+ }
888
+ /**
730
889
  * Forward one generate request to the configured endpoint. The requested image
731
890
  * count is satisfied with N parallel single-image requests (the `n` batch
732
891
  * parameter is never sent, because Responses-API-based gateways reject it as
@@ -736,6 +895,7 @@ async function generateImage(upstream, request, options = {}) {
736
895
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
737
896
  if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
738
897
  if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
898
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options);
739
899
  if (request.mode === "edit" && isZhipuImage(wireModel(request))) throw new ImageGenError("智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型", "edit-unsupported");
740
900
  const params = effectiveParams(request);
741
901
  const count = effectiveCount(request);
@@ -1359,24 +1519,42 @@ async function readGalleryImage(file) {
1359
1519
  //#endregion
1360
1520
  //#region src/templates-store.ts
1361
1521
  /**
1362
- * Prompt-template library store (awesome-gpt-image-2 mirror).
1522
+ * Prompt-template library store (multi-source).
1523
+ *
1524
+ * The library is a registry of independent sources (see TEMPLATE_SOURCES in
1525
+ * protocol.ts): each source has its own upstream JSON list, its own bundled
1526
+ * snapshot, its own refreshed runtime copy, and its own on-disk image pool.
1527
+ * Sources never mix — the overlay shows one tab per source and every request
1528
+ * names the source explicitly.
1363
1529
  *
1364
- * The case list ships as a bundled snapshot (src/templates/cases.json, inside
1365
- * the npm package) so the library works offline out of the box; a successful
1366
- * manual refresh writes a runtime copy under ~/.dsh/dsh-imagegen/templates/
1367
- * which then takes precedence. Reference images are not bundled (441 files,
1368
- * ≈100 MB) they are fetched from the vibeui.top mirror on demand, cached on
1369
- * disk under ~/.dsh/dsh-imagegen/template-images/, and served from there on
1530
+ * Each case list ships as a bundled snapshot (src/templates/<file>, inside the
1531
+ * npm package) so every library works offline out of the box; a successful
1532
+ * refresh (manual, or the periodic background sync) writes a runtime copy
1533
+ * under ~/.dsh/dsh-imagegen/templates/<sourceId>/ which then takes precedence.
1534
+ * Reference images are not bundled (hundreds of files, ≈100 MB per source)
1535
+ * they are fetched from the source's mirror on demand, cached on disk under
1536
+ * ~/.dsh/dsh-imagegen/template-images/<sourceId>/, and served from there on
1370
1537
  * every later view.
1371
1538
  *
1372
1539
  * Framework-free (node:fs only) so the route layer and tests can drive it
1373
1540
  * directly.
1374
1541
  */
1375
- /** Upstream mirror the library refreshes from (vibeui.top static mirror). */
1376
- const SOURCE_URL = "https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json";
1377
- /** Remote image directory (file names come from the case list). */
1378
- const IMAGE_BASE_URL = "https://vibeui.top/extra/awesome-gpt-image-2/data/images/";
1379
- /** Category label map mirrored from vibeui.top's site.js (zh display names). */
1542
+ /** Source registry (host half): where each TEMPLATE_SOURCES entry loads from. */
1543
+ const SOURCE_DEFS = {
1544
+ vibeui: {
1545
+ listUrl: "https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json",
1546
+ imageBaseUrl: "https://vibeui.top/extra/awesome-gpt-image-2/data/images/",
1547
+ bundledPath: fileURLToPath(new URL("../src/templates/cases.json", import.meta.url)),
1548
+ legacySnapshotPath: "legacy",
1549
+ legacyImageDir: "legacy"
1550
+ },
1551
+ canghe: {
1552
+ listUrl: "https://gpt-image2.canghe.ai/cases.json",
1553
+ imageBaseUrl: "https://gpt-image2.canghe.ai/images/",
1554
+ bundledPath: fileURLToPath(new URL("../src/templates/canghe-cases.json", import.meta.url))
1555
+ }
1556
+ };
1557
+ /** Category label map mirrored from the upstream sites' site.js (zh names). */
1380
1558
  const CATEGORY_ZH = {
1381
1559
  "Architecture & Spaces": "建筑与空间",
1382
1560
  "Brand & Logos": "品牌与标志",
@@ -1406,26 +1584,26 @@ const CATEGORY_ZH = {
1406
1584
  "Animals & Nature": "动物与自然",
1407
1585
  "Other Creative Uses": "其他创意用途"
1408
1586
  };
1409
- const DATA_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
1410
- const REFRESHED_CASES_PATH = path.join(DATA_DIR, "templates", "cases.json");
1411
- const IMAGE_CACHE_DIR = path.join(DATA_DIR, "template-images");
1412
- /**
1413
- * Bundled snapshot path. The host bundle emits to lib/index.js while this
1414
- * source file lives at src/templates-store.ts — both exactly one level below
1415
- * the package root — so `../src/templates/cases.json` resolves to the shipped
1416
- * snapshot in development and in the installed package alike.
1417
- */
1418
- const BUNDLED_CASES_PATH = fileURLToPath(new URL("../src/templates/cases.json", import.meta.url));
1587
+ const DATA_DIR$1 = path.join(homedir(), ".dsh", "dsh-imagegen");
1588
+ const REFRESHED_DIR = path.join(DATA_DIR$1, "templates");
1589
+ const IMAGE_CACHE_ROOT = path.join(DATA_DIR$1, "template-images");
1590
+ /** Pre-1.6 single-source locations (vibeui fallbacks). */
1591
+ const LEGACY_SNAPSHOT_PATH = path.join(REFRESHED_DIR, "cases.json");
1592
+ const LEGACY_IMAGE_DIR = IMAGE_CACHE_ROOT;
1419
1593
  /** Budget for one upstream fetch (list refresh or one image). */
1420
1594
  const FETCH_TIMEOUT_MS = 6e4;
1421
1595
  /** Refuse to cache implausibly large "images". */
1422
1596
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
1423
1597
  /** Strict reference-image file names this store writes and serves. */
1424
1598
  const IMAGE_FILE_PATTERN = /^case\d+\.(jpg|jpeg|png|webp|gif)$/i;
1425
- /** In-memory memo of the active list (avoid re-parsing on every request). */
1426
- let memo;
1599
+ /** Per-source in-memory memo of the active list (avoid re-parsing per request). */
1600
+ const memos = /* @__PURE__ */ new Map();
1427
1601
  /** Per-file in-flight downloads, so a gallery scroll never double-fetches. */
1428
1602
  const inflightImages = /* @__PURE__ */ new Map();
1603
+ /** Resolve a registered source id to its fetch definition. */
1604
+ function sourceDefOf(sourceId) {
1605
+ return SOURCE_DEFS[sourceId];
1606
+ }
1429
1607
  /** Validate + normalize one raw upstream case; undefined when unusable. */
1430
1608
  function normalizeCase(raw) {
1431
1609
  if (raw === null || typeof raw !== "object") return void 0;
@@ -1483,50 +1661,61 @@ async function readSnapshotFile(file) {
1483
1661
  }
1484
1662
  }
1485
1663
  /**
1486
- * The active template list: the refreshed runtime copy wins, the bundled
1487
- * snapshot is the always-available fallback. Memoized; a successful refresh
1488
- * replaces the memo.
1664
+ * The active template list of one source: the refreshed runtime copy wins, the
1665
+ * bundled snapshot is the always-available fallback. Memoized per source; a
1666
+ * successful refresh replaces that source's memo.
1489
1667
  */
1490
- async function listTemplates() {
1668
+ async function listTemplates(sourceId = DEFAULT_TEMPLATE_SOURCE_ID) {
1669
+ const def = sourceDefOf(sourceId);
1670
+ if (def === void 0) throw new Error(`未知的模板库来源:${sourceId}`);
1671
+ const memo = memos.get(sourceId);
1491
1672
  if (memo !== void 0) return memo;
1492
- const refreshed = await readSnapshotFile(REFRESHED_CASES_PATH);
1673
+ const refreshed = await readSnapshotFile(path.join(REFRESHED_DIR, sourceId, "cases.json")) ?? (def.legacySnapshotPath !== void 0 ? await readSnapshotFile(LEGACY_SNAPSHOT_PATH) : void 0);
1493
1674
  if (refreshed !== void 0) {
1494
- memo = {
1675
+ const result = {
1676
+ sourceId,
1495
1677
  ...refreshed,
1496
1678
  total: refreshed.cases.length,
1497
1679
  origin: "refreshed"
1498
1680
  };
1499
- return memo;
1681
+ memos.set(sourceId, result);
1682
+ return result;
1500
1683
  }
1501
- const bundled = await readSnapshotFile(BUNDLED_CASES_PATH);
1684
+ const bundled = await readSnapshotFile(def.bundledPath);
1502
1685
  if (bundled !== void 0) {
1503
- memo = {
1686
+ const result = {
1687
+ sourceId,
1504
1688
  ...bundled,
1505
1689
  total: bundled.cases.length,
1506
1690
  origin: "bundled"
1507
1691
  };
1508
- return memo;
1692
+ memos.set(sourceId, result);
1693
+ return result;
1509
1694
  }
1510
- memo = {
1695
+ const result = {
1696
+ sourceId,
1511
1697
  cases: [],
1512
1698
  total: 0,
1513
1699
  origin: "bundled",
1514
1700
  repository: "freestylefly/awesome-gpt-image-2",
1515
1701
  fetchedAt: ""
1516
1702
  };
1517
- return memo;
1703
+ memos.set(sourceId, result);
1704
+ return result;
1518
1705
  }
1519
1706
  /**
1520
- * Re-download the case list from the upstream mirror and persist it as the
1521
- * runtime copy. Throws with a user-presentable message on failure; the
1707
+ * Re-download one source's case list from its upstream mirror and persist it
1708
+ * as the runtime copy. Throws with a user-presentable message on failure; the
1522
1709
  * previous list (refreshed or bundled) stays active.
1523
1710
  */
1524
- async function refreshTemplates() {
1711
+ async function refreshTemplates(sourceId = DEFAULT_TEMPLATE_SOURCE_ID) {
1712
+ const def = sourceDefOf(sourceId);
1713
+ if (def === void 0) throw new Error(`未知的模板库来源:${sourceId}`);
1525
1714
  let response;
1526
1715
  try {
1527
- response = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1716
+ response = await fetch(def.listUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1528
1717
  } catch (error) {
1529
- throw new Error(`无法连接模板库源站:${error instanceof Error ? error.message : String(error)}`);
1718
+ throw new Error(`无法连接模板库源站(${sourceId}):${error instanceof Error ? error.message : String(error)}`);
1530
1719
  }
1531
1720
  if (!response.ok) throw new Error(`模板库源站拒绝请求(HTTP ${response.status})`);
1532
1721
  let payload;
@@ -1540,27 +1729,89 @@ async function refreshTemplates() {
1540
1729
  const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
1541
1730
  const snapshot = {
1542
1731
  repository: parsed.repository,
1543
- sourceUrl: SOURCE_URL,
1732
+ sourceUrl: def.listUrl,
1544
1733
  fetchedAt,
1545
1734
  totalCases: parsed.cases.length,
1546
1735
  cases: parsed.cases
1547
1736
  };
1548
- await promises.mkdir(path.dirname(REFRESHED_CASES_PATH), { recursive: true });
1549
- const tmp = `${REFRESHED_CASES_PATH}.tmp-${process.pid}`;
1737
+ const target = path.join(REFRESHED_DIR, sourceId, "cases.json");
1738
+ await promises.mkdir(path.dirname(target), { recursive: true });
1739
+ const tmp = `${target}.tmp-${process.pid}`;
1550
1740
  await promises.writeFile(tmp, JSON.stringify(snapshot), "utf8");
1551
- await promises.rename(tmp, REFRESHED_CASES_PATH);
1552
- memo = {
1741
+ await promises.rename(tmp, target);
1742
+ const result = {
1743
+ sourceId,
1553
1744
  cases: parsed.cases,
1554
1745
  total: parsed.cases.length,
1555
1746
  origin: "refreshed",
1556
1747
  repository: parsed.repository,
1557
1748
  fetchedAt
1558
1749
  };
1750
+ memos.set(sourceId, result);
1559
1751
  return {
1752
+ sourceId,
1560
1753
  total: parsed.cases.length,
1561
1754
  fetchedAt
1562
1755
  };
1563
1756
  }
1757
+ /** Fisher–Yates shuffle (returns a copy; never mutates the pool). */
1758
+ function shuffled(items) {
1759
+ const out = [...items];
1760
+ for (let i = out.length - 1; i > 0; i -= 1) {
1761
+ const j = Math.floor(Math.random() * (i + 1));
1762
+ const a = out[i];
1763
+ out[i] = out[j];
1764
+ out[j] = a;
1765
+ }
1766
+ return out;
1767
+ }
1768
+ /**
1769
+ * Draw up to `count` random cases across every source that has data,
1770
+ * round-robin between sources so one huge library cannot crowd out the
1771
+ * others, then shuffle the final pick. Reads memoized lists, so this never
1772
+ * touches the network — cheap enough for every shuffle click.
1773
+ */
1774
+ async function sampleTemplates(count = 9) {
1775
+ const size = Math.min(12, Math.max(1, Math.floor(count) || 9));
1776
+ const pools = [];
1777
+ for (const source of TEMPLATE_SOURCES) try {
1778
+ const list = await listTemplates(source.id);
1779
+ if (list.cases.length > 0) pools.push({
1780
+ sourceId: source.id,
1781
+ cases: shuffled(list.cases)
1782
+ });
1783
+ } catch {}
1784
+ const picks = [];
1785
+ for (let round = 0; picks.length < size && pools.some((pool) => pool.cases.length > 0); round += 1) {
1786
+ const pool = pools[round % pools.length];
1787
+ const picked = pool.cases.pop();
1788
+ if (picked !== void 0) picks.push({
1789
+ sourceId: pool.sourceId,
1790
+ case: picked
1791
+ });
1792
+ }
1793
+ return shuffled(picks);
1794
+ }
1795
+ /** Serially refresh every registered source; one failure never stops the rest. */
1796
+ async function syncAllTemplates() {
1797
+ const reports = [];
1798
+ for (const source of TEMPLATE_SOURCES) try {
1799
+ const result = await refreshTemplates(source.id);
1800
+ reports.push({
1801
+ sourceId: source.id,
1802
+ ok: true,
1803
+ total: result.total
1804
+ });
1805
+ } catch (error) {
1806
+ reports.push({
1807
+ sourceId: source.id,
1808
+ ok: false,
1809
+ total: 0,
1810
+ error: error instanceof Error ? error.message : String(error)
1811
+ });
1812
+ }
1813
+ return reports;
1814
+ }
1564
1815
  /** MIME type for a cached reference-image file name. */
1565
1816
  function mimeOfFile(file) {
1566
1817
  switch (path.extname(file).toLowerCase()) {
@@ -1571,11 +1822,11 @@ function mimeOfFile(file) {
1571
1822
  default: return "image/png";
1572
1823
  }
1573
1824
  }
1574
- /** Download one reference image into the disk cache; undefined on failure. */
1575
- async function fetchTemplateImage(file) {
1825
+ /** Download one reference image into the source's disk cache; undefined on failure. */
1826
+ async function fetchTemplateImage(def, file, cacheDir) {
1576
1827
  let response;
1577
1828
  try {
1578
- response = await fetch(`${IMAGE_BASE_URL}${encodeURIComponent(file)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1829
+ response = await fetch(`${def.imageBaseUrl}${encodeURIComponent(file)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1579
1830
  } catch {
1580
1831
  return;
1581
1832
  }
@@ -1585,10 +1836,10 @@ async function fetchTemplateImage(file) {
1585
1836
  if (data.byteLength === 0 || data.byteLength > MAX_IMAGE_BYTES) return void 0;
1586
1837
  const mime = mimeOfFile(file);
1587
1838
  try {
1588
- await promises.mkdir(IMAGE_CACHE_DIR, { recursive: true });
1589
- const tmp = path.join(IMAGE_CACHE_DIR, `${file}.tmp-${process.pid}`);
1839
+ await promises.mkdir(cacheDir, { recursive: true });
1840
+ const tmp = path.join(cacheDir, `${file}.tmp-${process.pid}`);
1590
1841
  await promises.writeFile(tmp, data);
1591
- await promises.rename(tmp, path.join(IMAGE_CACHE_DIR, file));
1842
+ await promises.rename(tmp, path.join(cacheDir, file));
1592
1843
  } catch {}
1593
1844
  return {
1594
1845
  data,
@@ -1596,33 +1847,143 @@ async function fetchTemplateImage(file) {
1596
1847
  };
1597
1848
  }
1598
1849
  /**
1599
- * Read one reference image for the template library. Cache hit → disk; miss →
1600
- * fetch from the upstream mirror, cache, and serve. Only file names present in
1601
- * the active case list are served, so the route can never act as an open
1850
+ * Read one reference image for a source's library. Cache hit → disk; miss →
1851
+ * fetch from that source's mirror, cache, and serve. Only file names present
1852
+ * in the active case list are served, so the route can never act as an open
1602
1853
  * proxy. Undefined when the name is unknown or the fetch failed.
1603
1854
  */
1604
- async function readTemplateImage(file) {
1855
+ async function readTemplateImage(sourceId, file) {
1856
+ const def = sourceDefOf(sourceId);
1857
+ if (def === void 0) return void 0;
1605
1858
  if (!IMAGE_FILE_PATTERN.test(file) || file.includes("..")) return void 0;
1606
- if (!(await listTemplates()).cases.some((entry) => entry.image === file)) return void 0;
1607
- const cached = path.join(IMAGE_CACHE_DIR, file);
1859
+ if (!(await listTemplates(sourceId)).cases.some((entry) => entry.image === file)) return void 0;
1860
+ const cacheDir = path.join(IMAGE_CACHE_ROOT, sourceId);
1608
1861
  try {
1609
1862
  return {
1610
- data: await promises.readFile(cached),
1863
+ data: await promises.readFile(path.join(cacheDir, file)),
1864
+ mime: mimeOfFile(file)
1865
+ };
1866
+ } catch {}
1867
+ if (def.legacyImageDir !== void 0) try {
1868
+ return {
1869
+ data: await promises.readFile(path.join(LEGACY_IMAGE_DIR, file)),
1611
1870
  mime: mimeOfFile(file)
1612
1871
  };
1613
1872
  } catch {}
1614
- const inflight = inflightImages.get(file);
1873
+ const cacheKey = `${sourceId}/${file}`;
1874
+ const inflight = inflightImages.get(cacheKey);
1615
1875
  if (inflight !== void 0) return inflight;
1616
- const pending = fetchTemplateImage(file);
1617
- inflightImages.set(file, pending);
1876
+ const pending = fetchTemplateImage(def, file, cacheDir);
1877
+ inflightImages.set(cacheKey, pending);
1618
1878
  try {
1619
1879
  return await pending;
1620
1880
  } finally {
1621
- inflightImages.delete(file);
1881
+ inflightImages.delete(cacheKey);
1622
1882
  }
1623
1883
  }
1624
- /** Drop the in-memory list memo (tests). */
1884
+ /** Drop the in-memory list memos (tests). */
1625
1885
  function clearTemplateMemo() {
1886
+ memos.clear();
1887
+ }
1888
+ //#endregion
1889
+ //#region src/template-favorites.ts
1890
+ /**
1891
+ * Favorites store for the prompt-template library.
1892
+ *
1893
+ * The user's starred templates persist host-side as full case snapshots under
1894
+ * ~/.dsh/dsh-imagegen/templates/favorites.json, keyed by
1895
+ * `${sourceId}:${caseId}` — the snapshot means a favorite stays usable even
1896
+ * after the upstream list drops or renumbers the case. Framework-free
1897
+ * (node:fs only) so the route layer and tests can drive it directly.
1898
+ */
1899
+ const DATA_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
1900
+ const FAVORITES_PATH = path.join(DATA_DIR, "templates", "favorites.json");
1901
+ /** Refuse to grow the file without bound; the user curates this list. */
1902
+ const MAX_FAVORITES = 1e3;
1903
+ /** In-memory memo of the persisted list. */
1904
+ let memo;
1905
+ /** Build the stable key of one case within a source. */
1906
+ function templateFavoriteKey(sourceId, caseId) {
1907
+ return `${sourceId}:${caseId}`;
1908
+ }
1909
+ /** Validate + normalize one raw stored favorite; undefined when unusable. */
1910
+ function normalizeFavorite(raw) {
1911
+ if (raw === null || typeof raw !== "object") return void 0;
1912
+ const record = raw;
1913
+ if (typeof record.key !== "string" || typeof record.savedAt !== "string") return void 0;
1914
+ const sourceId = typeof record.sourceId === "string" ? record.sourceId : "";
1915
+ if (!isTemplateSourceId(sourceId)) return void 0;
1916
+ if (record.key !== templateFavoriteKey(sourceId, Number(record.case && record.case.id))) return void 0;
1917
+ const rawCase = record.case;
1918
+ if (rawCase === null || typeof rawCase !== "object") return void 0;
1919
+ const item = rawCase;
1920
+ const id = Number(item.id);
1921
+ const title = typeof item.title === "string" ? item.title : "";
1922
+ const prompt = typeof item.prompt === "string" ? item.prompt : "";
1923
+ if (!Number.isInteger(id) || title === "" || prompt === "") return void 0;
1924
+ const snapshot = {
1925
+ id,
1926
+ title,
1927
+ prompt,
1928
+ category: typeof item.category === "string" ? item.category : "",
1929
+ categoryZh: typeof item.categoryZh === "string" ? item.categoryZh : "",
1930
+ styles: Array.isArray(item.styles) ? item.styles.map(String) : [],
1931
+ scenes: Array.isArray(item.scenes) ? item.scenes.map(String) : [],
1932
+ sourceLabel: typeof item.sourceLabel === "string" ? item.sourceLabel : "",
1933
+ sourceUrl: typeof item.sourceUrl === "string" ? item.sourceUrl : "",
1934
+ githubUrl: typeof item.githubUrl === "string" ? item.githubUrl : "",
1935
+ image: typeof item.image === "string" ? item.image : "",
1936
+ featured: item.featured === true
1937
+ };
1938
+ return {
1939
+ key: record.key,
1940
+ sourceId,
1941
+ savedAt: record.savedAt,
1942
+ case: snapshot
1943
+ };
1944
+ }
1945
+ /** Read + parse the favorites file (memoized). */
1946
+ async function listTemplateFavorites() {
1947
+ if (memo !== void 0) return memo;
1948
+ try {
1949
+ const parsed = JSON.parse(await promises.readFile(FAVORITES_PATH, "utf8"));
1950
+ memo = Array.isArray(parsed) ? parsed.map(normalizeFavorite).filter((entry) => entry !== void 0) : [];
1951
+ } catch {
1952
+ memo = [];
1953
+ }
1954
+ return memo;
1955
+ }
1956
+ /** Persist the list atomically and update the memo. */
1957
+ async function writeFavorites(entries) {
1958
+ memo = entries;
1959
+ await promises.mkdir(path.dirname(FAVORITES_PATH), { recursive: true });
1960
+ const tmp = `${FAVORITES_PATH}.tmp-${process.pid}`;
1961
+ await promises.writeFile(tmp, JSON.stringify(entries, null, 2), "utf8");
1962
+ await promises.rename(tmp, FAVORITES_PATH);
1963
+ }
1964
+ /** Star one template. Re-starring refreshes the snapshot and is idempotent. */
1965
+ async function addTemplateFavorite(sourceId, item) {
1966
+ if (!isTemplateSourceId(sourceId)) throw new Error(`未知的模板库来源:${sourceId}`);
1967
+ const key = templateFavoriteKey(sourceId, item.id);
1968
+ const rest = (await listTemplateFavorites()).filter((entry) => entry.key !== key);
1969
+ const next = [{
1970
+ key,
1971
+ sourceId,
1972
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
1973
+ case: item
1974
+ }, ...rest].slice(0, MAX_FAVORITES);
1975
+ await writeFavorites(next);
1976
+ return next;
1977
+ }
1978
+ /** Unstar one template by key; unknown keys are a no-op. */
1979
+ async function removeTemplateFavorite(key) {
1980
+ const next = (await listTemplateFavorites()).filter((entry) => entry.key !== key);
1981
+ if (next.length === memo?.length) return next;
1982
+ await writeFavorites(next);
1983
+ return next;
1984
+ }
1985
+ /** Drop the in-memory memo (tests). */
1986
+ function clearTemplateFavoritesMemo() {
1626
1987
  memo = void 0;
1627
1988
  }
1628
1989
  //#endregion
@@ -1768,6 +2129,42 @@ const IMAGE_PRESETS = [
1768
2129
  id: "glm-image"
1769
2130
  }]
1770
2131
  },
2132
+ {
2133
+ id: "aliyun-dashscope-qwen",
2134
+ name: "阿里云百炼(Qwen-Image)",
2135
+ apiUrl: "https://dashscope.aliyuncs.com/api/v1",
2136
+ hint: "阿里云百炼 DashScope 原生接口:通义千问 Qwen-Image 系列(该渠道不可复用于提示词增强)",
2137
+ models: [
2138
+ {
2139
+ alias: "qwen-image-3.0-pro",
2140
+ id: "qwen-image-3.0-pro"
2141
+ },
2142
+ {
2143
+ alias: "qwen-image-3.0",
2144
+ id: "qwen-image-3.0"
2145
+ },
2146
+ {
2147
+ alias: "qwen-image-2.0-pro",
2148
+ id: "qwen-image-2.0-pro"
2149
+ },
2150
+ {
2151
+ alias: "qwen-image-2.0",
2152
+ id: "qwen-image-2.0"
2153
+ },
2154
+ {
2155
+ alias: "qwen-image-max",
2156
+ id: "qwen-image-max"
2157
+ },
2158
+ {
2159
+ alias: "qwen-image-plus",
2160
+ id: "qwen-image-plus"
2161
+ },
2162
+ {
2163
+ alias: "qwen-image",
2164
+ id: "qwen-image"
2165
+ }
2166
+ ]
2167
+ },
1771
2168
  {
1772
2169
  id: "xai-grok",
1773
2170
  name: "xAI(Grok)",
@@ -1841,6 +2238,12 @@ async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
1841
2238
  function messageOf(error) {
1842
2239
  return error instanceof Error ? error.message : String(error);
1843
2240
  }
2241
+ /** Validate the { source } body of a template-library request. */
2242
+ function templateSourceOf(body) {
2243
+ const raw = body?.source;
2244
+ if (raw === void 0 || raw === "") return DEFAULT_TEMPLATE_SOURCE_ID;
2245
+ return typeof raw === "string" && isTemplateSourceId(raw) ? raw : void 0;
2246
+ }
1844
2247
  function parseGenerateRequest(body) {
1845
2248
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1846
2249
  if (prompt === "") return void 0;
@@ -2011,8 +2414,14 @@ function makeRoutes(deps) {
2011
2414
  const templates = deps.templates ?? {
2012
2415
  list: listTemplates,
2013
2416
  refresh: refreshTemplates,
2417
+ sample: sampleTemplates,
2014
2418
  readImage: readTemplateImage
2015
2419
  };
2420
+ const favorites = deps.favorites ?? {
2421
+ list: listTemplateFavorites,
2422
+ add: addTemplateFavorite,
2423
+ remove: removeTemplateFavorite
2424
+ };
2016
2425
  const resolvePrompt = deps.resolvePrompt ?? (() => ({
2017
2426
  apiUrl: "",
2018
2427
  apiKey: "",
@@ -2846,10 +3255,20 @@ function makeRoutes(deps) {
2846
3255
  path: TEMPLATES_API.list,
2847
3256
  handler: async (req, res) => {
2848
3257
  if (!guard(req, res, "POST")) return;
3258
+ const body = await readJsonBody(req);
3259
+ const sourceId = templateSourceOf(body);
3260
+ if (sourceId === void 0) {
3261
+ writeJson(res, 200, {
3262
+ ok: false,
3263
+ code: "templates-source-unknown",
3264
+ message: `未知的模板库来源:${String(body?.source ?? "")}`
3265
+ });
3266
+ return;
3267
+ }
2849
3268
  try {
2850
3269
  writeJson(res, 200, {
2851
3270
  ok: true,
2852
- ...await templates.list()
3271
+ ...await templates.list(sourceId)
2853
3272
  });
2854
3273
  } catch (error) {
2855
3274
  writeJson(res, 200, {
@@ -2865,10 +3284,20 @@ function makeRoutes(deps) {
2865
3284
  path: TEMPLATES_API.refresh,
2866
3285
  handler: async (req, res) => {
2867
3286
  if (!guard(req, res, "POST")) return;
3287
+ const body = await readJsonBody(req);
3288
+ const sourceId = templateSourceOf(body);
3289
+ if (sourceId === void 0) {
3290
+ writeJson(res, 200, {
3291
+ ok: false,
3292
+ code: "templates-source-unknown",
3293
+ message: `未知的模板库来源:${String(body?.source ?? "")}`
3294
+ });
3295
+ return;
3296
+ }
2868
3297
  try {
2869
3298
  writeJson(res, 200, {
2870
3299
  ok: true,
2871
- ...await templates.refresh()
3300
+ ...await templates.refresh(sourceId)
2872
3301
  });
2873
3302
  } catch (error) {
2874
3303
  writeJson(res, 200, {
@@ -2879,6 +3308,28 @@ function makeRoutes(deps) {
2879
3308
  }
2880
3309
  }
2881
3310
  },
3311
+ {
3312
+ kind: "exact",
3313
+ path: TEMPLATES_API.sample,
3314
+ handler: async (req, res) => {
3315
+ if (!guard(req, res, "POST")) return;
3316
+ const body = await readJsonBody(req);
3317
+ const requested = Number(body?.count);
3318
+ const count = Number.isFinite(requested) ? requested : 9;
3319
+ try {
3320
+ writeJson(res, 200, {
3321
+ ok: true,
3322
+ samples: await templates.sample(count)
3323
+ });
3324
+ } catch (error) {
3325
+ writeJson(res, 200, {
3326
+ ok: false,
3327
+ code: "templates-sample-failed",
3328
+ message: messageOf(error)
3329
+ });
3330
+ }
3331
+ }
3332
+ },
2882
3333
  {
2883
3334
  kind: "prefix",
2884
3335
  path: TEMPLATES_API.image,
@@ -2891,12 +3342,15 @@ function makeRoutes(deps) {
2891
3342
  writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2892
3343
  return;
2893
3344
  }
2894
- const file = imageFileFrom(req.url, TEMPLATES_API.image);
2895
- if (file === void 0) {
3345
+ const raw = imageFileFrom(req.url, TEMPLATES_API.image);
3346
+ const slash = raw?.indexOf("/") ?? -1;
3347
+ const sourceId = slash > 0 ? raw.slice(0, slash) : "";
3348
+ const file = slash > 0 ? raw.slice(slash + 1) : "";
3349
+ if (sourceId === "" || !isTemplateSourceId(sourceId) || file === "") {
2896
3350
  writeJson(res, 404, { error: "not found" });
2897
3351
  return;
2898
3352
  }
2899
- const found = await templates.readImage(file);
3353
+ const found = await templates.readImage(sourceId, file);
2900
3354
  if (found === void 0) {
2901
3355
  writeJson(res, 404, { error: "not found" });
2902
3356
  return;
@@ -2908,6 +3362,96 @@ function makeRoutes(deps) {
2908
3362
  });
2909
3363
  res.end(found.data);
2910
3364
  }
3365
+ },
3366
+ {
3367
+ kind: "exact",
3368
+ path: TEMPLATE_FAVORITES_API.list,
3369
+ handler: async (req, res) => {
3370
+ if (!guard(req, res, "POST")) return;
3371
+ try {
3372
+ writeJson(res, 200, {
3373
+ ok: true,
3374
+ favorites: await favorites.list()
3375
+ });
3376
+ } catch (error) {
3377
+ writeJson(res, 200, {
3378
+ ok: false,
3379
+ code: "template-favorites-failed",
3380
+ message: messageOf(error)
3381
+ });
3382
+ }
3383
+ }
3384
+ },
3385
+ {
3386
+ kind: "exact",
3387
+ path: TEMPLATE_FAVORITES_API.add,
3388
+ handler: async (req, res) => {
3389
+ if (!guard(req, res, "POST")) return;
3390
+ const body = await readJsonBody(req);
3391
+ const sourceId = templateSourceOf(body);
3392
+ const rawCase = body?.case;
3393
+ if (sourceId === void 0 || rawCase === null || typeof rawCase !== "object") {
3394
+ writeJson(res, 200, {
3395
+ ok: false,
3396
+ code: "template-favorite-invalid",
3397
+ message: "收藏请求缺少有效的来源或模板数据"
3398
+ });
3399
+ return;
3400
+ }
3401
+ const record = rawCase;
3402
+ const id = Number(record.id);
3403
+ const title = typeof record.title === "string" ? record.title.trim() : "";
3404
+ const prompt = typeof record.prompt === "string" ? record.prompt.trim() : "";
3405
+ if (!Number.isInteger(id) || title === "" || prompt === "") {
3406
+ writeJson(res, 200, {
3407
+ ok: false,
3408
+ code: "template-favorite-invalid",
3409
+ message: "收藏请求缺少有效的模板数据"
3410
+ });
3411
+ return;
3412
+ }
3413
+ try {
3414
+ writeJson(res, 200, {
3415
+ ok: true,
3416
+ favorites: await favorites.add(sourceId, rawCase)
3417
+ });
3418
+ } catch (error) {
3419
+ writeJson(res, 200, {
3420
+ ok: false,
3421
+ code: "template-favorites-failed",
3422
+ message: messageOf(error)
3423
+ });
3424
+ }
3425
+ }
3426
+ },
3427
+ {
3428
+ kind: "exact",
3429
+ path: TEMPLATE_FAVORITES_API.remove,
3430
+ handler: async (req, res) => {
3431
+ if (!guard(req, res, "POST")) return;
3432
+ const body = await readJsonBody(req);
3433
+ const key = typeof body?.key === "string" ? body.key : "";
3434
+ if (key === "") {
3435
+ writeJson(res, 200, {
3436
+ ok: false,
3437
+ code: "template-favorite-invalid",
3438
+ message: "取消收藏请求缺少模板标识"
3439
+ });
3440
+ return;
3441
+ }
3442
+ try {
3443
+ writeJson(res, 200, {
3444
+ ok: true,
3445
+ favorites: await favorites.remove(key)
3446
+ });
3447
+ } catch (error) {
3448
+ writeJson(res, 200, {
3449
+ ok: false,
3450
+ code: "template-favorites-failed",
3451
+ message: messageOf(error)
3452
+ });
3453
+ }
3454
+ }
2911
3455
  }
2912
3456
  ];
2913
3457
  }
@@ -3501,7 +4045,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
3501
4045
  /** Order of the announcement section within the tool-guidance band. */
3502
4046
  const SECTION_ORDER = 150;
3503
4047
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3504
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
4048
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图;qwen-image 系列使用阿里云 DashScope 原生接口(api_url 填 https://dashscope.aliyuncs.com/api/v1,不支持 OpenAI 兼容模式,该渠道不可复用于提示词增强,尺寸自动映射为宽*高)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):多来源标签页(精选案例库 / 沧河案例库,后续可扩展),打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选、收藏(星标,宿主持久化)与复用;各来源列表独立刷新,宿主每 12 小时后台自动同步一次。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问对应来源站点(vibeui.top / gpt-image2.canghe.ai)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3505
4049
  /** Append the live channel × model table so an Agent can honor user choices. */
3506
4050
  function guidanceFor(channels, defaultChannelId) {
3507
4051
  if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
@@ -3633,7 +4177,19 @@ function apply(ctx, config) {
3633
4177
  pendingConversationImages,
3634
4178
  runtime
3635
4179
  }).map((route) => ctx.webServer.register(route));
4180
+ const TEMPLATE_SYNC_INITIAL_DELAY_MS = 3e4;
4181
+ const TEMPLATE_SYNC_INTERVAL_MS = 720 * 60 * 1e3;
4182
+ let syncTimer;
4183
+ const runSync = () => {
4184
+ if (!resolve().enabled) return;
4185
+ syncAllTemplates().catch(() => {});
4186
+ };
4187
+ const startTimer = setTimeout(runSync, TEMPLATE_SYNC_INITIAL_DELAY_MS);
4188
+ syncTimer = setInterval(runSync, TEMPLATE_SYNC_INTERVAL_MS);
4189
+ syncTimer.unref?.();
3636
4190
  return () => {
4191
+ clearTimeout(startTimer);
4192
+ clearInterval(syncTimer);
3637
4193
  for (const dispose of disposers) dispose();
3638
4194
  };
3639
4195
  }, "dsh-imagegen: routes");
@@ -3690,4 +4246,4 @@ function apply(ctx, config) {
3690
4246
  sync();
3691
4247
  }
3692
4248
  //#endregion
3693
- export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, ImageGenerationRuntime, appendGallery, apply, checkForUpdate, clearGallery, clearTemplateMemo, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, latestSessionImage, listGallery, listTemplates, makeRoutes, name, profileFromProcess, readGalleryImage, readTemplateImage, refreshTemplates, registerAgentImageTools, registerEditImageCommand, removeGallery, updateGalleryTags };
4249
+ export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, ImageGenerationRuntime, addTemplateFavorite, appendGallery, apply, checkForUpdate, clearGallery, clearTemplateFavoritesMemo, clearTemplateMemo, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, latestSessionImage, listGallery, listTemplateFavorites, listTemplates, makeRoutes, name, profileFromProcess, readGalleryImage, readTemplateImage, refreshTemplates, registerAgentImageTools, registerEditImageCommand, removeGallery, removeTemplateFavorite, sampleTemplates, syncAllTemplates, updateGalleryTags };