@dickpy/dsh-imagegen 1.5.3 → 1.5.5

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.3";
44
+ const PLUGIN_VERSION = "1.5.5";
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},
@@ -572,8 +588,13 @@ function requestSignal(source, timeoutMs) {
572
588
  }
573
589
  };
574
590
  }
575
- /** Content-type extension hints for URL-fetched images. */
576
- function mimeOfExtension(path) {
591
+ /** Whether an error was produced by a requestSignal budget timeout. These can
592
+ * surface from the fetch call itself or from reading the response body, so the
593
+ * budget must stay armed until the body has been consumed. */
594
+ function isBudgetTimeout(error) {
595
+ return (error instanceof DOMException || error instanceof Error) && error.name === "TimeoutError";
596
+ }
597
+ /** Content-type extension hints for URL-fetched images. */ function mimeOfExtension(path) {
577
598
  const match = /\.([a-z0-9]+)$/i.exec(path);
578
599
  if (match === null) return void 0;
579
600
  switch (match[1].toLowerCase()) {
@@ -667,7 +688,7 @@ function effectiveCount(request) {
667
688
  return clampCount(request.n);
668
689
  }
669
690
  /** Normalize one upstream data item into a base64 image. */
670
- async function normalizeItem(item, upstream) {
691
+ async function normalizeItem(item, upstream, signal) {
671
692
  const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
672
693
  if (typeof item.b64_json === "string" && item.b64_json.trim() !== "") {
673
694
  const b64 = bareBase64(item.b64_json);
@@ -688,27 +709,168 @@ async function normalizeItem(item, upstream) {
688
709
  revisedPrompt
689
710
  };
690
711
  }
691
- const budget = requestSignal(void 0, IMAGE_FETCH_TIMEOUT_MS);
692
- let response;
712
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS);
693
713
  try {
694
- response = await fetch(url, {
695
- ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
696
- signal: budget.signal
697
- });
698
- } catch (error) {
699
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
714
+ let response;
715
+ try {
716
+ response = await fetch(url, {
717
+ ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
718
+ signal: budget.signal
719
+ });
720
+ } catch (error) {
721
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
722
+ }
723
+ if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
724
+ const buffer = Buffer.from(await response.arrayBuffer());
725
+ const contentType = response.headers.get("content-type");
726
+ const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
727
+ return {
728
+ b64: buffer.toString("base64"),
729
+ mime,
730
+ revisedPrompt
731
+ };
700
732
  } finally {
701
733
  budget.dispose();
702
734
  }
703
- if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
704
- const buffer = Buffer.from(await response.arrayBuffer());
705
- const contentType = response.headers.get("content-type");
706
- const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
707
- return {
708
- b64: buffer.toString("base64"),
709
- mime,
710
- revisedPrompt
711
- };
735
+ }
736
+ /** Expand a provider image item whose URL may be a string or an array. */
737
+ function imageItemsOf(value) {
738
+ if (value === null || typeof value !== "object") return [];
739
+ const item = value;
740
+ if (Array.isArray(item.url)) return item.url.filter((url) => typeof url === "string" && url !== "").map((url) => ({
741
+ ...item,
742
+ url
743
+ }));
744
+ return [item];
745
+ }
746
+ /** Return the data records from the response shapes shared by sync gateways. */
747
+ function dataRecordsOf(payload) {
748
+ 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;
749
+ if (data === void 0) return void 0;
750
+ return data.filter((entry) => entry !== null && typeof entry === "object");
751
+ }
752
+ const ASYNC_PENDING_STATUSES = /* @__PURE__ */ new Set([
753
+ "submitted",
754
+ "pending",
755
+ "processing",
756
+ "running",
757
+ "in_progress",
758
+ "queued"
759
+ ]);
760
+ const ASYNC_COMPLETED_STATUSES = /* @__PURE__ */ new Set([
761
+ "completed",
762
+ "succeeded",
763
+ "success",
764
+ "done"
765
+ ]);
766
+ const ASYNC_FAILED_STATUSES = /* @__PURE__ */ new Set([
767
+ "failed",
768
+ "failure",
769
+ "cancelled",
770
+ "canceled",
771
+ "error"
772
+ ]);
773
+ const ASYNC_POLL_MAX_MS = 24e4;
774
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 3e4;
775
+ /** Read a provider error message from the common nested locations. */
776
+ function asyncErrorMessage(payload, fallback) {
777
+ if (payload !== null && typeof payload === "object") {
778
+ const record = payload;
779
+ const candidates = [record.message, record.error];
780
+ const data = record.data;
781
+ const entries = Array.isArray(data) ? data : [data];
782
+ for (const entry of entries) {
783
+ if (entry === null || typeof entry !== "object") continue;
784
+ const item = entry;
785
+ candidates.push(item.message, item.error);
786
+ const nested = item.error;
787
+ if (nested !== null && typeof nested === "object") candidates.push(nested.message);
788
+ }
789
+ for (const candidate of candidates) {
790
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
791
+ if (candidate !== null && typeof candidate === "object") {
792
+ const message = candidate.message;
793
+ if (typeof message === "string" && message.trim() !== "") return message;
794
+ }
795
+ }
796
+ }
797
+ return fallback;
798
+ }
799
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
800
+ function waitForPoll(ms, signal) {
801
+ return new Promise((resolve, reject) => {
802
+ if (signal?.aborted === true) {
803
+ reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
804
+ return;
805
+ }
806
+ const onAbort = () => {
807
+ clearTimeout(timer);
808
+ signal?.removeEventListener("abort", onAbort);
809
+ reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
810
+ };
811
+ const done = () => {
812
+ signal?.removeEventListener("abort", onAbort);
813
+ resolve();
814
+ };
815
+ const timer = setTimeout(done, ms);
816
+ timer.unref();
817
+ signal?.addEventListener("abort", onAbort, { once: true });
818
+ });
819
+ }
820
+ /**
821
+ * Poll one apib/apimart-style provider task until it yields image records.
822
+ * The total deadline is shared by every poll and the final image downloads;
823
+ * local task cancellation propagates through every request and sleep.
824
+ */
825
+ async function pollAsyncTask(baseUrl, upstream, taskId, signal) {
826
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS;
827
+ let delay = 1e3;
828
+ while (Date.now() < deadline) {
829
+ const remaining = deadline - Date.now();
830
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining));
831
+ try {
832
+ let response;
833
+ try {
834
+ response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
835
+ method: "GET",
836
+ headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
837
+ signal: budget.signal
838
+ });
839
+ } catch (error) {
840
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
841
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
842
+ throw new ImageGenError(`无法轮询上游异步任务:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
843
+ }
844
+ let payload;
845
+ try {
846
+ payload = await response.json();
847
+ } catch (error) {
848
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
849
+ throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
850
+ }
851
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), "upstream-rejected");
852
+ const record = payload;
853
+ const data = record.data;
854
+ const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === "object" ? data : record;
855
+ const statusValue = statusRecord !== null && typeof statusRecord === "object" ? statusRecord.status : void 0;
856
+ const status = typeof statusValue === "string" ? statusValue.toLowerCase() : "";
857
+ if (ASYNC_FAILED_STATUSES.has(status)) throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || "unknown"})`), "upstream-rejected");
858
+ const nested = statusRecord !== null && typeof statusRecord === "object" ? statusRecord : record;
859
+ const result = nested.result ?? (nested.output !== null && typeof nested.output === "object" ? nested.output.result : void 0) ?? record.result;
860
+ const images = (result !== null && typeof result === "object" ? result : void 0)?.images ?? nested.images ?? record.images;
861
+ if (ASYNC_COMPLETED_STATUSES.has(status) || images !== void 0) {
862
+ const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images);
863
+ if (items.length > 0) return items;
864
+ if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
865
+ }
866
+ if (status !== "" && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, "upstream-invalid");
867
+ } finally {
868
+ budget.dispose();
869
+ }
870
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal);
871
+ delay = Math.min(5e3, delay * 2);
872
+ }
873
+ throw new ImageGenError("上游异步任务轮询超时(240 秒)", "upstream-timeout");
712
874
  }
713
875
  /**
714
876
  * Issue one single-image request (never sends `n`). The response is kept as a
@@ -742,7 +904,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
742
904
  });
743
905
  } else if (isNanoBanana(params.model)) {
744
906
  const form = new FormData();
745
- form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
907
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$3(parsed.mime)}`);
746
908
  form.append("prompt", request.prompt);
747
909
  form.append("model", params.model);
748
910
  if (params.aspect_ratio !== void 0) form.append("aspect_ratio", params.aspect_ratio);
@@ -760,7 +922,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
760
922
  });
761
923
  } else {
762
924
  const form = new FormData();
763
- form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
925
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$3(parsed.mime)}`);
764
926
  form.append("prompt", request.prompt);
765
927
  form.append("model", params.model);
766
928
  if (params.size !== void 0) form.append("size", params.size);
@@ -776,37 +938,43 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
776
938
  });
777
939
  }
778
940
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
779
- let response;
780
941
  try {
781
- const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
782
- response = await fetch(`${baseUrl}${endpoint}`, {
783
- method: "POST",
784
- headers,
785
- body,
786
- signal: budget.signal
787
- });
788
- } catch (error) {
789
- const message = error instanceof Error ? error.message : String(error);
790
- if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
791
- throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
942
+ let response;
943
+ try {
944
+ const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
945
+ response = await fetch(`${baseUrl}${endpoint}`, {
946
+ method: "POST",
947
+ headers,
948
+ body,
949
+ signal: budget.signal
950
+ });
951
+ } catch (error) {
952
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
953
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
954
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
955
+ }
956
+ let payload;
957
+ try {
958
+ payload = await response.json();
959
+ } catch (error) {
960
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
961
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
962
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
963
+ }
964
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
965
+ const data = dataRecordsOf(payload);
966
+ if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
967
+ if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
968
+ const asyncEntries = data.filter((entry) => typeof entry.task_id === "string" && entry.task_id.trim() !== "");
969
+ if (asyncEntries.length > 0) {
970
+ const asyncRecords = (await Promise.all(asyncEntries.map((entry) => pollAsyncTask(baseUrl, upstream, entry.task_id, signal)))).flat();
971
+ if (asyncRecords.length === 0) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
972
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
973
+ }
974
+ return Promise.all(data.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
792
975
  } finally {
793
976
  budget.dispose();
794
977
  }
795
- let payload;
796
- try {
797
- payload = await response.json();
798
- } catch {
799
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
800
- }
801
- if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
802
- const record = payload;
803
- const data = Array.isArray(record.data) ? record.data : Array.isArray(record.images) ? record.images : Array.isArray(record.output) ? record.output : void 0;
804
- if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
805
- if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
806
- return Promise.all(data.map(async (entry) => {
807
- if (entry === null || typeof entry !== "object") throw new ImageGenError("上游响应包含无效的图片条目", "upstream-invalid");
808
- return normalizeItem(entry, upstream);
809
- }));
810
978
  }
811
979
  /**
812
980
  * Qwen-Image (DashScope native multimodal-generation): one chat-style request
@@ -840,50 +1008,54 @@ async function generateQwenImage(baseUrl, upstream, request, options) {
840
1008
  }
841
1009
  };
842
1010
  const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
843
- let response;
844
1011
  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");
1012
+ let response;
1013
+ try {
1014
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
1015
+ method: "POST",
1016
+ headers: {
1017
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
1018
+ "content-type": "application/json"
1019
+ },
1020
+ body: JSON.stringify(body),
1021
+ signal: budget.signal
1022
+ });
1023
+ } catch (error) {
1024
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1025
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1026
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1027
+ }
1028
+ let payload;
1029
+ try {
1030
+ payload = await response.json();
1031
+ } catch (error) {
1032
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1033
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1034
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1035
+ }
1036
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1037
+ const output = payload.output;
1038
+ const choices = output !== void 0 && Array.isArray(output.choices) ? output.choices : [];
1039
+ const urls = [];
1040
+ for (const choice of choices) {
1041
+ const message = choice !== null && typeof choice === "object" ? choice.message : void 0;
1042
+ const items = message !== null && typeof message === "object" && Array.isArray(message.content) ? message.content : [];
1043
+ for (const item of items) if (item !== null && typeof item === "object") {
1044
+ const image = item.image;
1045
+ if (typeof image === "string" && image !== "") urls.push(image);
1046
+ }
1047
+ }
1048
+ if (urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
1049
+ return { images: await Promise.all(urls.map(async (url) => {
1050
+ const normalized = await normalizeItem({ url }, upstream);
1051
+ return {
1052
+ b64: normalized.b64,
1053
+ mime: normalized.mime
1054
+ };
1055
+ })) };
858
1056
  } finally {
859
1057
  budget.dispose();
860
1058
  }
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
1059
  }
888
1060
  /**
889
1061
  * Forward one generate request to the configured endpoint. The requested image
@@ -916,7 +1088,7 @@ function upstreamMessage(payload, status) {
916
1088
  return `上游接口拒绝请求(HTTP ${status})`;
917
1089
  }
918
1090
  /** File extension for a MIME type (multipart reference image). */
919
- function extensionOf$2(mime) {
1091
+ function extensionOf$3(mime) {
920
1092
  switch (mime.split(";")[0].trim()) {
921
1093
  case "image/jpeg": return "jpg";
922
1094
  case "image/webp": return "webp";
@@ -925,6 +1097,87 @@ function extensionOf$2(mime) {
925
1097
  }
926
1098
  }
927
1099
  //#endregion
1100
+ //#region src/storage-sync.ts
1101
+ /**
1102
+ * Object-storage sync for saved images: one S3-compatible uploader (SigV4,
1103
+ * zero dependencies) that covers Tencent COS / Alibaba OSS / Qiniu S3 /
1104
+ * MinIO / R2 style endpoints, plus a fire-and-forget hook the image stores
1105
+ * call after a file lands on disk. The handler is registered by the plugin
1106
+ * root (it owns the live settings), so framework-free stores stay decoupled
1107
+ * from the settings seam.
1108
+ *
1109
+ * Object keys: `${prefix}/gallery/<file>` and `${prefix}/images/<file>` —
1110
+ * content-addressed file names dedupe re-uploads naturally.
1111
+ */
1112
+ let uploadHandler;
1113
+ /** Register the live uploader (index.ts apply). Pass undefined to clear. */
1114
+ function setStorageSyncHandler(handler) {
1115
+ uploadHandler = handler;
1116
+ }
1117
+ /** Fire-and-forget notification from the image stores after a file write. */
1118
+ function notifyImageSaved(kind, filePath) {
1119
+ try {
1120
+ uploadHandler?.(kind, filePath);
1121
+ } catch {}
1122
+ }
1123
+ /** URL-encode per RFC 3986 (AWS SigV4 canonical forms). */
1124
+ function uriEncode(value, encodeSlash = true) {
1125
+ return value.replace(/[^A-Za-z0-9-_.~]/g, (char) => {
1126
+ return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
1127
+ }).replace(/%2F/g, encodeSlash ? "%2F" : "/");
1128
+ }
1129
+ /** HMAC-SHA256 helper. */
1130
+ function hmac(key, data) {
1131
+ return createHmac("sha256", key).update(data, "utf8").digest();
1132
+ }
1133
+ /**
1134
+ * PUT one object to an S3-compatible endpoint (SigV4, virtual-hosted or
1135
+ * path-style — the endpoint URL already includes the bucket). Returns the
1136
+ * elapsed milliseconds so the settings card can show a latency reading.
1137
+ */
1138
+ async function putObject(config, key, data, contentType = "application/octet-stream") {
1139
+ const endpoint = config.endpoint.trim().replace(/\/+$/, "");
1140
+ if (endpoint === "" || config.accessKey.trim() === "" || config.secretKey.trim() === "") throw new Error("对象存储配置不完整:请填写接口地址与密钥");
1141
+ const url = new URL(`${endpoint}/${key.split("/").map((part) => uriEncode(part)).join("/")}`);
1142
+ const payloadHash = createHash("sha256").update(data).digest("hex");
1143
+ const amzDate = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, "")}`;
1144
+ const dateStamp = amzDate.slice(0, 8);
1145
+ const host = url.host;
1146
+ const canonicalUri = url.pathname;
1147
+ const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
1148
+ const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
1149
+ const canonicalRequest = `PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
1150
+ const scope = `${dateStamp}/${config.region.trim() || "us-east-1"}/s3/aws4_request`;
1151
+ const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash("sha256").update(canonicalRequest, "utf8").digest("hex")}`;
1152
+ 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");
1153
+ const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKey.trim()}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
1154
+ const started = Date.now();
1155
+ const response = await fetch(url, {
1156
+ method: "PUT",
1157
+ headers: {
1158
+ "content-type": contentType,
1159
+ "x-amz-content-sha256": payloadHash,
1160
+ "x-amz-date": amzDate,
1161
+ authorization
1162
+ },
1163
+ body: new Uint8Array(data)
1164
+ });
1165
+ if (!response.ok) {
1166
+ const text = await response.text().catch(() => "");
1167
+ throw new Error(`对象存储拒绝上传(HTTP ${response.status})${text !== "" ? `:${text.slice(0, 200)}` : ""}`);
1168
+ }
1169
+ return { ms: Date.now() - started };
1170
+ }
1171
+ /** Upload a small probe object; used by the settings card's test button. */
1172
+ async function testStorage(config) {
1173
+ const key = `${config.prefix.trim() || "dsh-imagegen"}/ping.txt`;
1174
+ const { ms } = await putObject(config, key, Buffer.from("dsh-imagegen storage ok", "utf8"), "text/plain");
1175
+ return {
1176
+ ms,
1177
+ key
1178
+ };
1179
+ }
1180
+ //#endregion
928
1181
  //#region src/history-store.ts
929
1182
  /**
930
1183
  * Host-persisted generation history: images are stored as individual files
@@ -945,7 +1198,7 @@ function mutateHistory(operation) {
945
1198
  return next;
946
1199
  }
947
1200
  /** File extension for a MIME type (image file names). */
948
- function extensionOf$1(mime) {
1201
+ function extensionOf$2(mime) {
949
1202
  switch (mime.split(";")[0].trim()) {
950
1203
  case "image/jpeg": return "jpg";
951
1204
  case "image/webp": return "webp";
@@ -964,7 +1217,7 @@ function mimeOfFile$2(file) {
964
1217
  }
965
1218
  }
966
1219
  /** Sanitize an entry id for use as a file-name prefix. */
967
- function safeId$1(id) {
1220
+ function safeId$2(id) {
968
1221
  const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
969
1222
  return cleaned === "" ? "entry" : cleaned;
970
1223
  }
@@ -1035,7 +1288,8 @@ function toWire$1(entry) {
1035
1288
  ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
1036
1289
  ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
1037
1290
  ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
1038
- ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
1291
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel },
1292
+ ...entry.canvas === void 0 ? {} : { canvas: entry.canvas }
1039
1293
  };
1040
1294
  }
1041
1295
  /** List the persisted history, newest first, as wire entries. */
@@ -1046,13 +1300,14 @@ async function listHistory() {
1046
1300
  async function appendHistory(input) {
1047
1301
  return mutateHistory(async () => {
1048
1302
  await ensureDirs$1();
1049
- const prefix = safeId$1(input.id);
1303
+ const prefix = safeId$2(input.id);
1050
1304
  const storedImages = [];
1051
1305
  try {
1052
1306
  for (let index = 0; index < input.images.length; index++) {
1053
1307
  const image = input.images[index];
1054
- const file = `${prefix}-${index}.${extensionOf$1(image.mime)}`;
1308
+ const file = `${prefix}-${index}.${extensionOf$2(image.mime)}`;
1055
1309
  await promises.writeFile(path.join(IMAGES_DIR$1, file), Buffer.from(image.b64, "base64"));
1310
+ notifyImageSaved("history", path.join(IMAGES_DIR$1, file));
1056
1311
  storedImages.push({
1057
1312
  file,
1058
1313
  mime: image.mime,
@@ -1083,7 +1338,8 @@ async function appendHistory(input) {
1083
1338
  ...input.projectId === void 0 ? {} : { projectId: input.projectId },
1084
1339
  ...input.projectName === void 0 ? {} : { projectName: input.projectName },
1085
1340
  ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
1086
- ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
1341
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel },
1342
+ ...input.canvas === void 0 ? {} : { canvas: input.canvas }
1087
1343
  }, ...await readIndex$1()];
1088
1344
  const kept = merged.slice(0, 50);
1089
1345
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -1276,7 +1532,8 @@ var ImageGenerationRuntime = class {
1276
1532
  ...request.projectId === void 0 ? {} : { projectId: request.projectId },
1277
1533
  ...request.projectName === void 0 ? {} : { projectName: request.projectName },
1278
1534
  ...request.slotKey === void 0 ? {} : { slotKey: request.slotKey },
1279
- ...request.slotLabel === void 0 ? {} : { slotLabel: request.slotLabel }
1535
+ ...request.slotLabel === void 0 ? {} : { slotLabel: request.slotLabel },
1536
+ ...request.canvas === void 0 ? {} : { canvas: request.canvas }
1280
1537
  });
1281
1538
  return {
1282
1539
  ...result,
@@ -1312,7 +1569,7 @@ function mutateGallery(operation) {
1312
1569
  return next;
1313
1570
  }
1314
1571
  /** File extension for a MIME type (image file names). */
1315
- function extensionOf(mime) {
1572
+ function extensionOf$1(mime) {
1316
1573
  switch (mime.split(";")[0].trim()) {
1317
1574
  case "image/jpeg": return "jpg";
1318
1575
  case "image/webp": return "webp";
@@ -1331,7 +1588,7 @@ function mimeOfFile$1(file) {
1331
1588
  }
1332
1589
  }
1333
1590
  /** Sanitize an entry id for use as a file-name prefix. */
1334
- function safeId(id) {
1591
+ function safeId$1(id) {
1335
1592
  const cleaned = id.replace(/[^a-zA-Z0-9-]/g, "-");
1336
1593
  return cleaned === "" ? "entry" : cleaned;
1337
1594
  }
@@ -1407,7 +1664,8 @@ function toWire(entry) {
1407
1664
  ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
1408
1665
  ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
1409
1666
  ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
1410
- ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
1667
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel },
1668
+ ...entry.canvas === void 0 ? {} : { canvas: entry.canvas }
1411
1669
  };
1412
1670
  }
1413
1671
  /** List the persisted gallery, newest first, as wire entries. */
@@ -1428,13 +1686,14 @@ async function appendGallery(input) {
1428
1686
  added: false
1429
1687
  };
1430
1688
  }
1431
- const prefix = safeId(input.id);
1689
+ const prefix = safeId$1(input.id);
1432
1690
  const storedImages = [];
1433
1691
  try {
1434
1692
  for (let index = 0; index < input.images.length; index++) {
1435
1693
  const image = input.images[index];
1436
- const file = `${prefix}-${index}.${extensionOf(image.mime)}`;
1694
+ const file = `${prefix}-${index}.${extensionOf$1(image.mime)}`;
1437
1695
  await promises.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, "base64"));
1696
+ notifyImageSaved("gallery", path.join(IMAGES_DIR, file));
1438
1697
  storedImages.push({
1439
1698
  file,
1440
1699
  mime: image.mime,
@@ -1464,7 +1723,8 @@ async function appendGallery(input) {
1464
1723
  ...input.projectId === void 0 ? {} : { projectId: input.projectId },
1465
1724
  ...input.projectName === void 0 ? {} : { projectName: input.projectName },
1466
1725
  ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
1467
- ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
1726
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel },
1727
+ ...input.canvas === void 0 ? {} : { canvas: input.canvas }
1468
1728
  }, ...await readIndex()];
1469
1729
  await writeIndex(merged);
1470
1730
  return {
@@ -1517,6 +1777,349 @@ async function readGalleryImage(file) {
1517
1777
  }
1518
1778
  }
1519
1779
  //#endregion
1780
+ //#region src/canvas-store.ts
1781
+ /** Host-persisted infinite canvas documents and content-addressed assets. */
1782
+ const DATA_ROOT = process.env.DSH_HOME?.trim() || path.join(homedir(), ".dsh");
1783
+ const CANVAS_ROOT = path.join(DATA_ROOT, "dsh-imagegen", "canvas");
1784
+ path.join(CANVAS_ROOT, "pages");
1785
+ path.join(CANVAS_ROOT, "assets");
1786
+ path.join(CANVAS_ROOT, "index.json");
1787
+ var CanvasConflictError = class extends Error {
1788
+ code = "canvas-conflict";
1789
+ constructor(message = "画布已在其他窗口更新,请重新加载后再保存。") {
1790
+ super(message);
1791
+ this.name = "CanvasConflictError";
1792
+ }
1793
+ };
1794
+ function extensionOf(mime) {
1795
+ switch (mime.split(";")[0].trim().toLowerCase()) {
1796
+ case "image/jpeg": return "jpg";
1797
+ case "image/webp": return "webp";
1798
+ case "image/gif": return "gif";
1799
+ default: return "png";
1800
+ }
1801
+ }
1802
+ function mimeOf(file) {
1803
+ switch (path.extname(file).toLowerCase()) {
1804
+ case ".jpg":
1805
+ case ".jpeg": return "image/jpeg";
1806
+ case ".webp": return "image/webp";
1807
+ case ".gif": return "image/gif";
1808
+ default: return "image/png";
1809
+ }
1810
+ }
1811
+ function safeId(value) {
1812
+ const id = value.replace(/[^a-zA-Z0-9_-]/g, "-");
1813
+ return id === "" ? randomUUID() : id;
1814
+ }
1815
+ async function writeJsonAtomic(file, value) {
1816
+ await promises.mkdir(path.dirname(file), { recursive: true });
1817
+ const temp = `${file}.tmp-${process.pid}-${randomUUID()}`;
1818
+ await promises.writeFile(temp, `${JSON.stringify(value)}\n`, "utf8");
1819
+ await promises.rename(temp, file);
1820
+ }
1821
+ async function readJson(file) {
1822
+ try {
1823
+ return JSON.parse(await promises.readFile(file, "utf8"));
1824
+ } catch {
1825
+ return;
1826
+ }
1827
+ }
1828
+ function defaultDocument(id, title) {
1829
+ const now = Date.now();
1830
+ return {
1831
+ version: 2,
1832
+ id,
1833
+ title,
1834
+ revision: 1,
1835
+ viewport: {
1836
+ x: 0,
1837
+ y: 0,
1838
+ k: 1
1839
+ },
1840
+ background: "dots",
1841
+ nodes: [],
1842
+ connections: [],
1843
+ createdAt: now,
1844
+ updatedAt: now
1845
+ };
1846
+ }
1847
+ function isAssetRef(value) {
1848
+ if (value === null || typeof value !== "object") return false;
1849
+ const asset = value;
1850
+ return typeof asset.assetId === "string" && typeof asset.url === "string" && typeof asset.mime === "string" && typeof asset.width === "number" && typeof asset.height === "number";
1851
+ }
1852
+ function isNode(value) {
1853
+ if (value === null || typeof value !== "object") return false;
1854
+ const node = value;
1855
+ 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;
1856
+ if (node.type !== "image" && node.type !== "text" && node.type !== "config") return false;
1857
+ const metadata = node.metadata;
1858
+ if (metadata !== void 0 && (metadata === null || typeof metadata !== "object")) return false;
1859
+ const state = metadata ?? {};
1860
+ if (node.type === "image") {
1861
+ if (state.asset !== void 0 && !isAssetRef(state.asset)) return false;
1862
+ return state.status === void 0 || state.status === "idle" || state.status === "generating" || state.status === "success" || state.status === "error";
1863
+ }
1864
+ if (node.type === "config") return state.prompt === void 0 || typeof state.prompt === "string";
1865
+ return state.text === void 0 || typeof state.text === "string";
1866
+ }
1867
+ function isDocument(value) {
1868
+ if (value === null || typeof value !== "object") return false;
1869
+ const document = value;
1870
+ 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);
1871
+ }
1872
+ /** Upgrade a v1 (image/text/annotation + edges) document to the v2 node-graph model.
1873
+ * Images and text notes keep their geometry; annotation prompt cards become plain
1874
+ * text notes carrying the prompt, and old edges survive only between surviving nodes. */
1875
+ function migrateLegacyDocument(input) {
1876
+ const now = Date.now();
1877
+ const legacyNodes = Array.isArray(input.nodes) ? input.nodes : [];
1878
+ const nodes = [];
1879
+ const annotationIds = /* @__PURE__ */ new Set();
1880
+ for (const raw of legacyNodes) {
1881
+ if (raw === null || typeof raw !== "object") continue;
1882
+ const node = raw;
1883
+ if (typeof node.id !== "string" || typeof node.x !== "number" || typeof node.y !== "number") continue;
1884
+ const base = {
1885
+ id: node.id,
1886
+ title: typeof node.title === "string" ? node.title : "未命名节点",
1887
+ x: node.x,
1888
+ y: node.y,
1889
+ width: typeof node.width === "number" ? node.width : 300,
1890
+ height: typeof node.height === "number" ? node.height : 220
1891
+ };
1892
+ if (node.type === "image" && isAssetRef(node.asset)) {
1893
+ const generation = node.generation ?? {};
1894
+ nodes.push({
1895
+ ...base,
1896
+ type: "image",
1897
+ metadata: {
1898
+ asset: node.asset,
1899
+ status: typeof node.status === "string" && [
1900
+ "idle",
1901
+ "generating",
1902
+ "success",
1903
+ "error"
1904
+ ].includes(node.status) ? node.status : "success",
1905
+ ...typeof node.error === "string" ? { error: node.error } : {},
1906
+ ...typeof generation.prompt === "string" ? { prompt: generation.prompt } : {},
1907
+ ...typeof generation.model === "string" ? { model: generation.model } : {},
1908
+ ...typeof generation.taskId === "string" ? { taskId: generation.taskId } : {},
1909
+ ...typeof generation.sourceNodeId === "string" ? { sourceNodeId: generation.sourceNodeId } : {}
1910
+ }
1911
+ });
1912
+ } else if (node.type === "text") nodes.push({
1913
+ ...base,
1914
+ type: "text",
1915
+ metadata: {
1916
+ text: typeof node.text === "string" ? node.text : "",
1917
+ ...typeof node.fontSize === "number" ? { fontSize: node.fontSize } : {}
1918
+ }
1919
+ });
1920
+ else if (node.type === "annotation") {
1921
+ annotationIds.add(node.id);
1922
+ const prompt = typeof node.prompt === "string" && node.prompt.trim() !== "" ? node.prompt : "(旧版标注,提示词见此)";
1923
+ nodes.push({
1924
+ ...base,
1925
+ type: "text",
1926
+ title: "旧版标注",
1927
+ metadata: { text: prompt }
1928
+ });
1929
+ }
1930
+ }
1931
+ nodes.sort((a, b) => {
1932
+ const za = legacyNodes.find((item) => item?.id === a.id)?.zIndex;
1933
+ const zb = legacyNodes.find((item) => item?.id === b.id)?.zIndex;
1934
+ return (typeof za === "number" ? za : 0) - (typeof zb === "number" ? zb : 0);
1935
+ });
1936
+ const validIds = new Set(nodes.map((node) => node.id));
1937
+ const seen = /* @__PURE__ */ new Set();
1938
+ const connections = (Array.isArray(input.edges) ? input.edges : []).flatMap((raw) => {
1939
+ if (raw === null || typeof raw !== "object") return [];
1940
+ const edge = raw;
1941
+ if (typeof edge.fromNodeId !== "string" || typeof edge.toNodeId !== "string") return [];
1942
+ if (annotationIds.has(edge.fromNodeId) || annotationIds.has(edge.toNodeId)) return [];
1943
+ if (!validIds.has(edge.fromNodeId) || !validIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return [];
1944
+ const key = `${edge.fromNodeId}->${edge.toNodeId}`;
1945
+ if (seen.has(key)) return [];
1946
+ seen.add(key);
1947
+ return [{
1948
+ id: typeof edge.id === "string" ? edge.id : `edge-${randomUUID()}`,
1949
+ fromNodeId: edge.fromNodeId,
1950
+ toNodeId: edge.toNodeId
1951
+ }];
1952
+ });
1953
+ const legacyViewport = input.viewport ?? {};
1954
+ const background = input.background === "grid" ? "lines" : input.background === "blank" ? "blank" : "dots";
1955
+ return {
1956
+ version: 2,
1957
+ id: typeof input.id === "string" ? input.id : randomUUID(),
1958
+ title: typeof input.title === "string" ? input.title : "未命名画布",
1959
+ revision: typeof input.revision === "number" ? input.revision : 1,
1960
+ viewport: {
1961
+ x: typeof legacyViewport.x === "number" ? legacyViewport.x : 0,
1962
+ y: typeof legacyViewport.y === "number" ? legacyViewport.y : 0,
1963
+ k: typeof legacyViewport.scale === "number" && legacyViewport.scale > 0 ? legacyViewport.scale : 1
1964
+ },
1965
+ background,
1966
+ nodes,
1967
+ connections,
1968
+ createdAt: typeof input.createdAt === "number" ? input.createdAt : now,
1969
+ updatedAt: typeof input.updatedAt === "number" ? input.updatedAt : now
1970
+ };
1971
+ }
1972
+ /** Accept either the v2 document or a legacy v1 payload and return v2. */
1973
+ function coerceDocument(value) {
1974
+ if (value === null || typeof value !== "object") return void 0;
1975
+ const document = value;
1976
+ if (document.version === 1) {
1977
+ const migrated = migrateLegacyDocument(document);
1978
+ return isDocument(migrated) ? migrated : void 0;
1979
+ }
1980
+ return isDocument(value) ? value : void 0;
1981
+ }
1982
+ let mutation = Promise.resolve();
1983
+ function serialize(operation) {
1984
+ const next = mutation.then(operation, operation);
1985
+ mutation = next.then(() => void 0, () => void 0);
1986
+ return next;
1987
+ }
1988
+ var CanvasStore = class {
1989
+ root;
1990
+ constructor(root = CANVAS_ROOT) {
1991
+ this.root = root;
1992
+ }
1993
+ pagesDir() {
1994
+ return path.join(this.root, "pages");
1995
+ }
1996
+ assetsDir() {
1997
+ return path.join(this.root, "assets");
1998
+ }
1999
+ indexPath() {
2000
+ return path.join(this.root, "index.json");
2001
+ }
2002
+ async ensure() {
2003
+ await promises.mkdir(this.pagesDir(), { recursive: true });
2004
+ await promises.mkdir(this.assetsDir(), { recursive: true });
2005
+ }
2006
+ pagePath(id) {
2007
+ return path.join(this.pagesDir(), `${safeId(id)}.json`);
2008
+ }
2009
+ assetPath(id) {
2010
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return void 0;
2011
+ const target = path.join(this.assetsDir(), id);
2012
+ const relative = path.relative(this.assetsDir(), target);
2013
+ return relative.startsWith("..") || path.isAbsolute(relative) ? void 0 : target;
2014
+ }
2015
+ async readIndex() {
2016
+ const value = await readJson(this.indexPath());
2017
+ if (value === void 0 || typeof value !== "object" || !Array.isArray(value.projects)) return [];
2018
+ return value.projects.filter((item) => {
2019
+ if (item === null || typeof item !== "object") return false;
2020
+ const project = item;
2021
+ 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";
2022
+ });
2023
+ }
2024
+ async writeIndex(projects) {
2025
+ await this.ensure();
2026
+ await writeJsonAtomic(this.indexPath(), { projects });
2027
+ }
2028
+ async list() {
2029
+ return this.readIndex();
2030
+ }
2031
+ async create(title = "未命名画布") {
2032
+ return serialize(async () => {
2033
+ await this.ensure();
2034
+ const id = randomUUID();
2035
+ const document = defaultDocument(id, title.trim() || "未命名画布");
2036
+ await writeJsonAtomic(this.pagePath(id), document);
2037
+ const projects = await this.readIndex();
2038
+ await this.writeIndex([this.summaryOf(document), ...projects]);
2039
+ return document;
2040
+ });
2041
+ }
2042
+ async read(id) {
2043
+ return coerceDocument(await readJson(this.pagePath(id)));
2044
+ }
2045
+ async save(document, expectedRevision) {
2046
+ return serialize(async () => {
2047
+ const incoming = coerceDocument(document);
2048
+ if (incoming === void 0) throw new Error("malformed canvas document");
2049
+ const current = await this.read(incoming.id);
2050
+ if (current !== void 0 && expectedRevision !== void 0 && current.revision !== expectedRevision) throw new CanvasConflictError();
2051
+ const next = {
2052
+ ...incoming,
2053
+ revision: Math.max(current?.revision ?? 0, incoming.revision) + 1,
2054
+ updatedAt: Date.now()
2055
+ };
2056
+ await this.ensure();
2057
+ await writeJsonAtomic(this.pagePath(next.id), next);
2058
+ const projects = (await this.readIndex()).filter((item) => item.id !== next.id);
2059
+ await this.writeIndex([this.summaryOf(next), ...projects]);
2060
+ return next;
2061
+ });
2062
+ }
2063
+ async remove(id) {
2064
+ return serialize(async () => {
2065
+ try {
2066
+ await promises.rm(this.pagePath(id), { force: true });
2067
+ } catch {}
2068
+ const projects = (await this.readIndex()).filter((item) => item.id !== id);
2069
+ await this.writeIndex(projects);
2070
+ return projects;
2071
+ });
2072
+ }
2073
+ async putImage(input) {
2074
+ if (!input.data.byteLength) throw new Error("image data is empty");
2075
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(input.mime)) throw new Error("unsupported image type");
2076
+ if (!Number.isSafeInteger(input.width) || input.width < 1 || !Number.isSafeInteger(input.height) || input.height < 1) throw new Error("image dimensions are invalid");
2077
+ await this.ensure();
2078
+ const file = `${createHash("sha256").update(input.data).digest("hex")}.${extensionOf(input.mime)}`;
2079
+ const target = path.join(this.assetsDir(), file);
2080
+ try {
2081
+ await promises.access(target);
2082
+ } catch {
2083
+ await promises.writeFile(target, input.data);
2084
+ }
2085
+ return {
2086
+ assetId: file,
2087
+ url: `/api/dsh-imagegen/canvas/asset/${file}`,
2088
+ mime: input.mime,
2089
+ bytes: input.data.byteLength,
2090
+ width: input.width,
2091
+ height: input.height,
2092
+ origin: input.origin,
2093
+ ...input.originId === void 0 ? {} : { originId: input.originId },
2094
+ ...input.entryId === void 0 ? {} : { entryId: input.entryId },
2095
+ ...input.imageIndex === void 0 ? {} : { imageIndex: input.imageIndex }
2096
+ };
2097
+ }
2098
+ async readAsset(file) {
2099
+ const target = this.assetPath(file);
2100
+ if (target === void 0) return void 0;
2101
+ try {
2102
+ return {
2103
+ data: await promises.readFile(target),
2104
+ mime: mimeOf(file)
2105
+ };
2106
+ } catch {
2107
+ return;
2108
+ }
2109
+ }
2110
+ summaryOf(document) {
2111
+ return {
2112
+ id: document.id,
2113
+ title: document.title,
2114
+ revision: document.revision,
2115
+ nodeCount: document.nodes.length,
2116
+ createdAt: document.createdAt,
2117
+ updatedAt: document.updatedAt
2118
+ };
2119
+ }
2120
+ };
2121
+ const canvasStore = new CanvasStore();
2122
+ //#endregion
1520
2123
  //#region src/templates-store.ts
1521
2124
  /**
1522
2125
  * Prompt-template library store (multi-source).
@@ -2248,6 +2851,7 @@ function parseGenerateRequest(body) {
2248
2851
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
2249
2852
  if (prompt === "") return void 0;
2250
2853
  const comparisonModels = Array.isArray(body.comparisonModels) ? [...new Set(body.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
2854
+ const canvas = parseCanvasMeta(body.canvas);
2251
2855
  return {
2252
2856
  mode: body.mode === "edit" ? "edit" : "text",
2253
2857
  model: typeof body.model === "string" ? body.model : "",
@@ -2261,6 +2865,7 @@ function parseGenerateRequest(body) {
2261
2865
  ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
2262
2866
  ...typeof body.comparisonId === "string" && body.comparisonId !== "" ? { comparisonId: body.comparisonId } : {},
2263
2867
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
2868
+ ...canvas === void 0 ? {} : { canvas },
2264
2869
  ...body.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
2265
2870
  ...typeof body.projectId === "string" && body.projectId !== "" ? { projectId: body.projectId } : {},
2266
2871
  ...typeof body.projectName === "string" && body.projectName !== "" ? { projectName: body.projectName } : {},
@@ -2268,6 +2873,18 @@ function parseGenerateRequest(body) {
2268
2873
  ...typeof body.slotLabel === "string" && body.slotLabel !== "" ? { slotLabel: body.slotLabel } : {}
2269
2874
  };
2270
2875
  }
2876
+ function parseCanvasMeta(value) {
2877
+ if (value === null || typeof value !== "object") return void 0;
2878
+ const raw = value;
2879
+ if (typeof raw.canvasId !== "string" || raw.canvasId.trim() === "") return void 0;
2880
+ return {
2881
+ canvasId: raw.canvasId.trim(),
2882
+ ...typeof raw.sourceNodeId === "string" && raw.sourceNodeId.trim() !== "" ? { sourceNodeId: raw.sourceNodeId.trim() } : {},
2883
+ ...typeof raw.annotationNodeId === "string" && raw.annotationNodeId.trim() !== "" ? { annotationNodeId: raw.annotationNodeId.trim() } : {},
2884
+ ...typeof raw.parentNodeId === "string" && raw.parentNodeId.trim() !== "" ? { parentNodeId: raw.parentNodeId.trim() } : {},
2885
+ ...raw.placement === "right" || raw.placement === "below" ? { placement: raw.placement } : {}
2886
+ };
2887
+ }
2271
2888
  /** Validate a submitted history entry (images carry base64). */
2272
2889
  function parseHistoryEntryInput(body) {
2273
2890
  const raw = body.entry;
@@ -2291,6 +2908,7 @@ function parseHistoryEntryInput(body) {
2291
2908
  });
2292
2909
  }
2293
2910
  const comparisonModels = Array.isArray(entry.comparisonModels) ? [...new Set(entry.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
2911
+ const canvas = parseCanvasMeta(entry.canvas);
2294
2912
  return {
2295
2913
  id: entry.id,
2296
2914
  createdAt: entry.createdAt,
@@ -2307,6 +2925,7 @@ function parseHistoryEntryInput(body) {
2307
2925
  ...typeof entry.channel === "string" ? { channel: entry.channel } : {},
2308
2926
  ...typeof entry.comparisonId === "string" ? { comparisonId: entry.comparisonId } : {},
2309
2927
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
2928
+ ...canvas === void 0 ? {} : { canvas },
2310
2929
  ...entry.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
2311
2930
  ...typeof entry.projectId === "string" ? { projectId: entry.projectId } : {},
2312
2931
  ...typeof entry.projectName === "string" ? { projectName: entry.projectName } : {},
@@ -2411,6 +3030,7 @@ function makeRoutes(deps) {
2411
3030
  updateTags: updateGalleryTags,
2412
3031
  readImage: readGalleryImage
2413
3032
  };
3033
+ const canvas = deps.canvas ?? canvasStore;
2414
3034
  const templates = deps.templates ?? {
2415
3035
  list: listTemplates,
2416
3036
  refresh: refreshTemplates,
@@ -3250,6 +3870,253 @@ function makeRoutes(deps) {
3250
3870
  res.end(found.data);
3251
3871
  }
3252
3872
  },
3873
+ {
3874
+ kind: "exact",
3875
+ path: CANVAS_API.list,
3876
+ handler: async (req, res) => {
3877
+ if (!guard(req, res, "POST")) return;
3878
+ try {
3879
+ writeJson(res, 200, {
3880
+ ok: true,
3881
+ projects: await canvas.list()
3882
+ });
3883
+ } catch (error) {
3884
+ writeJson(res, 200, {
3885
+ ok: false,
3886
+ code: "canvas-failed",
3887
+ message: messageOf(error)
3888
+ });
3889
+ }
3890
+ }
3891
+ },
3892
+ {
3893
+ kind: "exact",
3894
+ path: CANVAS_API.create,
3895
+ handler: async (req, res) => {
3896
+ if (!guard(req, res, "POST")) return;
3897
+ const body = await readJsonBody(req);
3898
+ const title = typeof body?.title === "string" ? body.title : "未命名画布";
3899
+ try {
3900
+ writeJson(res, 200, {
3901
+ ok: true,
3902
+ document: await canvas.create(title)
3903
+ });
3904
+ } catch (error) {
3905
+ writeJson(res, 200, {
3906
+ ok: false,
3907
+ code: "canvas-failed",
3908
+ message: messageOf(error)
3909
+ });
3910
+ }
3911
+ }
3912
+ },
3913
+ {
3914
+ kind: "exact",
3915
+ path: CANVAS_API.read,
3916
+ handler: async (req, res) => {
3917
+ if (!guard(req, res, "POST")) return;
3918
+ const body = await readJsonBody(req);
3919
+ const id = typeof body?.id === "string" ? body.id : "";
3920
+ const document = id === "" ? void 0 : await canvas.read(id);
3921
+ if (document === void 0) writeJson(res, 200, {
3922
+ ok: false,
3923
+ code: "not-found",
3924
+ message: "画布不存在"
3925
+ });
3926
+ else writeJson(res, 200, {
3927
+ ok: true,
3928
+ document
3929
+ });
3930
+ }
3931
+ },
3932
+ {
3933
+ kind: "exact",
3934
+ path: CANVAS_API.save,
3935
+ handler: async (req, res) => {
3936
+ if (!guard(req, res, "POST")) return;
3937
+ const body = await readJsonBody(req);
3938
+ const document = body?.document;
3939
+ const expectedRevision = typeof body?.expectedRevision === "number" ? body.expectedRevision : void 0;
3940
+ if (document === void 0 || typeof document !== "object") {
3941
+ writeJson(res, 200, {
3942
+ ok: false,
3943
+ code: "bad-request",
3944
+ message: "canvas document is required"
3945
+ });
3946
+ return;
3947
+ }
3948
+ try {
3949
+ writeJson(res, 200, {
3950
+ ok: true,
3951
+ document: await canvas.save(document, expectedRevision)
3952
+ });
3953
+ } catch (error) {
3954
+ writeJson(res, 200, {
3955
+ ok: false,
3956
+ code: error instanceof CanvasConflictError ? error.code : "canvas-failed",
3957
+ message: messageOf(error)
3958
+ });
3959
+ }
3960
+ }
3961
+ },
3962
+ {
3963
+ kind: "exact",
3964
+ path: CANVAS_API.remove,
3965
+ handler: async (req, res) => {
3966
+ if (!guard(req, res, "POST")) return;
3967
+ const body = await readJsonBody(req);
3968
+ const id = typeof body?.id === "string" ? body.id : "";
3969
+ if (id === "") {
3970
+ writeJson(res, 200, {
3971
+ ok: false,
3972
+ code: "bad-request",
3973
+ message: "canvas id is required"
3974
+ });
3975
+ return;
3976
+ }
3977
+ try {
3978
+ writeJson(res, 200, {
3979
+ ok: true,
3980
+ projects: await canvas.remove(id)
3981
+ });
3982
+ } catch (error) {
3983
+ writeJson(res, 200, {
3984
+ ok: false,
3985
+ code: "canvas-failed",
3986
+ message: messageOf(error)
3987
+ });
3988
+ }
3989
+ }
3990
+ },
3991
+ {
3992
+ kind: "exact",
3993
+ path: CANVAS_API.assetUpload,
3994
+ handler: async (req, res) => {
3995
+ if (!guard(req, res, "POST")) return;
3996
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES);
3997
+ const parsed = typeof body?.dataUrl === "string" ? imageDataUrl$1(body.dataUrl) : void 0;
3998
+ const width = Number(body?.width);
3999
+ const height = Number(body?.height);
4000
+ if (parsed === void 0 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
4001
+ writeJson(res, 200, {
4002
+ ok: false,
4003
+ code: "bad-request",
4004
+ message: "image data and dimensions are required"
4005
+ });
4006
+ return;
4007
+ }
4008
+ try {
4009
+ writeJson(res, 200, {
4010
+ ok: true,
4011
+ asset: await canvas.putImage({
4012
+ data: parsed.data,
4013
+ mime: parsed.mediaType,
4014
+ width,
4015
+ height,
4016
+ origin: body?.origin === "history" || body?.origin === "gallery" || body?.origin === "generated" ? body.origin : "upload",
4017
+ ...typeof body?.originId === "string" ? { originId: body.originId } : {},
4018
+ ...typeof body?.entryId === "string" ? { entryId: body.entryId } : {},
4019
+ ...Number.isSafeInteger(Number(body?.imageIndex)) ? { imageIndex: Number(body?.imageIndex) } : {}
4020
+ })
4021
+ });
4022
+ } catch (error) {
4023
+ writeJson(res, 200, {
4024
+ ok: false,
4025
+ code: "canvas-asset-failed",
4026
+ message: messageOf(error)
4027
+ });
4028
+ }
4029
+ }
4030
+ },
4031
+ {
4032
+ kind: "exact",
4033
+ path: CANVAS_API.assetImport,
4034
+ handler: async (req, res) => {
4035
+ if (!guard(req, res, "POST")) return;
4036
+ const body = await readJsonBody(req);
4037
+ const source = body?.source === "history" || body?.source === "gallery" ? body.source : void 0;
4038
+ const entryId = typeof body?.entryId === "string" ? body.entryId : "";
4039
+ const imageIndex = Number(body?.imageIndex);
4040
+ const width = Number(body?.width);
4041
+ const height = Number(body?.height);
4042
+ if (source === void 0 || entryId === "" || !Number.isSafeInteger(imageIndex) || imageIndex < 0 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
4043
+ writeJson(res, 200, {
4044
+ ok: false,
4045
+ code: "bad-request",
4046
+ message: "source, entryId, imageIndex and dimensions are required"
4047
+ });
4048
+ return;
4049
+ }
4050
+ try {
4051
+ const backend = source === "history" ? history : gallery;
4052
+ const image = (await backend.list()).find((item) => item.id === entryId)?.images[imageIndex];
4053
+ if (image === void 0) {
4054
+ writeJson(res, 200, {
4055
+ ok: false,
4056
+ code: "not-found",
4057
+ message: "image not found"
4058
+ });
4059
+ return;
4060
+ }
4061
+ const base = source === "history" ? HISTORY_API.image : GALLERY_API.image;
4062
+ const file = imageFileFrom(image.url, base);
4063
+ const found = file === void 0 ? void 0 : await backend.readImage(file);
4064
+ if (found === void 0) {
4065
+ writeJson(res, 200, {
4066
+ ok: false,
4067
+ code: "not-found",
4068
+ message: "image not found"
4069
+ });
4070
+ return;
4071
+ }
4072
+ writeJson(res, 200, {
4073
+ ok: true,
4074
+ asset: await canvas.putImage({
4075
+ data: found.data,
4076
+ mime: found.mime,
4077
+ width,
4078
+ height,
4079
+ origin: source,
4080
+ originId: entryId,
4081
+ entryId,
4082
+ imageIndex
4083
+ })
4084
+ });
4085
+ } catch (error) {
4086
+ writeJson(res, 200, {
4087
+ ok: false,
4088
+ code: "canvas-asset-failed",
4089
+ message: messageOf(error)
4090
+ });
4091
+ }
4092
+ }
4093
+ },
4094
+ {
4095
+ kind: "prefix",
4096
+ path: CANVAS_API.asset,
4097
+ handler: async (req, res) => {
4098
+ if (!isLoopbackRequest(req)) {
4099
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
4100
+ return;
4101
+ }
4102
+ if (req.method !== "GET") {
4103
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
4104
+ return;
4105
+ }
4106
+ const file = imageFileFrom(req.url, CANVAS_API.asset);
4107
+ const found = file === void 0 ? void 0 : await canvas.readAsset(file);
4108
+ if (found === void 0) {
4109
+ writeJson(res, 404, { error: "not found" });
4110
+ return;
4111
+ }
4112
+ res.writeHead(200, {
4113
+ "content-type": found.mime,
4114
+ "content-length": found.data.length,
4115
+ "cache-control": "private, max-age=3600"
4116
+ });
4117
+ res.end(found.data);
4118
+ }
4119
+ },
3253
4120
  {
3254
4121
  kind: "exact",
3255
4122
  path: TEMPLATES_API.list,
@@ -3452,6 +4319,71 @@ function makeRoutes(deps) {
3452
4319
  });
3453
4320
  }
3454
4321
  }
4322
+ },
4323
+ {
4324
+ kind: "exact",
4325
+ path: DATA_FOLDER_API,
4326
+ handler: async (req, res) => {
4327
+ if (!guard(req, res, "POST")) return;
4328
+ const body = await readJsonBody(req);
4329
+ const dir = path.join(homedir(), ".dsh", "dsh-imagegen");
4330
+ try {
4331
+ await mkdir(dir, { recursive: true });
4332
+ } catch {}
4333
+ if (body?.open === false) {
4334
+ writeJson(res, 200, {
4335
+ ok: true,
4336
+ path: dir
4337
+ });
4338
+ return;
4339
+ }
4340
+ try {
4341
+ spawn(process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open", [dir], {
4342
+ detached: true,
4343
+ stdio: "ignore"
4344
+ }).unref();
4345
+ writeJson(res, 200, {
4346
+ ok: true,
4347
+ path: dir
4348
+ });
4349
+ } catch (error) {
4350
+ writeJson(res, 200, {
4351
+ ok: false,
4352
+ code: "data-folder-failed",
4353
+ message: messageOf(error)
4354
+ });
4355
+ }
4356
+ }
4357
+ },
4358
+ {
4359
+ kind: "exact",
4360
+ path: STORAGE_API.test,
4361
+ handler: async (req, res) => {
4362
+ if (!guard(req, res, "POST")) return;
4363
+ const storage = deps.resolveStorage?.();
4364
+ if (storage === void 0) {
4365
+ writeJson(res, 200, {
4366
+ ok: false,
4367
+ code: "storage-unavailable",
4368
+ message: "存储配置不可用"
4369
+ });
4370
+ return;
4371
+ }
4372
+ try {
4373
+ const result = await testStorage(storage);
4374
+ writeJson(res, 200, {
4375
+ ok: true,
4376
+ ms: result.ms,
4377
+ key: result.key
4378
+ });
4379
+ } catch (error) {
4380
+ writeJson(res, 200, {
4381
+ ok: false,
4382
+ code: "storage-test-failed",
4383
+ message: messageOf(error)
4384
+ });
4385
+ }
4386
+ }
3455
4387
  }
3456
4388
  ];
3457
4389
  }
@@ -4005,6 +4937,16 @@ function registerEditImageCommand(ctx, runtime, resolve, pendingImages) {
4005
4937
  }
4006
4938
  //#endregion
4007
4939
  //#region src/index.ts
4940
+ /** Content type for a saved image file name (object uploads). */
4941
+ function mimeOfPath(filePath) {
4942
+ switch (path.extname(filePath).toLowerCase()) {
4943
+ case ".jpg":
4944
+ case ".jpeg": return "image/jpeg";
4945
+ case ".webp": return "image/webp";
4946
+ case ".gif": return "image/gif";
4947
+ default: return "image/png";
4948
+ }
4949
+ }
4008
4950
  /** Stable cordis plugin name. */
4009
4951
  const name = "imagegen";
4010
4952
  /** Services required before the surfaces can mount. */
@@ -4034,6 +4976,14 @@ const Config = z.object({
4034
4976
  promptApiUrl: z.string().default(""),
4035
4977
  promptApiKey: z.string().role("secret").default(""),
4036
4978
  promptModel: z.string().default(""),
4979
+ storageEnabled: z.boolean().default(false),
4980
+ storageEndpoint: z.string().default(""),
4981
+ storageRegion: z.string().default(""),
4982
+ storagePrefix: z.string().default("dsh-imagegen"),
4983
+ storageAccessKey: z.string().default(""),
4984
+ storageSecretKey: z.string().role("secret").default(""),
4985
+ storageSyncGallery: z.boolean().default(true),
4986
+ storageSyncHistory: z.boolean().default(false),
4037
4987
  apiUrl: z.string().default(""),
4038
4988
  apiKey: z.string().role("secret").default(""),
4039
4989
  imageModels: z.array(z.string()).default([])
@@ -4134,7 +5084,17 @@ function apply(ctx, config) {
4134
5084
  defaultChannelId,
4135
5085
  promptApiUrl: typeof value.promptApiUrl === "string" ? value.promptApiUrl.trim() : "",
4136
5086
  promptApiKey: typeof value.promptApiKey === "string" ? value.promptApiKey.trim() : "",
4137
- promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : ""
5087
+ promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : "",
5088
+ storage: {
5089
+ enabled: value.storageEnabled ?? false,
5090
+ endpoint: typeof value.storageEndpoint === "string" ? value.storageEndpoint.trim() : "",
5091
+ region: typeof value.storageRegion === "string" ? value.storageRegion.trim() : "",
5092
+ accessKey: typeof value.storageAccessKey === "string" ? value.storageAccessKey.trim() : "",
5093
+ secretKey: typeof value.storageSecretKey === "string" ? value.storageSecretKey.trim() : "",
5094
+ prefix: typeof value.storagePrefix === "string" && value.storagePrefix.trim() !== "" ? value.storagePrefix.trim() : "dsh-imagegen",
5095
+ syncGallery: value.storageSyncGallery ?? true,
5096
+ syncHistory: value.storageSyncHistory ?? false
5097
+ }
4138
5098
  };
4139
5099
  };
4140
5100
  const channelsView = () => {
@@ -4144,6 +5104,13 @@ function apply(ctx, config) {
4144
5104
  defaultChannelId: value.defaultChannelId
4145
5105
  };
4146
5106
  };
5107
+ setStorageSyncHandler((kind, filePath) => {
5108
+ const storage = resolve().storage;
5109
+ if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === "") return;
5110
+ if (kind === "gallery" && !storage.syncGallery) return;
5111
+ if (kind === "history" && !storage.syncHistory) return;
5112
+ putObject(storage, `${storage.prefix}/${kind === "gallery" ? "gallery" : "images"}/${path.basename(filePath)}`, readFileSync(filePath), mimeOfPath(filePath)).catch(() => {});
5113
+ });
4147
5114
  const runtime = new ImageGenerationRuntime(channelsView);
4148
5115
  const pendingConversationImages = /* @__PURE__ */ new Map();
4149
5116
  ctx.inject(["settings", "attachments"], (sctx) => {
@@ -4175,7 +5142,8 @@ function apply(ctx, config) {
4175
5142
  },
4176
5143
  attachments: sctx.attachments,
4177
5144
  pendingConversationImages,
4178
- runtime
5145
+ runtime,
5146
+ resolveStorage: () => resolve().storage
4179
5147
  }).map((route) => ctx.webServer.register(route));
4180
5148
  const TEMPLATE_SYNC_INITIAL_DELAY_MS = 3e4;
4181
5149
  const TEMPLATE_SYNC_INTERVAL_MS = 720 * 60 * 1e3;
@@ -4244,6 +5212,9 @@ function apply(ctx, config) {
4244
5212
  onChange: sync
4245
5213
  });
4246
5214
  sync();
5215
+ return () => {
5216
+ setStorageSyncHandler(void 0);
5217
+ };
4247
5218
  }
4248
5219
  //#endregion
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 };
5220
+ 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 };