@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/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.4";
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
- /** Content-type extension hints for URL-fetched images. */
592
- function mimeOfExtension(path) {
591
+ /** Whether an error was produced by a requestSignal budget timeout. These can
592
+ * surface from the fetch call itself or from reading the response body, so the
593
+ * budget must stay armed until the body has been consumed. */
594
+ function isBudgetTimeout(error) {
595
+ return (error instanceof DOMException || error instanceof Error) && error.name === "TimeoutError";
596
+ }
597
+ /** Content-type extension hints for URL-fetched images. */ function mimeOfExtension(path) {
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 = await fetch(url, {
711
- ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
712
- signal: budget.signal
713
- });
714
- } catch (error) {
715
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
714
+ let response;
715
+ try {
716
+ response = await fetch(url, {
717
+ ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
718
+ signal: budget.signal
719
+ });
720
+ } catch (error) {
721
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
722
+ }
723
+ if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
724
+ const buffer = Buffer.from(await response.arrayBuffer());
725
+ const contentType = response.headers.get("content-type");
726
+ const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
727
+ return {
728
+ b64: buffer.toString("base64"),
729
+ mime,
730
+ revisedPrompt
731
+ };
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 = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
827
- method: "GET",
828
- headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
829
- signal: budget.signal
830
- });
831
- } catch (error) {
832
- if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
833
- const message = error instanceof Error ? error.message : String(error);
834
- if (/timeout|abort/i.test(message)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
835
- throw new ImageGenError(`无法轮询上游异步任务:${message}`, "upstream-unreachable");
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
- const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
935
- response = await fetch(`${baseUrl}${endpoint}`, {
936
- method: "POST",
937
- headers,
938
- body,
939
- signal: budget.signal
940
- });
941
- } catch (error) {
942
- const message = error instanceof Error ? error.message : String(error);
943
- if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
944
- throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
942
+ let response;
943
+ try {
944
+ const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
945
+ response = await fetch(`${baseUrl}${endpoint}`, {
946
+ method: "POST",
947
+ headers,
948
+ body,
949
+ signal: budget.signal
950
+ });
951
+ } catch (error) {
952
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
953
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
954
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
955
+ }
956
+ let payload;
957
+ try {
958
+ payload = await response.json();
959
+ } catch (error) {
960
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
961
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
962
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
963
+ }
964
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
965
+ const data = dataRecordsOf(payload);
966
+ if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
967
+ if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
968
+ const asyncEntries = data.filter((entry) => typeof entry.task_id === "string" && entry.task_id.trim() !== "");
969
+ if (asyncEntries.length > 0) {
970
+ const asyncRecords = (await Promise.all(asyncEntries.map((entry) => pollAsyncTask(baseUrl, upstream, entry.task_id, signal)))).flat();
971
+ if (asyncRecords.length === 0) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
972
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
973
+ }
974
+ return Promise.all(data.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
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 = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
1001
- method: "POST",
1002
- headers: {
1003
- authorization: `Bearer ${upstream.apiKey.trim()}`,
1004
- "content-type": "application/json"
1005
- },
1006
- body: JSON.stringify(body),
1007
- signal: budget.signal
1008
- });
1009
- } catch (error) {
1010
- const message = error instanceof Error ? error.message : String(error);
1011
- if (/aborter/i.test(message) || /timeout/i.test(message)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1012
- throw new ImageGenError(`无法连接上游接口:${message}`, "upstream-unreachable");
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",
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={() => patchNode(node.id, { status: 'idle', error: undefined })}>{tt('canvas.dismiss')}</button></div>
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>}
@@ -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': 'Потяните, чтобы изменить размер (пропорции зафиксированы)',