@dickpy/dsh-imagegen 1.5.2 → 1.5.4

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,12 +1,13 @@
1
+ import { promises, readFileSync } from "node:fs";
2
+ import path from "node:path";
1
3
  import * as settingsModule from "@deepseek-ai/dsh-settings";
2
4
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
3
5
  import z from "schemastery";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { promises } from "node:fs";
6
+ import { spawn } from "node:child_process";
7
+ import { mkdir } from "node:fs/promises";
8
+ import { createHash, createHmac, randomUUID } from "node:crypto";
6
9
  import { homedir } from "node:os";
7
- import path from "node:path";
8
10
  import { fileURLToPath } from "node:url";
9
- import { spawn } from "node:child_process";
10
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
12
  //#region src/settings-compat.ts
12
13
  const compatModule = settingsModule;
@@ -40,7 +41,7 @@ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
40
41
  /** Settings namespace this plugin owns (host settings seam + bridge). */
41
42
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
42
43
  /** Published package version shared by the host updater and the client UI. */
43
- const PLUGIN_VERSION = "1.5.2";
44
+ const PLUGIN_VERSION = "1.5.4";
44
45
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
45
46
  const SETTINGS_API = {
46
47
  describe: "/api/dsh-imagegen/settings/describe",
@@ -74,6 +75,10 @@ const TASK_API = {
74
75
  cancel: "/api/dsh-imagegen/tasks/cancel",
75
76
  retry: "/api/dsh-imagegen/tasks/retry"
76
77
  };
78
+ /** Reveal the host data directory (saved images) in the OS file manager. */
79
+ const DATA_FOLDER_API = "/api/dsh-imagegen/data-folder/open";
80
+ /** Probe the configured S3-compatible object storage. */
81
+ const STORAGE_API = { test: "/api/dsh-imagegen/storage/test" };
77
82
  /** Host-mediated GitHub Release update routes. */
78
83
  const UPDATE_API = {
79
84
  check: "/api/dsh-imagegen/update/check",
@@ -105,6 +110,17 @@ const GALLERY_API = {
105
110
  tags: "/api/dsh-imagegen/gallery/tags",
106
111
  image: "/api/dsh-imagegen/gallery/image"
107
112
  };
113
+ /** Host-persisted infinite canvas projects and their content-addressed assets. */
114
+ const CANVAS_API = {
115
+ list: "/api/dsh-imagegen/canvas/list",
116
+ create: "/api/dsh-imagegen/canvas/create",
117
+ read: "/api/dsh-imagegen/canvas/read",
118
+ save: "/api/dsh-imagegen/canvas/save",
119
+ remove: "/api/dsh-imagegen/canvas/remove",
120
+ assetUpload: "/api/dsh-imagegen/canvas/asset/upload",
121
+ assetImport: "/api/dsh-imagegen/canvas/asset/import",
122
+ asset: "/api/dsh-imagegen/canvas/asset"
123
+ };
108
124
  /**
109
125
  * Same-origin route family for the prompt-template libraries. The library is
110
126
  * multi-source: every request names a source id from {@link TEMPLATE_SOURCES},
@@ -206,6 +222,14 @@ const ENTRIES = {
206
222
  supportsEdit: false,
207
223
  supportsAspectRatio: false,
208
224
  qualityTiers: ["HD"]
225
+ },
226
+ qwen: {
227
+ label: "qwen-image",
228
+ labelZh: "千问图像",
229
+ known: true,
230
+ supportsEdit: true,
231
+ supportsAspectRatio: true,
232
+ qualityTiers: ["auto"]
209
233
  }
210
234
  };
211
235
  /** Official Gemini image ids served by Nano Banana gateways. */
@@ -244,6 +268,10 @@ function describeModel(model) {
244
268
  family: "zhipu",
245
269
  ...ENTRIES.zhipu
246
270
  };
271
+ if (/^qwen-image(?:[-_.]|$)/i.test(id)) return {
272
+ family: "qwen",
273
+ ...ENTRIES.qwen
274
+ };
247
275
  return {
248
276
  family: "unknown",
249
277
  label: "unknown",
@@ -471,6 +499,12 @@ function isSeedream(model) {
471
499
  function isZhipuImage(model) {
472
500
  return modelFamily(model) === "zhipu";
473
501
  }
502
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
503
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
504
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
505
+ function isQwenImage(model) {
506
+ return modelFamily(model) === "qwen";
507
+ }
474
508
  function isGlmImage(model) {
475
509
  return /^glm-image(?:-|$)/i.test(model.trim());
476
510
  }
@@ -483,6 +517,38 @@ function seedreamSize(quality) {
483
517
  if (quality === "1k") return "1K";
484
518
  return "2K";
485
519
  }
520
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
521
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
522
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
523
+ * recommend the larger set. */
524
+ const QWEN_SIZE_CLASSIC = {
525
+ "16:9": "1664*928",
526
+ "21:9": "1664*928",
527
+ "4:3": "1472*1104",
528
+ "3:2": "1472*1104",
529
+ "1:1": "1328*1328",
530
+ "3:4": "1104*1472",
531
+ "2:3": "1104*1472",
532
+ "9:16": "928*1664"
533
+ };
534
+ const QWEN_SIZE_HD = {
535
+ "16:9": "2688*1536",
536
+ "21:9": "2688*1536",
537
+ "4:3": "2368*1728",
538
+ "3:2": "2368*1728",
539
+ "1:1": "2048*2048",
540
+ "3:4": "1728*2368",
541
+ "2:3": "1728*2368",
542
+ "9:16": "1536*2688"
543
+ };
544
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
545
+ function isVersionedQwenImage(model) {
546
+ return /^qwen-image-\d+\.\d/i.test(model.trim());
547
+ }
548
+ function qwenSize(model, ratio) {
549
+ if (ratio === "" || ratio === "auto") return void 0;
550
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio];
551
+ }
486
552
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
487
553
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
488
554
  const OPENAI_SIZE_BY_RATIO = {
@@ -617,7 +683,7 @@ function effectiveCount(request) {
617
683
  return clampCount(request.n);
618
684
  }
619
685
  /** Normalize one upstream data item into a base64 image. */
620
- async function normalizeItem(item, upstream) {
686
+ async function normalizeItem(item, upstream, signal) {
621
687
  const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
622
688
  if (typeof item.b64_json === "string" && item.b64_json.trim() !== "") {
623
689
  const b64 = bareBase64(item.b64_json);
@@ -638,7 +704,7 @@ async function normalizeItem(item, upstream) {
638
704
  revisedPrompt
639
705
  };
640
706
  }
641
- const budget = requestSignal(void 0, IMAGE_FETCH_TIMEOUT_MS);
707
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS);
642
708
  let response;
643
709
  try {
644
710
  response = await fetch(url, {
@@ -660,6 +726,143 @@ async function normalizeItem(item, upstream) {
660
726
  revisedPrompt
661
727
  };
662
728
  }
729
+ /** Expand a provider image item whose URL may be a string or an array. */
730
+ function imageItemsOf(value) {
731
+ if (value === null || typeof value !== "object") return [];
732
+ const item = value;
733
+ if (Array.isArray(item.url)) return item.url.filter((url) => typeof url === "string" && url !== "").map((url) => ({
734
+ ...item,
735
+ url
736
+ }));
737
+ return [item];
738
+ }
739
+ /** Return the data records from the response shapes shared by sync gateways. */
740
+ function dataRecordsOf(payload) {
741
+ const data = Array.isArray(payload.data) ? payload.data : payload.data !== null && typeof payload.data === "object" ? [payload.data] : Array.isArray(payload.images) ? payload.images : Array.isArray(payload.output) ? payload.output : void 0;
742
+ if (data === void 0) return void 0;
743
+ return data.filter((entry) => entry !== null && typeof entry === "object");
744
+ }
745
+ const ASYNC_PENDING_STATUSES = /* @__PURE__ */ new Set([
746
+ "submitted",
747
+ "pending",
748
+ "processing",
749
+ "running",
750
+ "in_progress",
751
+ "queued"
752
+ ]);
753
+ const ASYNC_COMPLETED_STATUSES = /* @__PURE__ */ new Set([
754
+ "completed",
755
+ "succeeded",
756
+ "success",
757
+ "done"
758
+ ]);
759
+ const ASYNC_FAILED_STATUSES = /* @__PURE__ */ new Set([
760
+ "failed",
761
+ "failure",
762
+ "cancelled",
763
+ "canceled",
764
+ "error"
765
+ ]);
766
+ const ASYNC_POLL_MAX_MS = 24e4;
767
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 3e4;
768
+ /** Read a provider error message from the common nested locations. */
769
+ function asyncErrorMessage(payload, fallback) {
770
+ if (payload !== null && typeof payload === "object") {
771
+ const record = payload;
772
+ const candidates = [record.message, record.error];
773
+ const data = record.data;
774
+ const entries = Array.isArray(data) ? data : [data];
775
+ for (const entry of entries) {
776
+ if (entry === null || typeof entry !== "object") continue;
777
+ const item = entry;
778
+ candidates.push(item.message, item.error);
779
+ const nested = item.error;
780
+ if (nested !== null && typeof nested === "object") candidates.push(nested.message);
781
+ }
782
+ for (const candidate of candidates) {
783
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
784
+ if (candidate !== null && typeof candidate === "object") {
785
+ const message = candidate.message;
786
+ if (typeof message === "string" && message.trim() !== "") return message;
787
+ }
788
+ }
789
+ }
790
+ return fallback;
791
+ }
792
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
793
+ function waitForPoll(ms, signal) {
794
+ return new Promise((resolve, reject) => {
795
+ if (signal?.aborted === true) {
796
+ reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
797
+ return;
798
+ }
799
+ const onAbort = () => {
800
+ clearTimeout(timer);
801
+ signal?.removeEventListener("abort", onAbort);
802
+ reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
803
+ };
804
+ const done = () => {
805
+ signal?.removeEventListener("abort", onAbort);
806
+ resolve();
807
+ };
808
+ const timer = setTimeout(done, ms);
809
+ timer.unref();
810
+ signal?.addEventListener("abort", onAbort, { once: true });
811
+ });
812
+ }
813
+ /**
814
+ * Poll one apib/apimart-style provider task until it yields image records.
815
+ * The total deadline is shared by every poll and the final image downloads;
816
+ * local task cancellation propagates through every request and sleep.
817
+ */
818
+ async function pollAsyncTask(baseUrl, upstream, taskId, signal) {
819
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS;
820
+ let delay = 1e3;
821
+ while (Date.now() < deadline) {
822
+ const remaining = deadline - Date.now();
823
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining));
824
+ let response;
825
+ try {
826
+ response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
827
+ method: "GET",
828
+ headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
829
+ signal: budget.signal
830
+ });
831
+ } catch (error) {
832
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
833
+ const message = error instanceof Error ? error.message : String(error);
834
+ if (/timeout|abort/i.test(message)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
835
+ throw new ImageGenError(`无法轮询上游异步任务:${message}`, "upstream-unreachable");
836
+ } finally {
837
+ budget.dispose();
838
+ }
839
+ let payload;
840
+ try {
841
+ payload = await response.json();
842
+ } catch {
843
+ throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
844
+ }
845
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), "upstream-rejected");
846
+ const record = payload;
847
+ const data = record.data;
848
+ const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === "object" ? data : record;
849
+ const statusValue = statusRecord !== null && typeof statusRecord === "object" ? statusRecord.status : void 0;
850
+ const status = typeof statusValue === "string" ? statusValue.toLowerCase() : "";
851
+ if (ASYNC_FAILED_STATUSES.has(status)) throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || "unknown"})`), "upstream-rejected");
852
+ const nested = statusRecord !== null && typeof statusRecord === "object" ? statusRecord : record;
853
+ const result = nested.result ?? (nested.output !== null && typeof nested.output === "object" ? nested.output.result : void 0) ?? record.result;
854
+ const images = (result !== null && typeof result === "object" ? result : void 0)?.images ?? nested.images ?? record.images;
855
+ if (ASYNC_COMPLETED_STATUSES.has(status) || images !== void 0) {
856
+ const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images);
857
+ if (items.length > 0) return items;
858
+ if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
859
+ }
860
+ if (status !== "" && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, "upstream-invalid");
861
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal);
862
+ delay = Math.min(5e3, delay * 2);
863
+ }
864
+ throw new ImageGenError("上游异步任务轮询超时(240 秒)", "upstream-timeout");
865
+ }
663
866
  /**
664
867
  * Issue one single-image request (never sends `n`). The response is kept as a
665
868
  * list so a gateway that happens to return several images per call still works.
@@ -692,7 +895,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
692
895
  });
693
896
  } else if (isNanoBanana(params.model)) {
694
897
  const form = new FormData();
695
- form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
898
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$3(parsed.mime)}`);
696
899
  form.append("prompt", request.prompt);
697
900
  form.append("model", params.model);
698
901
  if (params.aspect_ratio !== void 0) form.append("aspect_ratio", params.aspect_ratio);
@@ -710,7 +913,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
710
913
  });
711
914
  } else {
712
915
  const form = new FormData();
713
- form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
916
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$3(parsed.mime)}`);
714
917
  form.append("prompt", request.prompt);
715
918
  form.append("model", params.model);
716
919
  if (params.size !== void 0) form.append("size", params.size);
@@ -749,14 +952,93 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
749
952
  throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
750
953
  }
751
954
  if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
752
- const record = payload;
753
- const data = Array.isArray(record.data) ? record.data : Array.isArray(record.images) ? record.images : Array.isArray(record.output) ? record.output : void 0;
955
+ const data = dataRecordsOf(payload);
754
956
  if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
755
957
  if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
756
- return Promise.all(data.map(async (entry) => {
757
- if (entry === null || typeof entry !== "object") throw new ImageGenError("上游响应包含无效的图片条目", "upstream-invalid");
758
- return normalizeItem(entry, upstream);
759
- }));
958
+ const asyncEntries = data.filter((entry) => typeof entry.task_id === "string" && entry.task_id.trim() !== "");
959
+ if (asyncEntries.length > 0) {
960
+ const asyncRecords = (await Promise.all(asyncEntries.map((entry) => pollAsyncTask(baseUrl, upstream, entry.task_id, signal)))).flat();
961
+ if (asyncRecords.length === 0) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
962
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
963
+ }
964
+ return Promise.all(data.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
965
+ }
966
+ /**
967
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
968
+ * carries the prompt (plus the reference image for edit mode) and answers
969
+ * synchronously with image URLs in the reply content. The versioned series
970
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
971
+ * single-image per call.
972
+ */
973
+ async function generateQwenImage(baseUrl, upstream, request, options) {
974
+ const model = wireModel(request);
975
+ const content = [];
976
+ if (request.mode === "edit") {
977
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
978
+ const parsed = parseDataUrl(request.image);
979
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
980
+ if (Buffer.from(parsed.base64, "base64").byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
981
+ content.push({ image: request.image });
982
+ }
983
+ content.push({ text: request.prompt });
984
+ const count = isVersionedQwenImage(model) ? clampCount(request.n) : 1;
985
+ const size = qwenSize(model, request.size);
986
+ const body = {
987
+ model,
988
+ input: { messages: [{
989
+ role: "user",
990
+ content
991
+ }] },
992
+ parameters: {
993
+ ...size !== void 0 ? { size } : {},
994
+ ...count > 1 ? { n: count } : {}
995
+ }
996
+ };
997
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
998
+ let response;
999
+ try {
1000
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
1001
+ method: "POST",
1002
+ headers: {
1003
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
1004
+ "content-type": "application/json"
1005
+ },
1006
+ body: JSON.stringify(body),
1007
+ signal: budget.signal
1008
+ });
1009
+ } catch (error) {
1010
+ const message = error instanceof Error ? error.message : String(error);
1011
+ if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1012
+ throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
1013
+ } finally {
1014
+ budget.dispose();
1015
+ }
1016
+ let payload;
1017
+ try {
1018
+ payload = await response.json();
1019
+ } catch {
1020
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1021
+ }
1022
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1023
+ const output = payload.output;
1024
+ const choices = output !== void 0 && Array.isArray(output.choices) ? output.choices : [];
1025
+ const urls = [];
1026
+ for (const choice of choices) {
1027
+ const message = choice !== null && typeof choice === "object" ? choice.message : void 0;
1028
+ const items = message !== null && typeof message === "object" && Array.isArray(message.content) ? message.content : [];
1029
+ for (const item of items) if (item !== null && typeof item === "object") {
1030
+ const image = item.image;
1031
+ if (typeof image === "string" && image !== "") urls.push(image);
1032
+ }
1033
+ }
1034
+ if (urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
1035
+ return { images: await Promise.all(urls.map(async (url) => {
1036
+ const normalized = await normalizeItem({ url }, upstream);
1037
+ return {
1038
+ b64: normalized.b64,
1039
+ mime: normalized.mime
1040
+ };
1041
+ })) };
760
1042
  }
761
1043
  /**
762
1044
  * Forward one generate request to the configured endpoint. The requested image
@@ -768,6 +1050,7 @@ async function generateImage(upstream, request, options = {}) {
768
1050
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
769
1051
  if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
770
1052
  if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
1053
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options);
771
1054
  if (request.mode === "edit" && isZhipuImage(wireModel(request))) throw new ImageGenError("智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型", "edit-unsupported");
772
1055
  const params = effectiveParams(request);
773
1056
  const count = effectiveCount(request);
@@ -788,7 +1071,7 @@ function upstreamMessage(payload, status) {
788
1071
  return `上游接口拒绝请求(HTTP ${status})`;
789
1072
  }
790
1073
  /** File extension for a MIME type (multipart reference image). */
791
- function extensionOf$2(mime) {
1074
+ function extensionOf$3(mime) {
792
1075
  switch (mime.split(";")[0].trim()) {
793
1076
  case "image/jpeg": return "jpg";
794
1077
  case "image/webp": return "webp";
@@ -797,6 +1080,87 @@ function extensionOf$2(mime) {
797
1080
  }
798
1081
  }
799
1082
  //#endregion
1083
+ //#region src/storage-sync.ts
1084
+ /**
1085
+ * Object-storage sync for saved images: one S3-compatible uploader (SigV4,
1086
+ * zero dependencies) that covers Tencent COS / Alibaba OSS / Qiniu S3 /
1087
+ * MinIO / R2 style endpoints, plus a fire-and-forget hook the image stores
1088
+ * call after a file lands on disk. The handler is registered by the plugin
1089
+ * root (it owns the live settings), so framework-free stores stay decoupled
1090
+ * from the settings seam.
1091
+ *
1092
+ * Object keys: `${prefix}/gallery/<file>` and `${prefix}/images/<file>` —
1093
+ * content-addressed file names dedupe re-uploads naturally.
1094
+ */
1095
+ let uploadHandler;
1096
+ /** Register the live uploader (index.ts apply). Pass undefined to clear. */
1097
+ function setStorageSyncHandler(handler) {
1098
+ uploadHandler = handler;
1099
+ }
1100
+ /** Fire-and-forget notification from the image stores after a file write. */
1101
+ function notifyImageSaved(kind, filePath) {
1102
+ try {
1103
+ uploadHandler?.(kind, filePath);
1104
+ } catch {}
1105
+ }
1106
+ /** URL-encode per RFC 3986 (AWS SigV4 canonical forms). */
1107
+ function uriEncode(value, encodeSlash = true) {
1108
+ return value.replace(/[^A-Za-z0-9-_.~]/g, (char) => {
1109
+ return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
1110
+ }).replace(/%2F/g, encodeSlash ? "%2F" : "/");
1111
+ }
1112
+ /** HMAC-SHA256 helper. */
1113
+ function hmac(key, data) {
1114
+ return createHmac("sha256", key).update(data, "utf8").digest();
1115
+ }
1116
+ /**
1117
+ * PUT one object to an S3-compatible endpoint (SigV4, virtual-hosted or
1118
+ * path-style — the endpoint URL already includes the bucket). Returns the
1119
+ * elapsed milliseconds so the settings card can show a latency reading.
1120
+ */
1121
+ async function putObject(config, key, data, contentType = "application/octet-stream") {
1122
+ const endpoint = config.endpoint.trim().replace(/\/+$/, "");
1123
+ if (endpoint === "" || config.accessKey.trim() === "" || config.secretKey.trim() === "") throw new Error("对象存储配置不完整:请填写接口地址与密钥");
1124
+ const url = new URL(`${endpoint}/${key.split("/").map((part) => uriEncode(part)).join("/")}`);
1125
+ const payloadHash = createHash("sha256").update(data).digest("hex");
1126
+ const amzDate = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "")}`;
1127
+ const dateStamp = amzDate.slice(0, 8);
1128
+ const host = url.host;
1129
+ const canonicalUri = url.pathname;
1130
+ const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
1131
+ const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
1132
+ const canonicalRequest = `PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
1133
+ const scope = `${dateStamp}/${config.region.trim() || "us-east-1"}/s3/aws4_request`;
1134
+ const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash("sha256").update(canonicalRequest, "utf8").digest("hex")}`;
1135
+ const signature = createHmac("sha256", hmac(hmac(hmac(hmac(`AWS4${config.secretKey.trim()}`, dateStamp), config.region.trim() || "us-east-1"), "s3"), "aws4_request")).update(stringToSign, "utf8").digest("hex");
1136
+ const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKey.trim()}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
1137
+ const started = Date.now();
1138
+ const response = await fetch(url, {
1139
+ method: "PUT",
1140
+ headers: {
1141
+ "content-type": contentType,
1142
+ "x-amz-content-sha256": payloadHash,
1143
+ "x-amz-date": amzDate,
1144
+ authorization
1145
+ },
1146
+ body: new Uint8Array(data)
1147
+ });
1148
+ if (!response.ok) {
1149
+ const text = await response.text().catch(() => "");
1150
+ throw new Error(`对象存储拒绝上传(HTTP ${response.status})${text !== "" ? `:${text.slice(0, 200)}` : ""}`);
1151
+ }
1152
+ return { ms: Date.now() - started };
1153
+ }
1154
+ /** Upload a small probe object; used by the settings card's test button. */
1155
+ async function testStorage(config) {
1156
+ const key = `${config.prefix.trim() || "dsh-imagegen"}/ping.txt`;
1157
+ const { ms } = await putObject(config, key, Buffer.from("dsh-imagegen storage ok", "utf8"), "text/plain");
1158
+ return {
1159
+ ms,
1160
+ key
1161
+ };
1162
+ }
1163
+ //#endregion
800
1164
  //#region src/history-store.ts
801
1165
  /**
802
1166
  * Host-persisted generation history: images are stored as individual files
@@ -817,7 +1181,7 @@ function mutateHistory(operation) {
817
1181
  return next;
818
1182
  }
819
1183
  /** File extension for a MIME type (image file names). */
820
- function extensionOf$1(mime) {
1184
+ function extensionOf$2(mime) {
821
1185
  switch (mime.split(";")[0].trim()) {
822
1186
  case "image/jpeg": return "jpg";
823
1187
  case "image/webp": return "webp";
@@ -836,7 +1200,7 @@ function mimeOfFile$2(file) {
836
1200
  }
837
1201
  }
838
1202
  /** Sanitize an entry id for use as a file-name prefix. */
839
- function safeId$1(id) {
1203
+ function safeId$2(id) {
840
1204
  const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
841
1205
  return cleaned === "" ? "entry" : cleaned;
842
1206
  }
@@ -907,7 +1271,8 @@ function toWire$1(entry) {
907
1271
  ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
908
1272
  ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
909
1273
  ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
910
- ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
1274
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel },
1275
+ ...entry.canvas === void 0 ? {} : { canvas: entry.canvas }
911
1276
  };
912
1277
  }
913
1278
  /** List the persisted history, newest first, as wire entries. */
@@ -918,13 +1283,14 @@ async function listHistory() {
918
1283
  async function appendHistory(input) {
919
1284
  return mutateHistory(async () => {
920
1285
  await ensureDirs$1();
921
- const prefix = safeId$1(input.id);
1286
+ const prefix = safeId$2(input.id);
922
1287
  const storedImages = [];
923
1288
  try {
924
1289
  for (let index = 0; index < input.images.length; index++) {
925
1290
  const image = input.images[index];
926
- const file = `${prefix}-${index}.${extensionOf$1(image.mime)}`;
1291
+ const file = `${prefix}-${index}.${extensionOf$2(image.mime)}`;
927
1292
  await promises.writeFile(path.join(IMAGES_DIR$1, file), Buffer.from(image.b64, "base64"));
1293
+ notifyImageSaved("history", path.join(IMAGES_DIR$1, file));
928
1294
  storedImages.push({
929
1295
  file,
930
1296
  mime: image.mime,
@@ -955,7 +1321,8 @@ async function appendHistory(input) {
955
1321
  ...input.projectId === void 0 ? {} : { projectId: input.projectId },
956
1322
  ...input.projectName === void 0 ? {} : { projectName: input.projectName },
957
1323
  ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
958
- ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
1324
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel },
1325
+ ...input.canvas === void 0 ? {} : { canvas: input.canvas }
959
1326
  }, ...await readIndex$1()];
960
1327
  const kept = merged.slice(0, 50);
961
1328
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -1148,7 +1515,8 @@ var ImageGenerationRuntime = class {
1148
1515
  ...request.projectId === void 0 ? {} : { projectId: request.projectId },
1149
1516
  ...request.projectName === void 0 ? {} : { projectName: request.projectName },
1150
1517
  ...request.slotKey === void 0 ? {} : { slotKey: request.slotKey },
1151
- ...request.slotLabel === void 0 ? {} : { slotLabel: request.slotLabel }
1518
+ ...request.slotLabel === void 0 ? {} : { slotLabel: request.slotLabel },
1519
+ ...request.canvas === void 0 ? {} : { canvas: request.canvas }
1152
1520
  });
1153
1521
  return {
1154
1522
  ...result,
@@ -1184,7 +1552,7 @@ function mutateGallery(operation) {
1184
1552
  return next;
1185
1553
  }
1186
1554
  /** File extension for a MIME type (image file names). */
1187
- function extensionOf(mime) {
1555
+ function extensionOf$1(mime) {
1188
1556
  switch (mime.split(";")[0].trim()) {
1189
1557
  case "image/jpeg": return "jpg";
1190
1558
  case "image/webp": return "webp";
@@ -1203,7 +1571,7 @@ function mimeOfFile$1(file) {
1203
1571
  }
1204
1572
  }
1205
1573
  /** Sanitize an entry id for use as a file-name prefix. */
1206
- function safeId(id) {
1574
+ function safeId$1(id) {
1207
1575
  const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
1208
1576
  return cleaned === "" ? "entry" : cleaned;
1209
1577
  }
@@ -1279,7 +1647,8 @@ function toWire(entry) {
1279
1647
  ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
1280
1648
  ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
1281
1649
  ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
1282
- ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
1650
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel },
1651
+ ...entry.canvas === void 0 ? {} : { canvas: entry.canvas }
1283
1652
  };
1284
1653
  }
1285
1654
  /** List the persisted gallery, newest first, as wire entries. */
@@ -1300,13 +1669,14 @@ async function appendGallery(input) {
1300
1669
  added: false
1301
1670
  };
1302
1671
  }
1303
- const prefix = safeId(input.id);
1672
+ const prefix = safeId$1(input.id);
1304
1673
  const storedImages = [];
1305
1674
  try {
1306
1675
  for (let index = 0; index < input.images.length; index++) {
1307
1676
  const image = input.images[index];
1308
- const file = `${prefix}-${index}.${extensionOf(image.mime)}`;
1677
+ const file = `${prefix}-${index}.${extensionOf$1(image.mime)}`;
1309
1678
  await promises.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, "base64"));
1679
+ notifyImageSaved("gallery", path.join(IMAGES_DIR, file));
1310
1680
  storedImages.push({
1311
1681
  file,
1312
1682
  mime: image.mime,
@@ -1336,7 +1706,8 @@ async function appendGallery(input) {
1336
1706
  ...input.projectId === void 0 ? {} : { projectId: input.projectId },
1337
1707
  ...input.projectName === void 0 ? {} : { projectName: input.projectName },
1338
1708
  ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
1339
- ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
1709
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel },
1710
+ ...input.canvas === void 0 ? {} : { canvas: input.canvas }
1340
1711
  }, ...await readIndex()];
1341
1712
  await writeIndex(merged);
1342
1713
  return {
@@ -1389,6 +1760,349 @@ async function readGalleryImage(file) {
1389
1760
  }
1390
1761
  }
1391
1762
  //#endregion
1763
+ //#region src/canvas-store.ts
1764
+ /** Host-persisted infinite canvas documents and content-addressed assets. */
1765
+ const DATA_ROOT = process.env.DSH_HOME?.trim() || path.join(homedir(), ".dsh");
1766
+ const CANVAS_ROOT = path.join(DATA_ROOT, "dsh-imagegen", "canvas");
1767
+ path.join(CANVAS_ROOT, "pages");
1768
+ path.join(CANVAS_ROOT, "assets");
1769
+ path.join(CANVAS_ROOT, "index.json");
1770
+ var CanvasConflictError = class extends Error {
1771
+ code = "canvas-conflict";
1772
+ constructor(message = "画布已在其他窗口更新,请重新加载后再保存。") {
1773
+ super(message);
1774
+ this.name = "CanvasConflictError";
1775
+ }
1776
+ };
1777
+ function extensionOf(mime) {
1778
+ switch (mime.split(";")[0].trim().toLowerCase()) {
1779
+ case "image/jpeg": return "jpg";
1780
+ case "image/webp": return "webp";
1781
+ case "image/gif": return "gif";
1782
+ default: return "png";
1783
+ }
1784
+ }
1785
+ function mimeOf(file) {
1786
+ switch (path.extname(file).toLowerCase()) {
1787
+ case ".jpg":
1788
+ case ".jpeg": return "image/jpeg";
1789
+ case ".webp": return "image/webp";
1790
+ case ".gif": return "image/gif";
1791
+ default: return "image/png";
1792
+ }
1793
+ }
1794
+ function safeId(value) {
1795
+ const id = value.replace(/[^a-zA-Z0-9_-]/g, "-");
1796
+ return id === "" ? randomUUID() : id;
1797
+ }
1798
+ async function writeJsonAtomic(file, value) {
1799
+ await promises.mkdir(path.dirname(file), { recursive: true });
1800
+ const temp = `${file}.tmp-${process.pid}-${randomUUID()}`;
1801
+ await promises.writeFile(temp, `${JSON.stringify(value)}\n`, "utf8");
1802
+ await promises.rename(temp, file);
1803
+ }
1804
+ async function readJson(file) {
1805
+ try {
1806
+ return JSON.parse(await promises.readFile(file, "utf8"));
1807
+ } catch {
1808
+ return;
1809
+ }
1810
+ }
1811
+ function defaultDocument(id, title) {
1812
+ const now = Date.now();
1813
+ return {
1814
+ version: 2,
1815
+ id,
1816
+ title,
1817
+ revision: 1,
1818
+ viewport: {
1819
+ x: 0,
1820
+ y: 0,
1821
+ k: 1
1822
+ },
1823
+ background: "dots",
1824
+ nodes: [],
1825
+ connections: [],
1826
+ createdAt: now,
1827
+ updatedAt: now
1828
+ };
1829
+ }
1830
+ function isAssetRef(value) {
1831
+ if (value === null || typeof value !== "object") return false;
1832
+ const asset = value;
1833
+ return typeof asset.assetId === "string" && typeof asset.url === "string" && typeof asset.mime === "string" && typeof asset.width === "number" && typeof asset.height === "number";
1834
+ }
1835
+ function isNode(value) {
1836
+ if (value === null || typeof value !== "object") return false;
1837
+ const node = value;
1838
+ if (typeof node.id !== "string" || typeof node.title !== "string" || typeof node.x !== "number" || typeof node.y !== "number" || typeof node.width !== "number" || typeof node.height !== "number") return false;
1839
+ if (node.type !== "image" && node.type !== "text" && node.type !== "config") return false;
1840
+ const metadata = node.metadata;
1841
+ if (metadata !== void 0 && (metadata === null || typeof metadata !== "object")) return false;
1842
+ const state = metadata ?? {};
1843
+ if (node.type === "image") {
1844
+ if (state.asset !== void 0 && !isAssetRef(state.asset)) return false;
1845
+ return state.status === void 0 || state.status === "idle" || state.status === "generating" || state.status === "success" || state.status === "error";
1846
+ }
1847
+ if (node.type === "config") return state.prompt === void 0 || typeof state.prompt === "string";
1848
+ return state.text === void 0 || typeof state.text === "string";
1849
+ }
1850
+ function isDocument(value) {
1851
+ if (value === null || typeof value !== "object") return false;
1852
+ const document = value;
1853
+ return document.version === 2 && typeof document.id === "string" && typeof document.title === "string" && typeof document.revision === "number" && document.viewport !== null && typeof document.viewport === "object" && typeof document.viewport.x === "number" && typeof document.viewport.y === "number" && typeof document.viewport.k === "number" && (document.background === "dots" || document.background === "lines" || document.background === "blank") && Array.isArray(document.nodes) && document.nodes.every(isNode) && Array.isArray(document.connections);
1854
+ }
1855
+ /** Upgrade a v1 (image/text/annotation + edges) document to the v2 node-graph model.
1856
+ * Images and text notes keep their geometry; annotation prompt cards become plain
1857
+ * text notes carrying the prompt, and old edges survive only between surviving nodes. */
1858
+ function migrateLegacyDocument(input) {
1859
+ const now = Date.now();
1860
+ const legacyNodes = Array.isArray(input.nodes) ? input.nodes : [];
1861
+ const nodes = [];
1862
+ const annotationIds = /* @__PURE__ */ new Set();
1863
+ for (const raw of legacyNodes) {
1864
+ if (raw === null || typeof raw !== "object") continue;
1865
+ const node = raw;
1866
+ if (typeof node.id !== "string" || typeof node.x !== "number" || typeof node.y !== "number") continue;
1867
+ const base = {
1868
+ id: node.id,
1869
+ title: typeof node.title === "string" ? node.title : "未命名节点",
1870
+ x: node.x,
1871
+ y: node.y,
1872
+ width: typeof node.width === "number" ? node.width : 300,
1873
+ height: typeof node.height === "number" ? node.height : 220
1874
+ };
1875
+ if (node.type === "image" && isAssetRef(node.asset)) {
1876
+ const generation = node.generation ?? {};
1877
+ nodes.push({
1878
+ ...base,
1879
+ type: "image",
1880
+ metadata: {
1881
+ asset: node.asset,
1882
+ status: typeof node.status === "string" && [
1883
+ "idle",
1884
+ "generating",
1885
+ "success",
1886
+ "error"
1887
+ ].includes(node.status) ? node.status : "success",
1888
+ ...typeof node.error === "string" ? { error: node.error } : {},
1889
+ ...typeof generation.prompt === "string" ? { prompt: generation.prompt } : {},
1890
+ ...typeof generation.model === "string" ? { model: generation.model } : {},
1891
+ ...typeof generation.taskId === "string" ? { taskId: generation.taskId } : {},
1892
+ ...typeof generation.sourceNodeId === "string" ? { sourceNodeId: generation.sourceNodeId } : {}
1893
+ }
1894
+ });
1895
+ } else if (node.type === "text") nodes.push({
1896
+ ...base,
1897
+ type: "text",
1898
+ metadata: {
1899
+ text: typeof node.text === "string" ? node.text : "",
1900
+ ...typeof node.fontSize === "number" ? { fontSize: node.fontSize } : {}
1901
+ }
1902
+ });
1903
+ else if (node.type === "annotation") {
1904
+ annotationIds.add(node.id);
1905
+ const prompt = typeof node.prompt === "string" && node.prompt.trim() !== "" ? node.prompt : "(旧版标注,提示词见此)";
1906
+ nodes.push({
1907
+ ...base,
1908
+ type: "text",
1909
+ title: "旧版标注",
1910
+ metadata: { text: prompt }
1911
+ });
1912
+ }
1913
+ }
1914
+ nodes.sort((a, b) => {
1915
+ const za = legacyNodes.find((item) => item?.id === a.id)?.zIndex;
1916
+ const zb = legacyNodes.find((item) => item?.id === b.id)?.zIndex;
1917
+ return (typeof za === "number" ? za : 0) - (typeof zb === "number" ? zb : 0);
1918
+ });
1919
+ const validIds = new Set(nodes.map((node) => node.id));
1920
+ const seen = /* @__PURE__ */ new Set();
1921
+ const connections = (Array.isArray(input.edges) ? input.edges : []).flatMap((raw) => {
1922
+ if (raw === null || typeof raw !== "object") return [];
1923
+ const edge = raw;
1924
+ if (typeof edge.fromNodeId !== "string" || typeof edge.toNodeId !== "string") return [];
1925
+ if (annotationIds.has(edge.fromNodeId) || annotationIds.has(edge.toNodeId)) return [];
1926
+ if (!validIds.has(edge.fromNodeId) || !validIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return [];
1927
+ const key = `${edge.fromNodeId}->${edge.toNodeId}`;
1928
+ if (seen.has(key)) return [];
1929
+ seen.add(key);
1930
+ return [{
1931
+ id: typeof edge.id === "string" ? edge.id : `edge-${randomUUID()}`,
1932
+ fromNodeId: edge.fromNodeId,
1933
+ toNodeId: edge.toNodeId
1934
+ }];
1935
+ });
1936
+ const legacyViewport = input.viewport ?? {};
1937
+ const background = input.background === "grid" ? "lines" : input.background === "blank" ? "blank" : "dots";
1938
+ return {
1939
+ version: 2,
1940
+ id: typeof input.id === "string" ? input.id : randomUUID(),
1941
+ title: typeof input.title === "string" ? input.title : "未命名画布",
1942
+ revision: typeof input.revision === "number" ? input.revision : 1,
1943
+ viewport: {
1944
+ x: typeof legacyViewport.x === "number" ? legacyViewport.x : 0,
1945
+ y: typeof legacyViewport.y === "number" ? legacyViewport.y : 0,
1946
+ k: typeof legacyViewport.scale === "number" && legacyViewport.scale > 0 ? legacyViewport.scale : 1
1947
+ },
1948
+ background,
1949
+ nodes,
1950
+ connections,
1951
+ createdAt: typeof input.createdAt === "number" ? input.createdAt : now,
1952
+ updatedAt: typeof input.updatedAt === "number" ? input.updatedAt : now
1953
+ };
1954
+ }
1955
+ /** Accept either the v2 document or a legacy v1 payload and return v2. */
1956
+ function coerceDocument(value) {
1957
+ if (value === null || typeof value !== "object") return void 0;
1958
+ const document = value;
1959
+ if (document.version === 1) {
1960
+ const migrated = migrateLegacyDocument(document);
1961
+ return isDocument(migrated) ? migrated : void 0;
1962
+ }
1963
+ return isDocument(value) ? value : void 0;
1964
+ }
1965
+ let mutation = Promise.resolve();
1966
+ function serialize(operation) {
1967
+ const next = mutation.then(operation, operation);
1968
+ mutation = next.then(() => void 0, () => void 0);
1969
+ return next;
1970
+ }
1971
+ var CanvasStore = class {
1972
+ root;
1973
+ constructor(root = CANVAS_ROOT) {
1974
+ this.root = root;
1975
+ }
1976
+ pagesDir() {
1977
+ return path.join(this.root, "pages");
1978
+ }
1979
+ assetsDir() {
1980
+ return path.join(this.root, "assets");
1981
+ }
1982
+ indexPath() {
1983
+ return path.join(this.root, "index.json");
1984
+ }
1985
+ async ensure() {
1986
+ await promises.mkdir(this.pagesDir(), { recursive: true });
1987
+ await promises.mkdir(this.assetsDir(), { recursive: true });
1988
+ }
1989
+ pagePath(id) {
1990
+ return path.join(this.pagesDir(), `${safeId(id)}.json`);
1991
+ }
1992
+ assetPath(id) {
1993
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return void 0;
1994
+ const target = path.join(this.assetsDir(), id);
1995
+ const relative = path.relative(this.assetsDir(), target);
1996
+ return relative.startsWith("..") || path.isAbsolute(relative) ? void 0 : target;
1997
+ }
1998
+ async readIndex() {
1999
+ const value = await readJson(this.indexPath());
2000
+ if (value === void 0 || typeof value !== "object" || !Array.isArray(value.projects)) return [];
2001
+ return value.projects.filter((item) => {
2002
+ if (item === null || typeof item !== "object") return false;
2003
+ const project = item;
2004
+ return typeof project.id === "string" && typeof project.title === "string" && typeof project.revision === "number" && typeof project.nodeCount === "number" && typeof project.createdAt === "number" && typeof project.updatedAt === "number";
2005
+ });
2006
+ }
2007
+ async writeIndex(projects) {
2008
+ await this.ensure();
2009
+ await writeJsonAtomic(this.indexPath(), { projects });
2010
+ }
2011
+ async list() {
2012
+ return this.readIndex();
2013
+ }
2014
+ async create(title = "未命名画布") {
2015
+ return serialize(async () => {
2016
+ await this.ensure();
2017
+ const id = randomUUID();
2018
+ const document = defaultDocument(id, title.trim() || "未命名画布");
2019
+ await writeJsonAtomic(this.pagePath(id), document);
2020
+ const projects = await this.readIndex();
2021
+ await this.writeIndex([this.summaryOf(document), ...projects]);
2022
+ return document;
2023
+ });
2024
+ }
2025
+ async read(id) {
2026
+ return coerceDocument(await readJson(this.pagePath(id)));
2027
+ }
2028
+ async save(document, expectedRevision) {
2029
+ return serialize(async () => {
2030
+ const incoming = coerceDocument(document);
2031
+ if (incoming === void 0) throw new Error("malformed canvas document");
2032
+ const current = await this.read(incoming.id);
2033
+ if (current !== void 0 && expectedRevision !== void 0 && current.revision !== expectedRevision) throw new CanvasConflictError();
2034
+ const next = {
2035
+ ...incoming,
2036
+ revision: Math.max(current?.revision ?? 0, incoming.revision) + 1,
2037
+ updatedAt: Date.now()
2038
+ };
2039
+ await this.ensure();
2040
+ await writeJsonAtomic(this.pagePath(next.id), next);
2041
+ const projects = (await this.readIndex()).filter((item) => item.id !== next.id);
2042
+ await this.writeIndex([this.summaryOf(next), ...projects]);
2043
+ return next;
2044
+ });
2045
+ }
2046
+ async remove(id) {
2047
+ return serialize(async () => {
2048
+ try {
2049
+ await promises.rm(this.pagePath(id), { force: true });
2050
+ } catch {}
2051
+ const projects = (await this.readIndex()).filter((item) => item.id !== id);
2052
+ await this.writeIndex(projects);
2053
+ return projects;
2054
+ });
2055
+ }
2056
+ async putImage(input) {
2057
+ if (!input.data.byteLength) throw new Error("image data is empty");
2058
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(input.mime)) throw new Error("unsupported image type");
2059
+ if (!Number.isSafeInteger(input.width) || input.width < 1 || !Number.isSafeInteger(input.height) || input.height < 1) throw new Error("image dimensions are invalid");
2060
+ await this.ensure();
2061
+ const file = `${createHash("sha256").update(input.data).digest("hex")}.${extensionOf(input.mime)}`;
2062
+ const target = path.join(this.assetsDir(), file);
2063
+ try {
2064
+ await promises.access(target);
2065
+ } catch {
2066
+ await promises.writeFile(target, input.data);
2067
+ }
2068
+ return {
2069
+ assetId: file,
2070
+ url: `/api/dsh-imagegen/canvas/asset/${file}`,
2071
+ mime: input.mime,
2072
+ bytes: input.data.byteLength,
2073
+ width: input.width,
2074
+ height: input.height,
2075
+ origin: input.origin,
2076
+ ...input.originId === void 0 ? {} : { originId: input.originId },
2077
+ ...input.entryId === void 0 ? {} : { entryId: input.entryId },
2078
+ ...input.imageIndex === void 0 ? {} : { imageIndex: input.imageIndex }
2079
+ };
2080
+ }
2081
+ async readAsset(file) {
2082
+ const target = this.assetPath(file);
2083
+ if (target === void 0) return void 0;
2084
+ try {
2085
+ return {
2086
+ data: await promises.readFile(target),
2087
+ mime: mimeOf(file)
2088
+ };
2089
+ } catch {
2090
+ return;
2091
+ }
2092
+ }
2093
+ summaryOf(document) {
2094
+ return {
2095
+ id: document.id,
2096
+ title: document.title,
2097
+ revision: document.revision,
2098
+ nodeCount: document.nodes.length,
2099
+ createdAt: document.createdAt,
2100
+ updatedAt: document.updatedAt
2101
+ };
2102
+ }
2103
+ };
2104
+ const canvasStore = new CanvasStore();
2105
+ //#endregion
1392
2106
  //#region src/templates-store.ts
1393
2107
  /**
1394
2108
  * Prompt-template library store (multi-source).
@@ -2001,6 +2715,42 @@ const IMAGE_PRESETS = [
2001
2715
  id: "glm-image"
2002
2716
  }]
2003
2717
  },
2718
+ {
2719
+ id: "aliyun-dashscope-qwen",
2720
+ name: "阿里云百炼(Qwen-Image)",
2721
+ apiUrl: "https://dashscope.aliyuncs.com/api/v1",
2722
+ hint: "阿里云百炼 DashScope 原生接口:通义千问 Qwen-Image 系列(该渠道不可复用于提示词增强)",
2723
+ models: [
2724
+ {
2725
+ alias: "qwen-image-3.0-pro",
2726
+ id: "qwen-image-3.0-pro"
2727
+ },
2728
+ {
2729
+ alias: "qwen-image-3.0",
2730
+ id: "qwen-image-3.0"
2731
+ },
2732
+ {
2733
+ alias: "qwen-image-2.0-pro",
2734
+ id: "qwen-image-2.0-pro"
2735
+ },
2736
+ {
2737
+ alias: "qwen-image-2.0",
2738
+ id: "qwen-image-2.0"
2739
+ },
2740
+ {
2741
+ alias: "qwen-image-max",
2742
+ id: "qwen-image-max"
2743
+ },
2744
+ {
2745
+ alias: "qwen-image-plus",
2746
+ id: "qwen-image-plus"
2747
+ },
2748
+ {
2749
+ alias: "qwen-image",
2750
+ id: "qwen-image"
2751
+ }
2752
+ ]
2753
+ },
2004
2754
  {
2005
2755
  id: "xai-grok",
2006
2756
  name: "xAI(Grok)",
@@ -2084,6 +2834,7 @@ function parseGenerateRequest(body) {
2084
2834
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
2085
2835
  if (prompt === "") return void 0;
2086
2836
  const comparisonModels = Array.isArray(body.comparisonModels) ? [...new Set(body.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
2837
+ const canvas = parseCanvasMeta(body.canvas);
2087
2838
  return {
2088
2839
  mode: body.mode === "edit" ? "edit" : "text",
2089
2840
  model: typeof body.model === "string" ? body.model : "",
@@ -2097,6 +2848,7 @@ function parseGenerateRequest(body) {
2097
2848
  ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
2098
2849
  ...typeof body.comparisonId === "string" && body.comparisonId !== "" ? { comparisonId: body.comparisonId } : {},
2099
2850
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
2851
+ ...canvas === void 0 ? {} : { canvas },
2100
2852
  ...body.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
2101
2853
  ...typeof body.projectId === "string" && body.projectId !== "" ? { projectId: body.projectId } : {},
2102
2854
  ...typeof body.projectName === "string" && body.projectName !== "" ? { projectName: body.projectName } : {},
@@ -2104,6 +2856,18 @@ function parseGenerateRequest(body) {
2104
2856
  ...typeof body.slotLabel === "string" && body.slotLabel !== "" ? { slotLabel: body.slotLabel } : {}
2105
2857
  };
2106
2858
  }
2859
+ function parseCanvasMeta(value) {
2860
+ if (value === null || typeof value !== "object") return void 0;
2861
+ const raw = value;
2862
+ if (typeof raw.canvasId !== "string" || raw.canvasId.trim() === "") return void 0;
2863
+ return {
2864
+ canvasId: raw.canvasId.trim(),
2865
+ ...typeof raw.sourceNodeId === "string" && raw.sourceNodeId.trim() !== "" ? { sourceNodeId: raw.sourceNodeId.trim() } : {},
2866
+ ...typeof raw.annotationNodeId === "string" && raw.annotationNodeId.trim() !== "" ? { annotationNodeId: raw.annotationNodeId.trim() } : {},
2867
+ ...typeof raw.parentNodeId === "string" && raw.parentNodeId.trim() !== "" ? { parentNodeId: raw.parentNodeId.trim() } : {},
2868
+ ...raw.placement === "right" || raw.placement === "below" ? { placement: raw.placement } : {}
2869
+ };
2870
+ }
2107
2871
  /** Validate a submitted history entry (images carry base64). */
2108
2872
  function parseHistoryEntryInput(body) {
2109
2873
  const raw = body.entry;
@@ -2127,6 +2891,7 @@ function parseHistoryEntryInput(body) {
2127
2891
  });
2128
2892
  }
2129
2893
  const comparisonModels = Array.isArray(entry.comparisonModels) ? [...new Set(entry.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
2894
+ const canvas = parseCanvasMeta(entry.canvas);
2130
2895
  return {
2131
2896
  id: entry.id,
2132
2897
  createdAt: entry.createdAt,
@@ -2143,6 +2908,7 @@ function parseHistoryEntryInput(body) {
2143
2908
  ...typeof entry.channel === "string" ? { channel: entry.channel } : {},
2144
2909
  ...typeof entry.comparisonId === "string" ? { comparisonId: entry.comparisonId } : {},
2145
2910
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
2911
+ ...canvas === void 0 ? {} : { canvas },
2146
2912
  ...entry.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
2147
2913
  ...typeof entry.projectId === "string" ? { projectId: entry.projectId } : {},
2148
2914
  ...typeof entry.projectName === "string" ? { projectName: entry.projectName } : {},
@@ -2247,6 +3013,7 @@ function makeRoutes(deps) {
2247
3013
  updateTags: updateGalleryTags,
2248
3014
  readImage: readGalleryImage
2249
3015
  };
3016
+ const canvas = deps.canvas ?? canvasStore;
2250
3017
  const templates = deps.templates ?? {
2251
3018
  list: listTemplates,
2252
3019
  refresh: refreshTemplates,
@@ -3086,6 +3853,253 @@ function makeRoutes(deps) {
3086
3853
  res.end(found.data);
3087
3854
  }
3088
3855
  },
3856
+ {
3857
+ kind: "exact",
3858
+ path: CANVAS_API.list,
3859
+ handler: async (req, res) => {
3860
+ if (!guard(req, res, "POST")) return;
3861
+ try {
3862
+ writeJson(res, 200, {
3863
+ ok: true,
3864
+ projects: await canvas.list()
3865
+ });
3866
+ } catch (error) {
3867
+ writeJson(res, 200, {
3868
+ ok: false,
3869
+ code: "canvas-failed",
3870
+ message: messageOf(error)
3871
+ });
3872
+ }
3873
+ }
3874
+ },
3875
+ {
3876
+ kind: "exact",
3877
+ path: CANVAS_API.create,
3878
+ handler: async (req, res) => {
3879
+ if (!guard(req, res, "POST")) return;
3880
+ const body = await readJsonBody(req);
3881
+ const title = typeof body?.title === "string" ? body.title : "未命名画布";
3882
+ try {
3883
+ writeJson(res, 200, {
3884
+ ok: true,
3885
+ document: await canvas.create(title)
3886
+ });
3887
+ } catch (error) {
3888
+ writeJson(res, 200, {
3889
+ ok: false,
3890
+ code: "canvas-failed",
3891
+ message: messageOf(error)
3892
+ });
3893
+ }
3894
+ }
3895
+ },
3896
+ {
3897
+ kind: "exact",
3898
+ path: CANVAS_API.read,
3899
+ handler: async (req, res) => {
3900
+ if (!guard(req, res, "POST")) return;
3901
+ const body = await readJsonBody(req);
3902
+ const id = typeof body?.id === "string" ? body.id : "";
3903
+ const document = id === "" ? void 0 : await canvas.read(id);
3904
+ if (document === void 0) writeJson(res, 200, {
3905
+ ok: false,
3906
+ code: "not-found",
3907
+ message: "画布不存在"
3908
+ });
3909
+ else writeJson(res, 200, {
3910
+ ok: true,
3911
+ document
3912
+ });
3913
+ }
3914
+ },
3915
+ {
3916
+ kind: "exact",
3917
+ path: CANVAS_API.save,
3918
+ handler: async (req, res) => {
3919
+ if (!guard(req, res, "POST")) return;
3920
+ const body = await readJsonBody(req);
3921
+ const document = body?.document;
3922
+ const expectedRevision = typeof body?.expectedRevision === "number" ? body.expectedRevision : void 0;
3923
+ if (document === void 0 || typeof document !== "object") {
3924
+ writeJson(res, 200, {
3925
+ ok: false,
3926
+ code: "bad-request",
3927
+ message: "canvas document is required"
3928
+ });
3929
+ return;
3930
+ }
3931
+ try {
3932
+ writeJson(res, 200, {
3933
+ ok: true,
3934
+ document: await canvas.save(document, expectedRevision)
3935
+ });
3936
+ } catch (error) {
3937
+ writeJson(res, 200, {
3938
+ ok: false,
3939
+ code: error instanceof CanvasConflictError ? error.code : "canvas-failed",
3940
+ message: messageOf(error)
3941
+ });
3942
+ }
3943
+ }
3944
+ },
3945
+ {
3946
+ kind: "exact",
3947
+ path: CANVAS_API.remove,
3948
+ handler: async (req, res) => {
3949
+ if (!guard(req, res, "POST")) return;
3950
+ const body = await readJsonBody(req);
3951
+ const id = typeof body?.id === "string" ? body.id : "";
3952
+ if (id === "") {
3953
+ writeJson(res, 200, {
3954
+ ok: false,
3955
+ code: "bad-request",
3956
+ message: "canvas id is required"
3957
+ });
3958
+ return;
3959
+ }
3960
+ try {
3961
+ writeJson(res, 200, {
3962
+ ok: true,
3963
+ projects: await canvas.remove(id)
3964
+ });
3965
+ } catch (error) {
3966
+ writeJson(res, 200, {
3967
+ ok: false,
3968
+ code: "canvas-failed",
3969
+ message: messageOf(error)
3970
+ });
3971
+ }
3972
+ }
3973
+ },
3974
+ {
3975
+ kind: "exact",
3976
+ path: CANVAS_API.assetUpload,
3977
+ handler: async (req, res) => {
3978
+ if (!guard(req, res, "POST")) return;
3979
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES);
3980
+ const parsed = typeof body?.dataUrl === "string" ? imageDataUrl$1(body.dataUrl) : void 0;
3981
+ const width = Number(body?.width);
3982
+ const height = Number(body?.height);
3983
+ if (parsed === void 0 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
3984
+ writeJson(res, 200, {
3985
+ ok: false,
3986
+ code: "bad-request",
3987
+ message: "image data and dimensions are required"
3988
+ });
3989
+ return;
3990
+ }
3991
+ try {
3992
+ writeJson(res, 200, {
3993
+ ok: true,
3994
+ asset: await canvas.putImage({
3995
+ data: parsed.data,
3996
+ mime: parsed.mediaType,
3997
+ width,
3998
+ height,
3999
+ origin: body?.origin === "history" || body?.origin === "gallery" || body?.origin === "generated" ? body.origin : "upload",
4000
+ ...typeof body?.originId === "string" ? { originId: body.originId } : {},
4001
+ ...typeof body?.entryId === "string" ? { entryId: body.entryId } : {},
4002
+ ...Number.isSafeInteger(Number(body?.imageIndex)) ? { imageIndex: Number(body?.imageIndex) } : {}
4003
+ })
4004
+ });
4005
+ } catch (error) {
4006
+ writeJson(res, 200, {
4007
+ ok: false,
4008
+ code: "canvas-asset-failed",
4009
+ message: messageOf(error)
4010
+ });
4011
+ }
4012
+ }
4013
+ },
4014
+ {
4015
+ kind: "exact",
4016
+ path: CANVAS_API.assetImport,
4017
+ handler: async (req, res) => {
4018
+ if (!guard(req, res, "POST")) return;
4019
+ const body = await readJsonBody(req);
4020
+ const source = body?.source === "history" || body?.source === "gallery" ? body.source : void 0;
4021
+ const entryId = typeof body?.entryId === "string" ? body.entryId : "";
4022
+ const imageIndex = Number(body?.imageIndex);
4023
+ const width = Number(body?.width);
4024
+ const height = Number(body?.height);
4025
+ if (source === void 0 || entryId === "" || !Number.isSafeInteger(imageIndex) || imageIndex < 0 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
4026
+ writeJson(res, 200, {
4027
+ ok: false,
4028
+ code: "bad-request",
4029
+ message: "source, entryId, imageIndex and dimensions are required"
4030
+ });
4031
+ return;
4032
+ }
4033
+ try {
4034
+ const backend = source === "history" ? history : gallery;
4035
+ const image = (await backend.list()).find((item) => item.id === entryId)?.images[imageIndex];
4036
+ if (image === void 0) {
4037
+ writeJson(res, 200, {
4038
+ ok: false,
4039
+ code: "not-found",
4040
+ message: "image not found"
4041
+ });
4042
+ return;
4043
+ }
4044
+ const base = source === "history" ? HISTORY_API.image : GALLERY_API.image;
4045
+ const file = imageFileFrom(image.url, base);
4046
+ const found = file === void 0 ? void 0 : await backend.readImage(file);
4047
+ if (found === void 0) {
4048
+ writeJson(res, 200, {
4049
+ ok: false,
4050
+ code: "not-found",
4051
+ message: "image not found"
4052
+ });
4053
+ return;
4054
+ }
4055
+ writeJson(res, 200, {
4056
+ ok: true,
4057
+ asset: await canvas.putImage({
4058
+ data: found.data,
4059
+ mime: found.mime,
4060
+ width,
4061
+ height,
4062
+ origin: source,
4063
+ originId: entryId,
4064
+ entryId,
4065
+ imageIndex
4066
+ })
4067
+ });
4068
+ } catch (error) {
4069
+ writeJson(res, 200, {
4070
+ ok: false,
4071
+ code: "canvas-asset-failed",
4072
+ message: messageOf(error)
4073
+ });
4074
+ }
4075
+ }
4076
+ },
4077
+ {
4078
+ kind: "prefix",
4079
+ path: CANVAS_API.asset,
4080
+ handler: async (req, res) => {
4081
+ if (!isLoopbackRequest(req)) {
4082
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4083
+ return;
4084
+ }
4085
+ if (req.method !== "GET") {
4086
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
4087
+ return;
4088
+ }
4089
+ const file = imageFileFrom(req.url, CANVAS_API.asset);
4090
+ const found = file === void 0 ? void 0 : await canvas.readAsset(file);
4091
+ if (found === void 0) {
4092
+ writeJson(res, 404, { error: "not found" });
4093
+ return;
4094
+ }
4095
+ res.writeHead(200, {
4096
+ "content-type": found.mime,
4097
+ "content-length": found.data.length,
4098
+ "cache-control": "private, max-age=3600"
4099
+ });
4100
+ res.end(found.data);
4101
+ }
4102
+ },
3089
4103
  {
3090
4104
  kind: "exact",
3091
4105
  path: TEMPLATES_API.list,
@@ -3288,6 +4302,71 @@ function makeRoutes(deps) {
3288
4302
  });
3289
4303
  }
3290
4304
  }
4305
+ },
4306
+ {
4307
+ kind: "exact",
4308
+ path: DATA_FOLDER_API,
4309
+ handler: async (req, res) => {
4310
+ if (!guard(req, res, "POST")) return;
4311
+ const body = await readJsonBody(req);
4312
+ const dir = path.join(homedir(), ".dsh", "dsh-imagegen");
4313
+ try {
4314
+ await mkdir(dir, { recursive: true });
4315
+ } catch {}
4316
+ if (body?.open === false) {
4317
+ writeJson(res, 200, {
4318
+ ok: true,
4319
+ path: dir
4320
+ });
4321
+ return;
4322
+ }
4323
+ try {
4324
+ spawn(process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open", [dir], {
4325
+ detached: true,
4326
+ stdio: "ignore"
4327
+ }).unref();
4328
+ writeJson(res, 200, {
4329
+ ok: true,
4330
+ path: dir
4331
+ });
4332
+ } catch (error) {
4333
+ writeJson(res, 200, {
4334
+ ok: false,
4335
+ code: "data-folder-failed",
4336
+ message: messageOf(error)
4337
+ });
4338
+ }
4339
+ }
4340
+ },
4341
+ {
4342
+ kind: "exact",
4343
+ path: STORAGE_API.test,
4344
+ handler: async (req, res) => {
4345
+ if (!guard(req, res, "POST")) return;
4346
+ const storage = deps.resolveStorage?.();
4347
+ if (storage === void 0) {
4348
+ writeJson(res, 200, {
4349
+ ok: false,
4350
+ code: "storage-unavailable",
4351
+ message: "存储配置不可用"
4352
+ });
4353
+ return;
4354
+ }
4355
+ try {
4356
+ const result = await testStorage(storage);
4357
+ writeJson(res, 200, {
4358
+ ok: true,
4359
+ ms: result.ms,
4360
+ key: result.key
4361
+ });
4362
+ } catch (error) {
4363
+ writeJson(res, 200, {
4364
+ ok: false,
4365
+ code: "storage-test-failed",
4366
+ message: messageOf(error)
4367
+ });
4368
+ }
4369
+ }
3291
4370
  }
3292
4371
  ];
3293
4372
  }
@@ -3841,6 +4920,16 @@ function registerEditImageCommand(ctx, runtime, resolve, pendingImages) {
3841
4920
  }
3842
4921
  //#endregion
3843
4922
  //#region src/index.ts
4923
+ /** Content type for a saved image file name (object uploads). */
4924
+ function mimeOfPath(filePath) {
4925
+ switch (path.extname(filePath).toLowerCase()) {
4926
+ case ".jpg":
4927
+ case ".jpeg": return "image/jpeg";
4928
+ case ".webp": return "image/webp";
4929
+ case ".gif": return "image/gif";
4930
+ default: return "image/png";
4931
+ }
4932
+ }
3844
4933
  /** Stable cordis plugin name. */
3845
4934
  const name = "imagegen";
3846
4935
  /** Services required before the surfaces can mount. */
@@ -3870,6 +4959,14 @@ const Config = z.object({
3870
4959
  promptApiUrl: z.string().default(""),
3871
4960
  promptApiKey: z.string().role("secret").default(""),
3872
4961
  promptModel: z.string().default(""),
4962
+ storageEnabled: z.boolean().default(false),
4963
+ storageEndpoint: z.string().default(""),
4964
+ storageRegion: z.string().default(""),
4965
+ storagePrefix: z.string().default("dsh-imagegen"),
4966
+ storageAccessKey: z.string().default(""),
4967
+ storageSecretKey: z.string().role("secret").default(""),
4968
+ storageSyncGallery: z.boolean().default(true),
4969
+ storageSyncHistory: z.boolean().default(false),
3873
4970
  apiUrl: z.string().default(""),
3874
4971
  apiKey: z.string().role("secret").default(""),
3875
4972
  imageModels: z.array(z.string()).default([])
@@ -3881,7 +4978,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
3881
4978
  /** Order of the announcement section within the tool-guidance band. */
3882
4979
  const SECTION_ORDER = 150;
3883
4980
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3884
- 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 的数百条提示词案例,可搜索、筛选、收藏(星标,宿主持久化)与复用;各来源列表独立刷新,宿主每 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)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
4981
+ 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)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3885
4982
  /** Append the live channel × model table so an Agent can honor user choices. */
3886
4983
  function guidanceFor(channels, defaultChannelId) {
3887
4984
  if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
@@ -3970,7 +5067,17 @@ function apply(ctx, config) {
3970
5067
  defaultChannelId,
3971
5068
  promptApiUrl: typeof value.promptApiUrl === "string" ? value.promptApiUrl.trim() : "",
3972
5069
  promptApiKey: typeof value.promptApiKey === "string" ? value.promptApiKey.trim() : "",
3973
- promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : ""
5070
+ promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : "",
5071
+ storage: {
5072
+ enabled: value.storageEnabled ?? false,
5073
+ endpoint: typeof value.storageEndpoint === "string" ? value.storageEndpoint.trim() : "",
5074
+ region: typeof value.storageRegion === "string" ? value.storageRegion.trim() : "",
5075
+ accessKey: typeof value.storageAccessKey === "string" ? value.storageAccessKey.trim() : "",
5076
+ secretKey: typeof value.storageSecretKey === "string" ? value.storageSecretKey.trim() : "",
5077
+ prefix: typeof value.storagePrefix === "string" && value.storagePrefix.trim() !== "" ? value.storagePrefix.trim() : "dsh-imagegen",
5078
+ syncGallery: value.storageSyncGallery ?? true,
5079
+ syncHistory: value.storageSyncHistory ?? false
5080
+ }
3974
5081
  };
3975
5082
  };
3976
5083
  const channelsView = () => {
@@ -3980,6 +5087,13 @@ function apply(ctx, config) {
3980
5087
  defaultChannelId: value.defaultChannelId
3981
5088
  };
3982
5089
  };
5090
+ setStorageSyncHandler((kind, filePath) => {
5091
+ const storage = resolve().storage;
5092
+ if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === "") return;
5093
+ if (kind === "gallery" && !storage.syncGallery) return;
5094
+ if (kind === "history" && !storage.syncHistory) return;
5095
+ putObject(storage, `${storage.prefix}/${kind === "gallery" ? "gallery" : "images"}/${path.basename(filePath)}`, readFileSync(filePath), mimeOfPath(filePath)).catch(() => {});
5096
+ });
3983
5097
  const runtime = new ImageGenerationRuntime(channelsView);
3984
5098
  const pendingConversationImages = /* @__PURE__ */ new Map();
3985
5099
  ctx.inject(["settings", "attachments"], (sctx) => {
@@ -4011,7 +5125,8 @@ function apply(ctx, config) {
4011
5125
  },
4012
5126
  attachments: sctx.attachments,
4013
5127
  pendingConversationImages,
4014
- runtime
5128
+ runtime,
5129
+ resolveStorage: () => resolve().storage
4015
5130
  }).map((route) => ctx.webServer.register(route));
4016
5131
  const TEMPLATE_SYNC_INITIAL_DELAY_MS = 3e4;
4017
5132
  const TEMPLATE_SYNC_INTERVAL_MS = 720 * 60 * 1e3;
@@ -4080,6 +5195,9 @@ function apply(ctx, config) {
4080
5195
  onChange: sync
4081
5196
  });
4082
5197
  sync();
5198
+ return () => {
5199
+ setStorageSyncHandler(void 0);
5200
+ };
4083
5201
  }
4084
5202
  //#endregion
4085
- 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 };
5203
+ 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, putObject, readGalleryImage, readTemplateImage, refreshTemplates, registerAgentImageTools, registerEditImageCommand, removeGallery, removeTemplateFavorite, sampleTemplates, setStorageSyncHandler, syncAllTemplates, testStorage, updateGalleryTags };