dsh-comfyui 0.4.0 → 0.5.1

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/client/client.js CHANGED
@@ -61,6 +61,10 @@ window.__ModuleLoader__.load({
61
61
  error: "出错",
62
62
  refresh: "刷新",
63
63
  ours: "本插件",
64
+ connChecking: "正在检测 ComfyUI 连接…",
65
+ connOk: "连接正常 · ComfyUI {version} · {ms} ms",
66
+ connFailTitle: "无法连接 ComfyUI",
67
+ connFailBody: "后端未能连上 ComfyUI({message})。请先启动本机 ComfyUI,确保后端能检测到其端口;若使用远程服务器,请到设置页检查服务器地址并点「测试连接」。",
64
68
  wfAdd: "新建工作流",
65
69
  wfName: "名称",
66
70
  wfDesc: "概述",
@@ -131,7 +135,34 @@ window.__ModuleLoader__.load({
131
135
  wfEmpty: "没有检测到任何工作流,请通过新建、导入或提取来创建执行流程。",
132
136
  wfLibrarySection: "API 工作流",
133
137
  wfRefresh: "刷新",
134
- wfImportFile: "导入文件",
138
+ wfImportFile: "导入 JSON",
139
+ exportPresets: "导出预设",
140
+ importPresets: "导入预设",
141
+ transferExportTitle: "导出工作流预设",
142
+ transferImportTitle: "导入工作流预设",
143
+ transferExportHint: "共 {total} 个可导出工作流,已选 {selected} 个。运行参数与技能包会一并导出。",
144
+ transferSelectAll: "全选",
145
+ transferSelectNone: "全不选",
146
+ transferExportRun: "导出 ({n})",
147
+ transferImportRun: "导入 ({n})",
148
+ transferExporting: "正在导出…",
149
+ transferImporting: "正在导入…",
150
+ transferExportDone: "已导出 {n} 个工作流:{names}(浏览器正在下载预设包)",
151
+ transferExportFile: "文件 {file}({size} KB)",
152
+ transferExportWarnings: "导出警告:{warnings}",
153
+ transferSelectedList: "将导出:{names}",
154
+ transferExportEmpty: "没有可导出的工作流",
155
+ transferPickFile: "点击选择或拖入 .zip 预设包",
156
+ transferPickHint: "导入会创建为新工作流,不会覆盖库中已有的工作流",
157
+ transferPackageInfo: "包内共 {n} 个工作流 · 导出于 {date}",
158
+ transferParamBadge: "参数 {n}",
159
+ transferSkillBadge: "技能包 · {n} 个文件",
160
+ transferSkillRequired: "技能包 · {n} 个文件 · 必读",
161
+ transferImportDone: "已导入 {n} 个工作流",
162
+ transferImportedItem: "✓ {src} → {dst}",
163
+ transferImportFailedItem: "✗ {src}:{reason}",
164
+ transferImportNote: "重名工作流自动追加(导入)后缀",
165
+ transferFinish: "完成",
135
166
  wfComfyuiSection: "ComfyUI 工作流",
136
167
  wfComfyuiHint: "面板自动检测 ComfyUI 中保存的工作流。\n注:非 API 流程,无法直接执行,需经过提取转化为 API 工作流,或自行导入 API 工作流即可执行。",
137
168
  wfComfyuiEmpty: "ComfyUI 端没有检测到保存的工作流",
@@ -293,6 +324,10 @@ window.__ModuleLoader__.load({
293
324
  error: "Error",
294
325
  refresh: "Refresh",
295
326
  ours: "ours",
327
+ connChecking: "Checking the ComfyUI connection…",
328
+ connOk: "Connected · ComfyUI {version} · {ms} ms",
329
+ connFailTitle: "Cannot reach ComfyUI",
330
+ connFailBody: "The backend could not reach ComfyUI ({message}). Start the local ComfyUI and make sure the backend can see its port; for a remote server, check the URL in settings and run \"Test connection\".",
296
331
  wfAdd: "New workflow",
297
332
  wfName: "Name",
298
333
  wfDesc: "Overview",
@@ -363,7 +398,34 @@ window.__ModuleLoader__.load({
363
398
  wfEmpty: "No workflows detected — create one via New, Import, or Extract.",
364
399
  wfLibrarySection: "API workflows",
365
400
  wfRefresh: "Refresh",
366
- wfImportFile: "Import file",
401
+ wfImportFile: "Import JSON",
402
+ exportPresets: "Export presets",
403
+ importPresets: "Import presets",
404
+ transferExportTitle: "Export workflow presets",
405
+ transferImportTitle: "Import workflow presets",
406
+ transferExportHint: "{total} exportable workflows, {selected} selected. Parameters and skill packs are included.",
407
+ transferSelectAll: "Select all",
408
+ transferSelectNone: "Select none",
409
+ transferExportRun: "Export ({n})",
410
+ transferImportRun: "Import ({n})",
411
+ transferExporting: "Exporting…",
412
+ transferImporting: "Importing…",
413
+ transferExportDone: "Exported {n} workflows: {names} (the browser is downloading the package)",
414
+ transferExportFile: "File {file} ({size} KB)",
415
+ transferExportWarnings: "Export warnings: {warnings}",
416
+ transferSelectedList: "Will export: {names}",
417
+ transferExportEmpty: "No exportable workflows",
418
+ transferPickFile: "Click to choose, or drop a .zip preset package here",
419
+ transferPickHint: "Import creates new workflows — nothing in your library is overwritten",
420
+ transferPackageInfo: "{n} workflows in package · exported {date}",
421
+ transferParamBadge: "{n} params",
422
+ transferSkillBadge: "Skill pack · {n} files",
423
+ transferSkillRequired: "Skill pack · {n} files · required",
424
+ transferImportDone: "Imported {n} workflows",
425
+ transferImportedItem: "✓ {src} → {dst}",
426
+ transferImportFailedItem: "✗ {src}: {reason}",
427
+ transferImportNote: "Name clashes get an \"(imported)\" suffix",
428
+ transferFinish: "Done",
367
429
  wfComfyuiSection: "ComfyUI workflows",
368
430
  wfComfyuiHint: "Auto-detects workflows saved in ComfyUI.\nNote: these are UI graphs, not directly runnable — extract them into API workflows, or import an API workflow instead.",
369
431
  wfComfyuiEmpty: "No workflows detected on the ComfyUI server",
@@ -997,49 +1059,49 @@ window.__ModuleLoader__.load({
997
1059
  * and the session-header trigger are separate registrations in one bundle,
998
1060
  * so they coordinate through this module-level store plus useSyncExternalStore.
999
1061
  */
1000
- const listeners = /* @__PURE__ */ new Set();
1062
+ const listeners$1 = /* @__PURE__ */ new Set();
1001
1063
  let open = false;
1002
1064
  let tab = "workflows";
1003
1065
  let assetFilter = "";
1004
- function emit() {
1005
- for (const listener of listeners) listener();
1066
+ function emit$1() {
1067
+ for (const listener of listeners$1) listener();
1006
1068
  }
1007
1069
  const panelStore = {
1008
1070
  isOpen: () => open,
1009
1071
  toggle: () => {
1010
1072
  open = !open;
1011
- emit();
1073
+ emit$1();
1012
1074
  },
1013
1075
  open: () => {
1014
1076
  if (!open) {
1015
1077
  open = true;
1016
- emit();
1078
+ emit$1();
1017
1079
  }
1018
1080
  },
1019
1081
  close: () => {
1020
1082
  if (open) {
1021
1083
  open = false;
1022
- emit();
1084
+ emit$1();
1023
1085
  }
1024
1086
  },
1025
1087
  getTab: () => tab,
1026
1088
  setTab: (next) => {
1027
1089
  if (tab !== next) {
1028
1090
  tab = next;
1029
- emit();
1091
+ emit$1();
1030
1092
  }
1031
1093
  },
1032
1094
  getAssetFilter: () => assetFilter,
1033
1095
  setAssetFilter: (name) => {
1034
1096
  if (assetFilter !== name) {
1035
1097
  assetFilter = name;
1036
- emit();
1098
+ emit$1();
1037
1099
  }
1038
1100
  },
1039
1101
  subscribe(listener) {
1040
- listeners.add(listener);
1102
+ listeners$1.add(listener);
1041
1103
  return () => {
1042
- listeners.delete(listener);
1104
+ listeners$1.delete(listener);
1043
1105
  };
1044
1106
  }
1045
1107
  };
@@ -1052,10 +1114,132 @@ window.__ModuleLoader__.load({
1052
1114
  return (0, react.useSyncExternalStore)(panelStore.subscribe, panelStore.getTab, panelStore.getTab);
1053
1115
  }
1054
1116
  //#endregion
1117
+ //#region src/client/connection.tsx
1118
+ /**
1119
+ * Connection probe + reminder toast. Opening the panel used to give zero
1120
+ * feedback when ComfyUI was down: the workflows/queue tabs just stayed empty
1121
+ * with no explanation. The header trigger now fires a real backend probe
1122
+ * (POST /comfyui/test → the host connects to the configured ComfyUI server —
1123
+ * local port or remote URL alike); when it fails, the panel closes again —
1124
+ * the trigger must not stay highlighted against a dead backend — and a fixed
1125
+ * toast at the top of the page tells the user to start ComfyUI or fix the
1126
+ * server address. The probe never blocks the panel: it opens immediately,
1127
+ * the toast settles whenever the probe answers.
1128
+ */
1129
+ const listeners = /* @__PURE__ */ new Set();
1130
+ let state = { status: "hidden" };
1131
+ /** Dedupe concurrent probes (rapid trigger clicks). */
1132
+ let inFlight = null;
1133
+ /** Skip re-probing while a "connected" verdict is still fresh. */
1134
+ let lastOkAt = 0;
1135
+ /** The ok toast only shows on fail→ok transitions, never on healthy opens. */
1136
+ let hadFailure = false;
1137
+ let okHideTimer = null;
1138
+ function emit() {
1139
+ for (const listener of listeners) listener();
1140
+ }
1141
+ function setState(next) {
1142
+ state = next;
1143
+ emit();
1144
+ }
1145
+ function clearOkTimer() {
1146
+ if (okHideTimer !== null) {
1147
+ clearTimeout(okHideTimer);
1148
+ okHideTimer = null;
1149
+ }
1150
+ }
1151
+ /** Close button on the toast; also ends the transient ok feedback early. */
1152
+ function dismissConnectionToast() {
1153
+ clearOkTimer();
1154
+ if (state.status !== "hidden") setState({ status: "hidden" });
1155
+ }
1156
+ /**
1157
+ * Probe the ComfyUI server through the host and surface the result as toast
1158
+ * state. A success within the last 30s skips the round-trip entirely, so
1159
+ * healthy repeated panel opens do not hammer the server; failures always
1160
+ * re-probe on the next open (the user may have just started ComfyUI).
1161
+ */
1162
+ function probeConnection() {
1163
+ if (inFlight !== null) return inFlight;
1164
+ if (Date.now() - lastOkAt < 3e4) return Promise.resolve();
1165
+ if (state.status !== "fail") {
1166
+ clearOkTimer();
1167
+ setState({ status: "probing" });
1168
+ }
1169
+ inFlight = (async () => {
1170
+ try {
1171
+ const payload = await postJson("/comfyui/test", {});
1172
+ if (payload.ok === true) {
1173
+ lastOkAt = Date.now();
1174
+ const recovered = hadFailure;
1175
+ hadFailure = false;
1176
+ if (recovered) {
1177
+ setState({
1178
+ status: "ok",
1179
+ version: payload.version,
1180
+ latencyMs: payload.latencyMs
1181
+ });
1182
+ clearOkTimer();
1183
+ okHideTimer = setTimeout(dismissConnectionToast, 4e3);
1184
+ } else setState({ status: "hidden" });
1185
+ } else {
1186
+ hadFailure = true;
1187
+ setState({
1188
+ status: "fail",
1189
+ message: payload.error ?? "unknown error"
1190
+ });
1191
+ panelStore.close();
1192
+ }
1193
+ } catch (error) {
1194
+ hadFailure = true;
1195
+ setState({
1196
+ status: "fail",
1197
+ message: error instanceof Error ? error.message : String(error)
1198
+ });
1199
+ panelStore.close();
1200
+ } finally {
1201
+ inFlight = null;
1202
+ }
1203
+ })();
1204
+ return inFlight;
1205
+ }
1206
+ function getConnState() {
1207
+ return state;
1208
+ }
1209
+ function subscribe(listener) {
1210
+ listeners.add(listener);
1211
+ return () => {
1212
+ listeners.delete(listener);
1213
+ };
1214
+ }
1215
+ /** Fixed top-center status toast; mounted next to the header trigger so it
1216
+ * renders (and can be dismissed) even when the panel is closed again. The
1217
+ * probing variant fades in after a short CSS delay, so a fast successful
1218
+ * probe never paints anything. */
1219
+ function ConnectionToast({ t }) {
1220
+ const conn = (0, react.useSyncExternalStore)(subscribe, getConnState, getConnState);
1221
+ if (conn.status === "hidden") return null;
1222
+ return (0, react.createElement)("div", {
1223
+ className: `dsc-conn-toast dsc-conn-toast--${conn.status}`,
1224
+ role: "status"
1225
+ }, (0, react.createElement)("span", {
1226
+ className: "dsc-conn-toast-dot",
1227
+ "aria-hidden": true
1228
+ }), (0, react.createElement)("div", { className: "dsc-conn-toast-body" }, conn.status === "fail" ? (0, react.createElement)("div", { className: "dsc-conn-toast-body-inner" }, (0, react.createElement)("div", { className: "dsc-conn-toast-title" }, t("connFailTitle")), (0, react.createElement)("div", { className: "dsc-conn-toast-text" }, t("connFailBody", { message: conn.message ?? "" }))) : (0, react.createElement)("div", { className: "dsc-conn-toast-title" }, conn.status === "probing" ? t("connChecking") : t("connOk", {
1229
+ version: conn.version ?? "unknown",
1230
+ ms: conn.latencyMs ?? 0
1231
+ }))), (0, react.createElement)("button", {
1232
+ className: "dsc-conn-toast-close",
1233
+ "aria-label": t("close"),
1234
+ onClick: dismissConnectionToast
1235
+ }, "✕"));
1236
+ }
1237
+ //#endregion
1055
1238
  //#region src/client/trigger.tsx
1056
1239
  /**
1057
1240
  * The session-header trigger (conversation.session.header.actions, id
1058
- * 'comfyui'): a labelled button that toggles the ComfyUI panel.
1241
+ * 'comfyui'): a labelled button that toggles the ComfyUI panel, plus the
1242
+ * connection reminder toast shown when the backend cannot reach ComfyUI.
1059
1243
  */
1060
1244
  /**
1061
1245
  * ComfyUI brand glyph (mono), inlined from @lobehub/icons-static-svg
@@ -1075,16 +1259,22 @@ window.__ModuleLoader__.load({
1075
1259
  "aria-hidden": true
1076
1260
  }, (0, react.createElement)("path", { d: "M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" }));
1077
1261
  }
1078
- /** Header-action button toggling the ComfyUI panel. */
1262
+ /** Header-action button toggling the ComfyUI panel. Opening it also fires a
1263
+ * backend connectivity probe: when ComfyUI is down, a reminder toast explains
1264
+ * why the panel stays empty instead of the click appearing to do nothing. */
1079
1265
  function ComfyUITrigger({ t }) {
1080
1266
  const open = usePanelOpen();
1081
- return (0, react.createElement)("button", {
1267
+ return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("button", {
1082
1268
  className: "dsc-trigger",
1083
1269
  title: t("panelTitle"),
1084
1270
  "aria-label": t("panelTitle"),
1085
1271
  "aria-pressed": open,
1086
- onClick: () => panelStore.toggle()
1087
- }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle")));
1272
+ onClick: () => {
1273
+ const opening = !panelStore.isOpen();
1274
+ panelStore.toggle();
1275
+ if (opening) probeConnection();
1276
+ }
1277
+ }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle"))), (0, react.createElement)(ConnectionToast, { t }));
1088
1278
  }
1089
1279
  //#endregion
1090
1280
  //#region src/client/panel.tsx
@@ -2971,6 +3161,8 @@ window.__ModuleLoader__.load({
2971
3161
  const [busy, setBusy] = (0, react.useState)(false);
2972
3162
  const [viewing, setViewing] = (0, react.useState)(null);
2973
3163
  const [extract, setExtract] = (0, react.useState)(null);
3164
+ /** Open transfer dialog: 'export' | 'import', null = closed. */
3165
+ const [transfer, setTransfer] = (0, react.useState)(null);
2974
3166
  const [tagFilter, setTagFilter] = (0, react.useState)(null);
2975
3167
  const [skillFor, setSkillFor] = (0, react.useState)(null);
2976
3168
  /** A delete waiting on the user's answer about the workflow's skill pack. */
@@ -3174,6 +3366,25 @@ window.__ModuleLoader__.load({
3174
3366
  onRun: (state) => runExtract(state),
3175
3367
  onClose: () => setExtract(null)
3176
3368
  });
3369
+ if (transfer === "export") return (0, react.createElement)(ExportDialog, {
3370
+ t,
3371
+ workflows: list ?? [],
3372
+ onClose: () => setTransfer(null),
3373
+ onDone: (notice) => {
3374
+ setTransfer(null);
3375
+ setNotice(notice);
3376
+ load();
3377
+ }
3378
+ });
3379
+ if (transfer === "import") return (0, react.createElement)(ImportDialog, {
3380
+ t,
3381
+ onClose: () => setTransfer(null),
3382
+ onDone: (notice) => {
3383
+ setTransfer(null);
3384
+ setNotice(notice);
3385
+ load();
3386
+ }
3387
+ });
3177
3388
  if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
3178
3389
  t,
3179
3390
  message: error
@@ -3382,6 +3593,20 @@ window.__ModuleLoader__.load({
3382
3593
  event.target.value = "";
3383
3594
  }
3384
3595
  })), (0, react.createElement)("button", {
3596
+ className: "dsc-btn",
3597
+ disabled: busy,
3598
+ onClick: () => {
3599
+ setError(null);
3600
+ setTransfer("export");
3601
+ }
3602
+ }, t("exportPresets")), (0, react.createElement)("button", {
3603
+ className: "dsc-btn",
3604
+ disabled: busy,
3605
+ onClick: () => {
3606
+ setError(null);
3607
+ setTransfer("import");
3608
+ }
3609
+ }, t("importPresets")), (0, react.createElement)("button", {
3385
3610
  className: "dsc-btn",
3386
3611
  disabled: busy,
3387
3612
  onClick: () => void load()
@@ -3403,6 +3628,10 @@ window.__ModuleLoader__.load({
3403
3628
  ...state,
3404
3629
  error: data.error ?? "failed to analyze"
3405
3630
  });
3631
+ else if (data.analysis.ok !== true) onUpdate({
3632
+ ...state,
3633
+ error: data.analysis.error
3634
+ });
3406
3635
  else onUpdate({
3407
3636
  ...state,
3408
3637
  analysis: data.analysis,
@@ -3464,6 +3693,348 @@ window.__ModuleLoader__.load({
3464
3693
  onClick: onClose
3465
3694
  }, t("wfCancel")))));
3466
3695
  }
3696
+ /** Shared checkbox row for the transfer dialogs: name, badges, description. */
3697
+ function TransferItem(props) {
3698
+ return (0, react.createElement)("label", { className: "dsc-transfer-item" }, (0, react.createElement)("input", {
3699
+ type: "checkbox",
3700
+ checked: props.checked,
3701
+ onChange: props.onToggle
3702
+ }), (0, react.createElement)("span", {
3703
+ className: "dsc-transfer-item-name",
3704
+ title: props.name
3705
+ }, props.name), props.paramBadge !== null ? (0, react.createElement)("span", { className: "dsc-badge" }, props.paramBadge) : null, props.skillBadge !== null ? (0, react.createElement)("span", { className: "dsc-badge dsc-badge--ok" }, props.skillBadge) : null, props.description !== "" ? (0, react.createElement)("span", {
3706
+ className: "dsc-transfer-item-desc",
3707
+ title: props.description
3708
+ }, props.description) : null, props.warnings.length > 0 ? (0, react.createElement)("span", {
3709
+ className: "dsc-transfer-item-desc dsc-danger-text",
3710
+ title: props.warnings.join(";")
3711
+ }, props.warnings.join(";")) : null);
3712
+ }
3713
+ /** While a transfer dialog is open, swallow drag events aimed at the host
3714
+ * page. The conversation shell attaches dropped files to the chat with
3715
+ * document-level bubble listeners, so dragging a preset toward the dialog
3716
+ * also dropped it into the conversation. Window-capture listeners run before
3717
+ * any document-bubble handler and shield everything OUTSIDE the dialog;
3718
+ * events landing inside the dialog pass through — the dialog box itself must
3719
+ * stopPropagation() them at React's dispatch root, or they bubble on to the
3720
+ * document and the shell attaches the file anyway. */
3721
+ function DragGuard() {
3722
+ (0, react.useEffect)(() => {
3723
+ const guard = (event) => {
3724
+ if (event.target instanceof Element && event.target.closest(".dsc-transfer") !== null) return;
3725
+ event.preventDefault();
3726
+ event.stopPropagation();
3727
+ };
3728
+ for (const type of [
3729
+ "dragenter",
3730
+ "dragover",
3731
+ "drop"
3732
+ ]) window.addEventListener(type, guard, true);
3733
+ return () => {
3734
+ for (const type of [
3735
+ "dragenter",
3736
+ "dragover",
3737
+ "drop"
3738
+ ]) window.removeEventListener(type, guard, true);
3739
+ };
3740
+ }, []);
3741
+ return null;
3742
+ }
3743
+ /** Export dialog: pick library workflows (checkboxes + select-all, all
3744
+ * pre-selected) and download one preset package. Packaging happens host-side;
3745
+ * the dialog lists, selects, and submits a native form download. */
3746
+ function ExportDialog(props) {
3747
+ const { t, workflows } = props;
3748
+ const [selected, setSelected] = (0, react.useState)(() => new Set(workflows.map((workflow) => workflow.id)));
3749
+ const [busy, setBusy] = (0, react.useState)(false);
3750
+ const toggle = (id) => {
3751
+ setSelected((prev) => {
3752
+ const next = new Set(prev);
3753
+ if (next.has(id)) next.delete(id);
3754
+ else next.add(id);
3755
+ return next;
3756
+ });
3757
+ };
3758
+ const allSelected = workflows.length > 0 && selected.size === workflows.length;
3759
+ const toggleAll = () => {
3760
+ setSelected(allSelected ? /* @__PURE__ */ new Set() : new Set(workflows.map((workflow) => workflow.id)));
3761
+ };
3762
+ /** Download natively: a hidden form GETs `/comfyui/workflows/export/<file
3763
+ * name>?ids=…` and the server answers with `content-disposition:
3764
+ * attachment`, so the browser — not JS blob plumbing (fetch → blob →
3765
+ * objectURL → revoke, which one environment turned into a 0-byte file) —
3766
+ * owns the transfer. The page never navigates because the response is an
3767
+ * attachment.
3768
+ *
3769
+ * GET with the name in the path is deliberate: this setup's download
3770
+ * manager mangles POST downloads (renames them, or re-fetches the URL on
3771
+ * its own), which is where the phantom "skills sometimes missing" zips
3772
+ * came from. A GET is the one verb every manager handles correctly — and
3773
+ * since the name rides in the URL, saved name == receipt name even under
3774
+ * interception, and a manager's own re-fetch still gets a genuine export
3775
+ * of the same ids (the route is a pure read).
3776
+ *
3777
+ * The form response is invisible to JS, so the notice is fed from
3778
+ * `export/last` — the server's record of what it ACTUALLY packaged — not
3779
+ * from the checkboxes. */
3780
+ const exportSelected = () => {
3781
+ if (busy) return;
3782
+ setBusy(true);
3783
+ (async () => {
3784
+ let previousAt = null;
3785
+ try {
3786
+ previousAt = (await getJson("/comfyui/workflows/export/last")).last?.at ?? null;
3787
+ } catch {}
3788
+ const now = /* @__PURE__ */ new Date();
3789
+ const p = (n, width = 2) => String(n).padStart(width, "0");
3790
+ const filename = `dsh-comfyui-presets-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}-${p(now.getMilliseconds(), 3)}-${Math.random().toString(36).slice(2, 8)}.zip`;
3791
+ const form = document.createElement("form");
3792
+ form.method = "GET";
3793
+ form.action = `/comfyui/workflows/export/${filename}`;
3794
+ form.style.display = "none";
3795
+ for (const id of selected) {
3796
+ const input = document.createElement("input");
3797
+ input.type = "hidden";
3798
+ input.name = "ids";
3799
+ input.value = id;
3800
+ form.append(input);
3801
+ }
3802
+ document.body.append(form);
3803
+ form.submit();
3804
+ form.remove();
3805
+ let last = null;
3806
+ for (let attempt = 0; attempt < 10 && last === null; attempt++) {
3807
+ await new Promise((resolve) => window.setTimeout(resolve, 350));
3808
+ try {
3809
+ const payload = await getJson("/comfyui/workflows/export/last");
3810
+ if (payload.last !== null && payload.last.at !== previousAt) last = payload.last;
3811
+ } catch {}
3812
+ }
3813
+ const names = (last?.names ?? selectedNames()).join("、");
3814
+ let notice = t("transferExportDone", {
3815
+ n: last?.count ?? selected.size,
3816
+ names
3817
+ });
3818
+ if (last !== null) notice += " " + t("transferExportFile", {
3819
+ file: last.filename,
3820
+ size: Math.max(1, Math.round(last.size / 1024))
3821
+ });
3822
+ if (last !== null && last.warnings.length > 0) notice += " " + t("transferExportWarnings", { warnings: last.warnings.join(";") });
3823
+ props.onDone(notice);
3824
+ })();
3825
+ };
3826
+ /** Names behind the current selection, dialog order, with pack-bearing
3827
+ * entries annotated. Shown next to the submit button so "what will be
3828
+ * packaged — and will it carry skills" is readable at the decision point:
3829
+ * a selection that happens to exclude the one pack-bearing workflow looks
3830
+ * exactly like "skills export unreliably" from the zip alone. */
3831
+ const selectedNames = () => workflows.filter((workflow) => selected.has(workflow.id)).map((workflow) => workflow.skillDir !== void 0 ? `${workflow.name} (${t("skillBadge")})` : workflow.name);
3832
+ return (0, react.createElement)("div", {
3833
+ className: "dsc-picker-overlay",
3834
+ onClick: props.onClose
3835
+ }, (0, react.createElement)(DragGuard), (0, react.createElement)("div", {
3836
+ className: "dsc-transfer",
3837
+ onClick: (event) => event.stopPropagation(),
3838
+ onDragEnter: (event) => {
3839
+ event.preventDefault();
3840
+ event.stopPropagation();
3841
+ },
3842
+ onDragOver: (event) => {
3843
+ event.preventDefault();
3844
+ event.stopPropagation();
3845
+ },
3846
+ onDrop: (event) => {
3847
+ event.preventDefault();
3848
+ event.stopPropagation();
3849
+ }
3850
+ }, (0, react.createElement)("div", { className: "dsc-transfer-head" }, (0, react.createElement)("span", { className: "dsc-transfer-title" }, t("transferExportTitle")), (0, react.createElement)("button", {
3851
+ className: "dsc-panel-close",
3852
+ "aria-label": t("close"),
3853
+ onClick: props.onClose
3854
+ }, "✕")), workflows.length === 0 ? (0, react.createElement)("div", { className: "dsc-transfer-empty" }, t("transferExportEmpty")) : (0, react.createElement)("div", { className: "dsc-transfer-body" }, (0, react.createElement)("div", { className: "dsc-transfer-bar" }, (0, react.createElement)("span", { className: "dsc-hint" }, t("transferExportHint", {
3855
+ total: workflows.length,
3856
+ selected: selected.size
3857
+ })), (0, react.createElement)("button", {
3858
+ className: "dsc-btn dsc-btn--sm",
3859
+ onClick: toggleAll
3860
+ }, allSelected ? t("transferSelectNone") : t("transferSelectAll"))), (0, react.createElement)("div", { className: "dsc-transfer-list" }, workflows.map((workflow) => (0, react.createElement)(TransferItem, {
3861
+ key: workflow.id,
3862
+ name: workflow.name,
3863
+ description: workflow.description,
3864
+ paramBadge: (workflow.parameters ?? []).length > 0 ? t("transferParamBadge", { n: (workflow.parameters ?? []).length }) : null,
3865
+ skillBadge: workflow.skillDir !== void 0 ? t("skillBadge") : null,
3866
+ warnings: [],
3867
+ checked: selected.has(workflow.id),
3868
+ onToggle: () => toggle(workflow.id)
3869
+ })))), selected.size > 0 ? (0, react.createElement)("div", { className: "dsc-transfer-note" }, (0, react.createElement)("span", {
3870
+ className: "dsc-hint",
3871
+ title: selectedNames().join("、")
3872
+ }, t("transferSelectedList", { names: selectedNames().join("、") }))) : null, (0, react.createElement)("div", { className: "dsc-transfer-actions" }, workflows.length === 0 ? null : (0, react.createElement)("button", {
3873
+ className: "dsc-btn dsc-btn--primary",
3874
+ disabled: busy || selected.size === 0,
3875
+ onClick: () => exportSelected()
3876
+ }, busy ? t("transferExporting") : t("transferExportRun", { n: selected.size })), (0, react.createElement)("button", {
3877
+ className: "dsc-btn",
3878
+ disabled: busy,
3879
+ onClick: props.onClose
3880
+ }, t("cancel")))));
3881
+ }
3882
+ /** Import dialog: choose a preset package (file picker or drag-drop), see
3883
+ * what it offers, then import the selection as NEW workflows. The file is
3884
+ * POSTed twice by design — analyze and apply parse the same bytes, so the
3885
+ * host needs no temp-file staging or cleanup. */
3886
+ function ImportDialog(props) {
3887
+ const { t } = props;
3888
+ const [file, setFile] = (0, react.useState)(null);
3889
+ const [analysis, setAnalysis] = (0, react.useState)(null);
3890
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
3891
+ const [busy, setBusy] = (0, react.useState)(false);
3892
+ const [error, setError] = (0, react.useState)(null);
3893
+ const [results, setResults] = (0, react.useState)(null);
3894
+ const [imported, setImported] = (0, react.useState)(0);
3895
+ const [dragOver, setDragOver] = (0, react.useState)(false);
3896
+ /** Phase 1: upload the package for analysis (no server-side writes). */
3897
+ const analyze = async (picked) => {
3898
+ setBusy(true);
3899
+ setError(null);
3900
+ try {
3901
+ const data = await postRaw("/comfyui/workflows/import/analyze", await picked.arrayBuffer());
3902
+ if (data.ok !== true || data.analysis === void 0) throw new Error(data.error ?? "failed to analyze");
3903
+ setFile(picked);
3904
+ setAnalysis(data.analysis);
3905
+ setSelected(new Set(data.analysis.workflows.map((entry) => entry.index)));
3906
+ } catch (cause) {
3907
+ setError(cause instanceof Error ? cause.message : String(cause));
3908
+ } finally {
3909
+ setBusy(false);
3910
+ }
3911
+ };
3912
+ const toggle = (index) => {
3913
+ setSelected((prev) => {
3914
+ const next = new Set(prev);
3915
+ if (next.has(index)) next.delete(index);
3916
+ else next.add(index);
3917
+ return next;
3918
+ });
3919
+ };
3920
+ const allSelected = analysis !== null && analysis.workflows.length > 0 && selected.size === analysis.workflows.length;
3921
+ const toggleAll = () => {
3922
+ if (analysis === null) return;
3923
+ setSelected(allSelected ? /* @__PURE__ */ new Set() : new Set(analysis.workflows.map((entry) => entry.index)));
3924
+ };
3925
+ /** Phase 2: re-upload the same bytes with the selection as manifest indexes. */
3926
+ const importSelected = async () => {
3927
+ if (file === null) return;
3928
+ setBusy(true);
3929
+ setError(null);
3930
+ try {
3931
+ const bytes = await file.arrayBuffer();
3932
+ const data = await postRaw(`/comfyui/workflows/import/apply?select=${[...selected].join(",")}`, bytes);
3933
+ if (data.ok !== true || data.results === void 0) throw new Error(data.error ?? "failed to import");
3934
+ setResults(data.results);
3935
+ setImported(data.imported ?? 0);
3936
+ } catch (cause) {
3937
+ setError(cause instanceof Error ? cause.message : String(cause));
3938
+ } finally {
3939
+ setBusy(false);
3940
+ }
3941
+ };
3942
+ const finish = () => {
3943
+ if (imported > 0) props.onDone(t("transferImportDone", { n: imported }));
3944
+ else props.onClose();
3945
+ };
3946
+ return (0, react.createElement)("div", {
3947
+ className: "dsc-picker-overlay",
3948
+ onClick: props.onClose
3949
+ }, (0, react.createElement)(DragGuard), (0, react.createElement)("div", {
3950
+ className: "dsc-transfer",
3951
+ onClick: (event) => event.stopPropagation(),
3952
+ onDragEnter: (event) => {
3953
+ event.preventDefault();
3954
+ event.stopPropagation();
3955
+ },
3956
+ onDragOver: (event) => {
3957
+ event.preventDefault();
3958
+ event.stopPropagation();
3959
+ },
3960
+ onDrop: (event) => {
3961
+ event.preventDefault();
3962
+ event.stopPropagation();
3963
+ }
3964
+ }, (0, react.createElement)("div", { className: "dsc-transfer-head" }, (0, react.createElement)("span", { className: "dsc-transfer-title" }, t("transferImportTitle")), (0, react.createElement)("button", {
3965
+ className: "dsc-panel-close",
3966
+ "aria-label": t("close"),
3967
+ onClick: props.onClose
3968
+ }, "✕")), (0, react.createElement)("div", { className: "dsc-transfer-body" }, results !== null ? (0, react.createElement)("div", { className: "dsc-transfer-list" }, results.map((outcome) => (0, react.createElement)("div", {
3969
+ key: outcome.index,
3970
+ className: `dsc-transfer-item dsc-transfer-item--static${outcome.ok ? " dsc-transfer-item--ok" : " dsc-transfer-item--fail"}`
3971
+ }, (0, react.createElement)("span", {
3972
+ className: "dsc-transfer-item-name",
3973
+ title: outcome.name
3974
+ }, outcome.ok ? t("transferImportedItem", {
3975
+ src: outcome.name,
3976
+ dst: outcome.newName ?? ""
3977
+ }) : t("transferImportFailedItem", {
3978
+ src: outcome.name,
3979
+ reason: outcome.error ?? ""
3980
+ })), outcome.warnings.length > 0 ? (0, react.createElement)("span", {
3981
+ className: "dsc-transfer-item-desc",
3982
+ title: outcome.warnings.join(";")
3983
+ }, outcome.warnings.join(";")) : null))) : analysis === null ? (0, react.createElement)("label", {
3984
+ className: `dsc-transfer-drop${dragOver ? " dsc-transfer-drop--over" : ""}`,
3985
+ onDragOver: (event) => {
3986
+ event.preventDefault();
3987
+ setDragOver(true);
3988
+ },
3989
+ onDragLeave: () => setDragOver(false),
3990
+ onDrop: (event) => {
3991
+ event.preventDefault();
3992
+ setDragOver(false);
3993
+ const dropped = event.dataTransfer?.files?.[0];
3994
+ if (dropped !== void 0) analyze(dropped);
3995
+ }
3996
+ }, (0, react.createElement)("input", {
3997
+ type: "file",
3998
+ accept: ".zip,application/zip,application/x-zip-compressed",
3999
+ style: { display: "none" },
4000
+ onChange: (event) => {
4001
+ const picked = event.target.files?.[0];
4002
+ if (picked !== void 0) analyze(picked);
4003
+ event.target.value = "";
4004
+ }
4005
+ }), busy ? t("transferImporting") : t("transferPickFile")) : (0, react.createElement)("div", { className: "dsc-transfer-body" }, (0, react.createElement)("div", { className: "dsc-transfer-bar" }, (0, react.createElement)("span", { className: "dsc-hint" }, t("transferPackageInfo", {
4006
+ n: analysis.workflows.length,
4007
+ date: formatTs(analysis.exportedAt)
4008
+ })), (0, react.createElement)("button", {
4009
+ className: "dsc-btn dsc-btn--sm",
4010
+ onClick: toggleAll
4011
+ }, allSelected ? t("transferSelectNone") : t("transferSelectAll"))), (0, react.createElement)("div", { className: "dsc-transfer-list" }, analysis.workflows.map((entry) => (0, react.createElement)(TransferItem, {
4012
+ key: entry.index,
4013
+ name: entry.name,
4014
+ description: entry.description,
4015
+ paramBadge: entry.paramCount > 0 ? t("transferParamBadge", { n: entry.paramCount }) : null,
4016
+ skillBadge: entry.skill !== null ? entry.skill.required ? t("transferSkillRequired", { n: entry.skill.fileCount }) : t("transferSkillBadge", { n: entry.skill.fileCount }) : null,
4017
+ warnings: entry.warnings,
4018
+ checked: selected.has(entry.index),
4019
+ onToggle: () => toggle(entry.index)
4020
+ })))), error !== null ? (0, react.createElement)(ErrorNote, {
4021
+ t,
4022
+ message: error
4023
+ }) : null, analysis !== null && results === null ? (0, react.createElement)("div", { className: "dsc-hint" }, t("transferImportNote")) : null, analysis === null && results === null ? (0, react.createElement)("div", { className: "dsc-hint" }, t("transferPickHint")) : null), (0, react.createElement)("div", { className: "dsc-transfer-actions" }, results !== null ? (0, react.createElement)("button", {
4024
+ className: "dsc-btn dsc-btn--primary",
4025
+ onClick: finish
4026
+ }, t("transferFinish")) : analysis !== null ? (0, react.createElement)("button", {
4027
+ className: "dsc-btn dsc-btn--primary",
4028
+ disabled: busy || selected.size === 0,
4029
+ onClick: () => {
4030
+ importSelected();
4031
+ }
4032
+ }, busy ? t("transferImporting") : t("transferImportRun", { n: selected.size })) : null, (0, react.createElement)("button", {
4033
+ className: "dsc-btn",
4034
+ disabled: busy,
4035
+ onClick: results !== null ? finish : props.onClose
4036
+ }, t("cancel")))));
4037
+ }
3467
4038
  /** Read-only inspection of a ComfyUI-side saved workflow: node list + JSON. */
3468
4039
  function WorkflowView(props) {
3469
4040
  const nodes = Array.isArray(props.graph.nodes) ? props.graph.nodes.filter((node) => node.mode !== 4 && typeof node.type === "string" && node.type !== "") : [];
@@ -3990,6 +4561,40 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3990
4561
  .dsc-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); border-color: var(--dsw-alias-border-l1); }
3991
4562
  .dsc-trigger[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
3992
4563
  .dsc-trigger-glyph { font-size: 13px; line-height: 1; }
4564
+ /* --- connection reminder toast: fixed at the top of the page, mounted by the
4565
+ header trigger when the backend probe cannot reach ComfyUI. The probing
4566
+ state fades in after a short delay so a fast successful probe never paints;
4567
+ the fail state is sticky until dismissed or a later probe recovers. --- */
4568
+ .dsc-conn-toast {
4569
+ position: fixed; top: 14px; left: 50%;
4570
+ z-index: 980;
4571
+ display: flex; align-items: flex-start; gap: 10px;
4572
+ max-width: min(560px, 92vw);
4573
+ padding: 10px 12px;
4574
+ background: var(--dsw-alias-bg-layer-2);
4575
+ border: 1px solid var(--dsw-alias-border-l2);
4576
+ border-radius: 10px;
4577
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
4578
+ color: var(--dsw-alias-label-primary);
4579
+ font-size: 12px; line-height: 1.5;
4580
+ animation: dsc-conn-toast-in 0.16s ease-out both;
4581
+ }
4582
+ .dsc-conn-toast--probing { animation-delay: 0.45s; }
4583
+ .dsc-conn-toast--ok { border-color: var(--dsw-alias-state-success-primary); }
4584
+ .dsc-conn-toast--fail { border-color: var(--dsw-alias-state-error-primary); }
4585
+ .dsc-conn-toast-dot { flex: none; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; }
4586
+ .dsc-conn-toast--probing .dsc-conn-toast-dot { background: var(--dsw-alias-state-warn-primary); animation: dsc-conn-dot 1s ease-in-out infinite; }
4587
+ .dsc-conn-toast--ok .dsc-conn-toast-dot { background: var(--dsw-alias-state-success-primary); }
4588
+ .dsc-conn-toast--fail .dsc-conn-toast-dot { background: var(--dsw-alias-state-error-primary); }
4589
+ .dsc-conn-toast-body { min-width: 0; }
4590
+ .dsc-conn-toast-title { font-weight: 600; }
4591
+ .dsc-conn-toast--fail .dsc-conn-toast-title { color: var(--dsw-alias-state-error-primary); }
4592
+ .dsc-conn-toast--ok .dsc-conn-toast-title { color: var(--dsw-alias-state-success-primary); }
4593
+ .dsc-conn-toast-text { color: var(--dsw-alias-label-secondary); margin-top: 2px; overflow-wrap: anywhere; }
4594
+ .dsc-conn-toast-close { flex: none; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-size: 13px; padding: 2px 4px; line-height: 1; }
4595
+ .dsc-conn-toast-close:hover { color: var(--dsw-alias-label-primary); }
4596
+ @keyframes dsc-conn-toast-in { from { opacity: 0; transform: translate(-50%, -6px); } to { opacity: 1; transform: translate(-50%, 0); } }
4597
+ @keyframes dsc-conn-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
3993
4598
  /* --- panel shell: floating window, percentage-anchored to the page header
3994
4599
  (top) and the composer (bottom), draggable by the header and resizable via
3995
4600
  the corner handle. Follows the host light/dark theme. --- */
@@ -4295,6 +4900,28 @@ video.dsc-picker-player { max-height: 180px; }
4295
4900
  .dsc-param-random { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
4296
4901
  .dsc-param-bool { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-primary); cursor: pointer; }
4297
4902
  .dsc-param-advanced { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 8px; padding: 8px; }
4903
+ /* --- workflow preset transfer dialogs (export / import) --- */
4904
+ .dsc-transfer { position: relative; display: flex; flex-direction: column; width: min(580px, 92vw); max-height: min(640px, 86vh); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: 0 16px 56px rgba(0, 0, 0, 0.5); overflow: hidden; }
4905
+ .dsc-transfer-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
4906
+ .dsc-transfer-title { font-weight: 600; font-size: 13px; color: var(--dsw-alias-label-primary); }
4907
+ .dsc-transfer-body { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; overflow-y: auto; min-height: 0; }
4908
+ .dsc-transfer-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
4909
+ .dsc-transfer-list { display: flex; flex-direction: column; gap: 4px; }
4910
+ .dsc-transfer-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; cursor: pointer; font-size: 12px; color: var(--dsw-alias-label-primary); }
4911
+ .dsc-transfer-item:hover { border-color: var(--dsw-alias-brand-primary); }
4912
+ .dsc-transfer-item--static, .dsc-transfer-item--static:hover { cursor: default; border-color: var(--dsw-alias-border-l1); }
4913
+ .dsc-transfer-item--ok { border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent); }
4914
+ .dsc-transfer-item--fail { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); }
4915
+ .dsc-transfer-item-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 240px; }
4916
+ .dsc-transfer-item-desc { color: var(--dsw-alias-label-tertiary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 40px; text-align: right; }
4917
+ .dsc-transfer-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
4918
+ .dsc-transfer-note { padding: 6px 12px 0; }
4919
+ .dsc-transfer-note .dsc-hint { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4920
+ .dsc-transfer-drop { display: flex; align-items: center; justify-content: center; min-height: 150px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; color: var(--dsw-alias-label-secondary); font-size: 13px; cursor: pointer; text-align: center; padding: 12px; transition: border-color 0.12s, color 0.12s; }
4921
+ .dsc-transfer-drop:hover, .dsc-transfer-drop--over { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
4922
+ .dsc-transfer-empty { padding: 40px 12px; text-align: center; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
4923
+ .dsc-btn--primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
4924
+ .dsc-btn--primary:disabled { opacity: 0.5; cursor: default; }
4298
4925
  `;
4299
4926
  /** Inject the stylesheet once (idempotent), owned by this plugin for HMR. */
4300
4927
  function injectStyles() {