@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/README.md +23 -1
- package/lib/client.js +5412 -1935
- package/lib/client.js.map +1 -1
- package/lib/index.js +1082 -111
- package/package.json +1 -1
- package/src/canvas-store.ts +376 -0
- package/src/client/CanvasWorkspace.tsx +1628 -0
- package/src/client/ImageGenPanel.tsx +159 -96
- package/src/client/SettingsCard.tsx +173 -1
- package/src/client/api.ts +48 -1
- package/src/client/canvas-workspace.module.css +929 -0
- package/src/client/locales.ts +1446 -1146
- package/src/client/panel.module.css +83 -33
- package/src/engine.ts +255 -95
- package/src/gallery-store.ts +5 -0
- package/src/generation-runtime.ts +1 -0
- package/src/history-store.ts +5 -0
- package/src/index.ts +70 -3
- package/src/protocol.ts +121 -1
- package/src/routes.ts +231 -1
- package/src/storage-sync.ts +105 -0
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 {
|
|
5
|
-
import {
|
|
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.
|
|
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
|
-
/**
|
|
576
|
-
|
|
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(
|
|
692
|
-
let response;
|
|
712
|
+
const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS);
|
|
693
713
|
try {
|
|
694
|
-
response
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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$
|
|
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$
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 };
|