@dickpy/dsh-imagegen 1.5.4 → 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 +2 -0
- package/lib/client.js +495 -394
- package/lib/client.js.map +1 -1
- package/lib/index.js +138 -121
- package/package.json +1 -1
- package/src/client/CanvasWorkspace.tsx +60 -1
- package/src/client/locales.ts +3 -0
- package/src/engine.ts +150 -132
- package/src/protocol.ts +1 -1
package/lib/index.js
CHANGED
|
@@ -41,7 +41,7 @@ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
|
|
|
41
41
|
/** Settings namespace this plugin owns (host settings seam + bridge). */
|
|
42
42
|
const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
|
|
43
43
|
/** Published package version shared by the host updater and the client UI. */
|
|
44
|
-
const PLUGIN_VERSION = "1.5.
|
|
44
|
+
const PLUGIN_VERSION = "1.5.5";
|
|
45
45
|
/** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
|
|
46
46
|
const SETTINGS_API = {
|
|
47
47
|
describe: "/api/dsh-imagegen/settings/describe",
|
|
@@ -588,8 +588,13 @@ function requestSignal(source, timeoutMs) {
|
|
|
588
588
|
}
|
|
589
589
|
};
|
|
590
590
|
}
|
|
591
|
-
/**
|
|
592
|
-
|
|
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) {
|
|
593
598
|
const match = /\.([a-z0-9]+)$/i.exec(path);
|
|
594
599
|
if (match === null) return void 0;
|
|
595
600
|
switch (match[1].toLowerCase()) {
|
|
@@ -705,26 +710,28 @@ async function normalizeItem(item, upstream, signal) {
|
|
|
705
710
|
};
|
|
706
711
|
}
|
|
707
712
|
const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS);
|
|
708
|
-
let response;
|
|
709
713
|
try {
|
|
710
|
-
response
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
};
|
|
716
732
|
} finally {
|
|
717
733
|
budget.dispose();
|
|
718
734
|
}
|
|
719
|
-
if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
|
|
720
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
721
|
-
const contentType = response.headers.get("content-type");
|
|
722
|
-
const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
|
|
723
|
-
return {
|
|
724
|
-
b64: buffer.toString("base64"),
|
|
725
|
-
mime,
|
|
726
|
-
revisedPrompt
|
|
727
|
-
};
|
|
728
735
|
}
|
|
729
736
|
/** Expand a provider image item whose URL may be a string or an array. */
|
|
730
737
|
function imageItemsOf(value) {
|
|
@@ -821,43 +828,45 @@ async function pollAsyncTask(baseUrl, upstream, taskId, signal) {
|
|
|
821
828
|
while (Date.now() < deadline) {
|
|
822
829
|
const remaining = deadline - Date.now();
|
|
823
830
|
const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining));
|
|
824
|
-
let response;
|
|
825
831
|
try {
|
|
826
|
-
response
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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");
|
|
836
867
|
} finally {
|
|
837
868
|
budget.dispose();
|
|
838
869
|
}
|
|
839
|
-
let payload;
|
|
840
|
-
try {
|
|
841
|
-
payload = await response.json();
|
|
842
|
-
} catch {
|
|
843
|
-
throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
|
|
844
|
-
}
|
|
845
|
-
if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), "upstream-rejected");
|
|
846
|
-
const record = payload;
|
|
847
|
-
const data = record.data;
|
|
848
|
-
const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === "object" ? data : record;
|
|
849
|
-
const statusValue = statusRecord !== null && typeof statusRecord === "object" ? statusRecord.status : void 0;
|
|
850
|
-
const status = typeof statusValue === "string" ? statusValue.toLowerCase() : "";
|
|
851
|
-
if (ASYNC_FAILED_STATUSES.has(status)) throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || "unknown"})`), "upstream-rejected");
|
|
852
|
-
const nested = statusRecord !== null && typeof statusRecord === "object" ? statusRecord : record;
|
|
853
|
-
const result = nested.result ?? (nested.output !== null && typeof nested.output === "object" ? nested.output.result : void 0) ?? record.result;
|
|
854
|
-
const images = (result !== null && typeof result === "object" ? result : void 0)?.images ?? nested.images ?? record.images;
|
|
855
|
-
if (ASYNC_COMPLETED_STATUSES.has(status) || images !== void 0) {
|
|
856
|
-
const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images);
|
|
857
|
-
if (items.length > 0) return items;
|
|
858
|
-
if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
|
|
859
|
-
}
|
|
860
|
-
if (status !== "" && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, "upstream-invalid");
|
|
861
870
|
await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal);
|
|
862
871
|
delay = Math.min(5e3, delay * 2);
|
|
863
872
|
}
|
|
@@ -929,39 +938,43 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
|
|
|
929
938
|
});
|
|
930
939
|
}
|
|
931
940
|
const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
|
|
932
|
-
let response;
|
|
933
941
|
try {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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)));
|
|
945
975
|
} finally {
|
|
946
976
|
budget.dispose();
|
|
947
977
|
}
|
|
948
|
-
let payload;
|
|
949
|
-
try {
|
|
950
|
-
payload = await response.json();
|
|
951
|
-
} catch {
|
|
952
|
-
throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
|
|
953
|
-
}
|
|
954
|
-
if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
|
|
955
|
-
const data = dataRecordsOf(payload);
|
|
956
|
-
if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
|
|
957
|
-
if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
|
|
958
|
-
const asyncEntries = data.filter((entry) => typeof entry.task_id === "string" && entry.task_id.trim() !== "");
|
|
959
|
-
if (asyncEntries.length > 0) {
|
|
960
|
-
const asyncRecords = (await Promise.all(asyncEntries.map((entry) => pollAsyncTask(baseUrl, upstream, entry.task_id, signal)))).flat();
|
|
961
|
-
if (asyncRecords.length === 0) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
|
|
962
|
-
return Promise.all(asyncRecords.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
|
|
963
|
-
}
|
|
964
|
-
return Promise.all(data.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
|
|
965
978
|
}
|
|
966
979
|
/**
|
|
967
980
|
* Qwen-Image (DashScope native multimodal-generation): one chat-style request
|
|
@@ -995,50 +1008,54 @@ async function generateQwenImage(baseUrl, upstream, request, options) {
|
|
|
995
1008
|
}
|
|
996
1009
|
};
|
|
997
1010
|
const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
|
|
998
|
-
let response;
|
|
999
1011
|
try {
|
|
1000
|
-
response
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
+
})) };
|
|
1013
1056
|
} finally {
|
|
1014
1057
|
budget.dispose();
|
|
1015
1058
|
}
|
|
1016
|
-
let payload;
|
|
1017
|
-
try {
|
|
1018
|
-
payload = await response.json();
|
|
1019
|
-
} catch {
|
|
1020
|
-
throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
|
|
1021
|
-
}
|
|
1022
|
-
if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
|
|
1023
|
-
const output = payload.output;
|
|
1024
|
-
const choices = output !== void 0 && Array.isArray(output.choices) ? output.choices : [];
|
|
1025
|
-
const urls = [];
|
|
1026
|
-
for (const choice of choices) {
|
|
1027
|
-
const message = choice !== null && typeof choice === "object" ? choice.message : void 0;
|
|
1028
|
-
const items = message !== null && typeof message === "object" && Array.isArray(message.content) ? message.content : [];
|
|
1029
|
-
for (const item of items) if (item !== null && typeof item === "object") {
|
|
1030
|
-
const image = item.image;
|
|
1031
|
-
if (typeof image === "string" && image !== "") urls.push(image);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
if (urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
|
|
1035
|
-
return { images: await Promise.all(urls.map(async (url) => {
|
|
1036
|
-
const normalized = await normalizeItem({ url }, upstream);
|
|
1037
|
-
return {
|
|
1038
|
-
b64: normalized.b64,
|
|
1039
|
-
mime: normalized.mime
|
|
1040
|
-
};
|
|
1041
|
-
})) };
|
|
1042
1059
|
}
|
|
1043
1060
|
/**
|
|
1044
1061
|
* Forward one generate request to the configured endpoint. The requested image
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dickpy/dsh-imagegen",
|
|
3
3
|
"description": "AI image generation plugin for the dsh web GUI: text-to-image and image-to-image through configurable provider channels (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with per-channel model catalogs and a New Session / Image Generation tab entry opening a three-column studio beside the native conversation.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -254,6 +254,8 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
254
254
|
const syncedRef = useRef('')
|
|
255
255
|
const processedTasks = useRef(new Set<string>())
|
|
256
256
|
const processedImport = useRef('')
|
|
257
|
+
const localTaskIds = useRef(new Set<string>())
|
|
258
|
+
const mountedAtRef = useRef(Date.now())
|
|
257
259
|
const internalClipboard = useRef<{ nodes: CanvasNode[]; connections: Array<{ fromNodeId: string; toNodeId: string }> } | null>(null)
|
|
258
260
|
const pastRef = useRef<string[]>([])
|
|
259
261
|
const futureRef = useRef<string[]>([])
|
|
@@ -496,6 +498,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
496
498
|
},
|
|
497
499
|
}
|
|
498
500
|
const task = await api.taskSubmit(request)
|
|
501
|
+
localTaskIds.current.add(task.id)
|
|
499
502
|
mutate(previous => {
|
|
500
503
|
const nodes = [...previous.nodes]
|
|
501
504
|
const connections = [...previous.connections]
|
|
@@ -525,6 +528,62 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
525
528
|
|
|
526
529
|
// ---------------------------------------------------------- task intake
|
|
527
530
|
|
|
531
|
+
/** Re-run a failed image node's generation from its recorded prompt/model,
|
|
532
|
+
* re-deriving the edit base from the connected source config node. */
|
|
533
|
+
const retryGeneration = useCallback(async (node: CanvasNode): Promise<void> => {
|
|
534
|
+
const current = documentRef.current
|
|
535
|
+
if (current === null || !connected) { setError(tt('canvas.needApi')); onOpenSettings?.(); return }
|
|
536
|
+
const metadata = nodeMetadata(node)
|
|
537
|
+
const prompt = (metadata.prompt ?? '').trim()
|
|
538
|
+
if (prompt === '') { setError(tt('canvas.needPrompt')); return }
|
|
539
|
+
const model = imageModels.includes(metadata.model ?? '') ? metadata.model! : imageModels[0] ?? ''
|
|
540
|
+
if (model === '') { setError(tt('canvas.needModel')); return }
|
|
541
|
+
const sourceId = metadata.sourceNodeId
|
|
542
|
+
const references = sourceId === undefined ? [] : upstreamNodes(current, sourceId).filter(item => item.type === 'image' && usableAsset(item) !== undefined)
|
|
543
|
+
const baseAsset = references[0] !== undefined ? usableAsset(references[0]!) : undefined
|
|
544
|
+
try {
|
|
545
|
+
let image: string | undefined
|
|
546
|
+
let refName: string | undefined
|
|
547
|
+
if (baseAsset !== undefined) {
|
|
548
|
+
image = await assetToDataUrl(baseAsset)
|
|
549
|
+
refName = 'canvas-reference.png'
|
|
550
|
+
}
|
|
551
|
+
const request: GenerateRequest = {
|
|
552
|
+
mode: image === undefined ? 'text' : 'edit', model, prompt, size: metadata.size ?? 'auto', quality: metadata.quality ?? 'auto', n: 1, detail: '',
|
|
553
|
+
...(defaultChannelId === undefined ? {} : { channelId: defaultChannelId }),
|
|
554
|
+
...(image === undefined ? {} : { image, refName }),
|
|
555
|
+
canvas: { canvasId: current.id, sourceNodeId: references[0]?.id ?? sourceId, parentNodeId: node.id, placement: 'right' as const },
|
|
556
|
+
}
|
|
557
|
+
const task = await api.taskSubmit(request)
|
|
558
|
+
localTaskIds.current.add(task.id)
|
|
559
|
+
patchNode(node.id, { status: 'generating', error: undefined, taskId: task.id })
|
|
560
|
+
setError(null)
|
|
561
|
+
} catch (caught) {
|
|
562
|
+
setError(caught instanceof Error ? caught.message : String(caught))
|
|
563
|
+
}
|
|
564
|
+
}, [api, connected, defaultChannelId, imageModels, onOpenSettings, patchNode, upstreamNodes])
|
|
565
|
+
|
|
566
|
+
// Orphan reconciliation: a generating placeholder whose task no longer exists
|
|
567
|
+
// in the host feed (e.g. the host restarted) can never complete on its own.
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (document === null) return
|
|
570
|
+
const feedFresh = tasks.length > 0 || Date.now() - mountedAtRef.current > 8000
|
|
571
|
+
if (!feedFresh) return
|
|
572
|
+
const feedIds = new Set(tasks.map(task => task.id))
|
|
573
|
+
const orphans = document.nodes.filter(node => {
|
|
574
|
+
if (node.type !== 'image' || nodeMetadata(node).status !== 'generating') return false
|
|
575
|
+
const taskId = nodeMetadata(node).taskId
|
|
576
|
+
return taskId !== undefined && !feedIds.has(taskId) && !localTaskIds.current.has(taskId)
|
|
577
|
+
})
|
|
578
|
+
if (orphans.length === 0) return
|
|
579
|
+
updateNodes(nodes => nodes.map(node => {
|
|
580
|
+
const taskId = node.type === 'image' ? nodeMetadata(node).taskId : undefined
|
|
581
|
+
if (node.type !== 'image' || nodeMetadata(node).status !== 'generating' || taskId === undefined
|
|
582
|
+
|| feedIds.has(taskId) || localTaskIds.current.has(taskId)) return node
|
|
583
|
+
return { ...node, metadata: { ...nodeMetadata(node), status: 'error', error: tt('canvas.taskLost') } }
|
|
584
|
+
}))
|
|
585
|
+
}, [document, tasks, updateNodes])
|
|
586
|
+
|
|
528
587
|
useEffect(() => {
|
|
529
588
|
if (document === null) return
|
|
530
589
|
const canvasTasks = tasks.filter(task => task.request.canvas?.canvasId === document.id)
|
|
@@ -1171,7 +1230,7 @@ export function CanvasWorkspace(props: CanvasWorkspaceProps): React.JSX.Element
|
|
|
1171
1230
|
{isGenerating
|
|
1172
1231
|
? <div className={css.nodeState}><span className={css.spinner} aria-hidden="true" /><span>{tt('canvas.generatingNode')}</span></div>
|
|
1173
1232
|
: isError
|
|
1174
|
-
? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() =>
|
|
1233
|
+
? <div className={css.nodeStateError}>{metadata.error ?? tt('canvas.generateFailed')}<button type="button" onClick={() => { void retryGeneration(node) }}>{tt('canvas.retry')}</button></div>
|
|
1175
1234
|
: hasImage
|
|
1176
1235
|
? <img src={asset.url} alt={node.title} draggable={false} onDragStart={event => event.preventDefault()} />
|
|
1177
1236
|
: <button type="button" className={css.nodeEmpty} onClick={() => setPickerOpen(true)}><ToolbarIcon name="image" /><span>{tt('canvas.emptyImageNode')}</span></button>}
|
package/src/client/locales.ts
CHANGED
|
@@ -60,6 +60,7 @@ export const zh = {
|
|
|
60
60
|
'canvas.generatingNode': '生成中…',
|
|
61
61
|
'canvas.generateFailed': '生成失败',
|
|
62
62
|
'canvas.retry': '重试',
|
|
63
|
+
'canvas.taskLost': '生成任务已丢失(服务重启),请重试',
|
|
63
64
|
'canvas.emptyImageNode': '点击上传图片',
|
|
64
65
|
'canvas.connectHint': '拖到其他节点建立连线',
|
|
65
66
|
'canvas.resizeHint': '拖动调整大小(图片锁定比例)',
|
|
@@ -550,6 +551,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
550
551
|
'canvas.generatingNode': 'Generating…',
|
|
551
552
|
'canvas.generateFailed': 'Generation failed',
|
|
552
553
|
'canvas.retry': 'Retry',
|
|
554
|
+
'canvas.taskLost': 'Generation task lost (service restarted); please retry',
|
|
553
555
|
'canvas.emptyImageNode': 'Click to upload an image',
|
|
554
556
|
'canvas.connectHint': 'Drag onto another node to connect',
|
|
555
557
|
'canvas.resizeHint': 'Drag to resize (image ratio locked)',
|
|
@@ -1032,6 +1034,7 @@ export const ru: Record<keyof typeof zh, string> = {
|
|
|
1032
1034
|
'canvas.generatingNode': 'Генерация…',
|
|
1033
1035
|
'canvas.generateFailed': 'Не удалось создать изображение',
|
|
1034
1036
|
'canvas.retry': 'Повторить',
|
|
1037
|
+
'canvas.taskLost': 'Задача генерации потеряна (сервис перезапущен); повторите',
|
|
1035
1038
|
'canvas.emptyImageNode': 'Нажмите, чтобы загрузить изображение',
|
|
1036
1039
|
'canvas.connectHint': 'Потяните к другому узлу, чтобы соединить',
|
|
1037
1040
|
'canvas.resizeHint': 'Потяните, чтобы изменить размер (пропорции зафиксированы)',
|