dsh-comfyui 0.3.0-beta.2 → 0.3.0-beta.4

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
@@ -73,9 +73,16 @@ window.__ModuleLoader__.load({
73
73
  wfUploaded: "已上传",
74
74
  wfCopyName: "复制文件名",
75
75
  wfLoadArea: "加载区",
76
- wfLoadHint: "点击选择图像(加载区选中的图像将作为图生图的默认源图)",
77
- wfLoadEmpty: "未选择图像,点击选择",
76
+ wfLoadHint: "点击放入素材(加载区的素材按顺序填入工作流未指定的加载参数)",
77
+ wfLoadEmpty: "加载区为空,点击下方“添加加载区”放入素材",
78
78
  wfLoadPicked: "已加载",
79
+ wfLoadAddSlot: "+ 添加加载区",
80
+ wfLoadAddMedia: "添加素材",
81
+ wfLoadRemoveSlot: "删除加载区",
82
+ wfLoadNone: "空",
83
+ wfLoadNoneHint: "清空该加载位(保留加载位,不放素材)",
84
+ wfLoadSlotLabel: "加载位",
85
+ wfLoadSummary: "加载位 {slots} 个 · 已放入 {loaded} 个素材,未指定的加载参数按顺序取用",
79
86
  wfTabAll: "全部",
80
87
  wfTabImported: "已导入",
81
88
  wfTabGenerated: "已生成",
@@ -147,6 +154,14 @@ window.__ModuleLoader__.load({
147
154
  assetBack: "返回",
148
155
  assetEmpty: "还没有生成记录。运行工作流后,结果会出现在这里。",
149
156
  assetGone: "源文件已被 ComfyUI 清理",
157
+ assetDelete: "删除资产",
158
+ assetDeleteTitle: "删除该资产?",
159
+ assetDeleteBody: "将从资产库移除这条记录,并删除 ComfyUI 输出目录里的 {count} 个文件。此操作不可恢复。",
160
+ assetDeleteNoDir: "未能定位 ComfyUI 输出目录,只会移除资产记录,磁盘文件保留(可在 cordis.yml 的 outputDir 里指定输出目录)。",
161
+ assetDeleteDone: "已删除记录,磁盘文件删除 {deleted} 个",
162
+ assetDeleteFailed: "删除失败:{message}",
163
+ confirmDelete: "确定删除",
164
+ cancel: "取消",
150
165
  queueEmpty: "当前没有任务",
151
166
  queueError: "无法读取队列(ComfyUI 未运行?)",
152
167
  jobFilterLabel: "历史",
@@ -234,9 +249,16 @@ window.__ModuleLoader__.load({
234
249
  wfUploaded: "Uploaded",
235
250
  wfCopyName: "Copy file name",
236
251
  wfLoadArea: "Load area",
237
- wfLoadHint: "Click to pick an image (the selection becomes the default source for image-to-image)",
238
- wfLoadEmpty: "No image selected — click to pick",
252
+ wfLoadHint: "Click to put media in this slot (slots fill the workflow unset loader parameters in order)",
253
+ wfLoadEmpty: "Load area is empty — use \"Add slot\" below to put media in it",
239
254
  wfLoadPicked: "Loaded",
255
+ wfLoadAddSlot: "+ Add slot",
256
+ wfLoadAddMedia: "Add media",
257
+ wfLoadRemoveSlot: "Delete slot",
258
+ wfLoadNone: "None",
259
+ wfLoadNoneHint: "Empty this slot (the slot stays, with no media)",
260
+ wfLoadSlotLabel: "Slot",
261
+ wfLoadSummary: "{slots} slot(s) · {loaded} loaded; unset loader parameters take them in order",
240
262
  wfTabAll: "All",
241
263
  wfTabImported: "Imported",
242
264
  wfTabGenerated: "Generated",
@@ -308,6 +330,14 @@ window.__ModuleLoader__.load({
308
330
  assetBack: "Back",
309
331
  assetEmpty: "Nothing generated yet. Results appear here after workflows run.",
310
332
  assetGone: "Source file evicted by ComfyUI",
333
+ assetDelete: "Delete asset",
334
+ assetDeleteTitle: "Delete this asset?",
335
+ assetDeleteBody: "Removes the record from the asset library and deletes {count} file(s) from the ComfyUI output directory. This cannot be undone.",
336
+ assetDeleteNoDir: "The ComfyUI output directory could not be located, so only the record is removed and the files stay on disk (set outputDir in cordis.yml to enable file deletion).",
337
+ assetDeleteDone: "Record removed, {deleted} file(s) deleted",
338
+ assetDeleteFailed: "Delete failed: {message}",
339
+ confirmDelete: "Delete",
340
+ cancel: "Cancel",
311
341
  queueEmpty: "No tasks right now",
312
342
  queueError: "Cannot read the queue (ComfyUI not running?)",
313
343
  jobFilterLabel: "History",
@@ -947,6 +977,33 @@ window.__ModuleLoader__.load({
947
977
  return `/comfyui/media?file=${encodeURIComponent(preview.filename)}&subfolder=${encodeURIComponent(preview.subfolder ?? "")}&type=${encodeURIComponent(preview.type ?? "output")}`;
948
978
  }
949
979
  /** Opens a generated image in a large overlay with prev/next navigation. */
980
+ /** Whether a node input is fed by a link ([nodeId, slot]) rather than a widget value. */
981
+ function isLinkValue(value) {
982
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number";
983
+ }
984
+ /** Short description of a node input's current value, for the advanced
985
+ * picker's dropdown. */
986
+ function inputValueKind(value) {
987
+ if (value === null) return "null";
988
+ if (Array.isArray(value)) return "array";
989
+ if (typeof value === "object") return "object";
990
+ return typeof value;
991
+ }
992
+ /** Read a parameter default as a boolean. Defaults saved before the editor
993
+ * had a checkbox are strings ("true"/"false"), so they are coerced here rather
994
+ * than silently reading as `true` for every non-empty string. */
995
+ function asBool(value) {
996
+ if (typeof value === "boolean") return value;
997
+ if (typeof value === "number") return value !== 0;
998
+ return /^(true|1|yes|on)$/i.test(value.trim());
999
+ }
1000
+ /** Tooltip for a number field: the declared type and bounds, when known. */
1001
+ function numberRangeHint(param) {
1002
+ const parts = [param.numberKind ?? "number"];
1003
+ if (param.min !== void 0 || param.max !== void 0) parts.push(`${param.min ?? "-∞"} ~ ${param.max ?? "∞"}`);
1004
+ if (param.step !== void 0) parts.push(`step ${param.step}`);
1005
+ return parts.join(" · ");
1006
+ }
950
1007
  function newParamId() {
951
1008
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
952
1009
  return `p${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
@@ -1263,6 +1320,7 @@ window.__ModuleLoader__.load({
1263
1320
  const [recognizing, setRecognizing] = (0, react.useState)(false);
1264
1321
  const [recognizeError, setRecognizeError] = (0, react.useState)(null);
1265
1322
  const [uploading, setUploading] = (0, react.useState)({});
1323
+ const [numDraft, setNumDraft] = (0, react.useState)({});
1266
1324
  const [hovered, setHovered] = (0, react.useState)(null);
1267
1325
  const hoverRef = (0, react.useRef)(null);
1268
1326
  const latestRef = (0, react.useRef)({
@@ -1335,7 +1393,7 @@ window.__ModuleLoader__.load({
1335
1393
  };
1336
1394
  const nodeEntries = Object.entries(workflow).map(([id, raw]) => [id, raw]);
1337
1395
  const advNodeInfo = nodeEntries.find(([id]) => id === advNode)?.[1];
1338
- const advInputs = advNodeInfo !== void 0 && advNodeInfo.inputs !== void 0 ? Object.entries(advNodeInfo.inputs).filter(([key]) => key !== "upload") : [];
1396
+ const advInputs = advNodeInfo !== void 0 && advNodeInfo.inputs !== void 0 ? Object.entries(advNodeInfo.inputs).filter(([, value]) => !isLinkValue(value)) : [];
1339
1397
  const selectedInput = advInputs.find(([key]) => key === advInput);
1340
1398
  /** Server-side auto-detection: prompt/size/steps/seed + dropdown options (object_info). */
1341
1399
  const recognize = async () => {
@@ -1397,6 +1455,7 @@ window.__ModuleLoader__.load({
1397
1455
  }
1398
1456
  let options;
1399
1457
  let upload;
1458
+ let numberSpec;
1400
1459
  if (typeof advNodeInfo.class_type === "string") try {
1401
1460
  const result = await postJson("/comfyui/workflows/input-options", {
1402
1461
  classType: advNodeInfo.class_type,
@@ -1404,6 +1463,7 @@ window.__ModuleLoader__.load({
1404
1463
  });
1405
1464
  if (result.ok === true && Array.isArray(result.options) && result.options.length > 0) options = result.options;
1406
1465
  upload = result.upload;
1466
+ numberSpec = result.number;
1407
1467
  } catch {}
1408
1468
  onChange([...params, {
1409
1469
  id: newParamId(),
@@ -1413,6 +1473,10 @@ window.__ModuleLoader__.load({
1413
1473
  nodeId: advNode,
1414
1474
  inputKey: advInput,
1415
1475
  default: typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : "",
1476
+ numberKind: type === "number" ? numberSpec?.kind : void 0,
1477
+ min: type === "number" ? numberSpec?.min : void 0,
1478
+ max: type === "number" ? numberSpec?.max : void 0,
1479
+ step: type === "number" ? numberSpec?.step : void 0,
1416
1480
  options,
1417
1481
  upload
1418
1482
  }]);
@@ -1544,15 +1608,43 @@ window.__ModuleLoader__.load({
1544
1608
  value: String(param.default),
1545
1609
  placeholder: t("wfParamDefault"),
1546
1610
  onChange: (event) => updateDefault(index, event.target.value)
1547
- }) : (0, react.createElement)("input", {
1611
+ }) : param.type === "boolean" ? (0, react.createElement)("label", { className: "dsc-param-bool dsc-param-default" }, (0, react.createElement)("input", {
1612
+ type: "checkbox",
1613
+ checked: asBool(param.default),
1614
+ onChange: (event) => updateDefault(index, event.target.checked)
1615
+ }), (0, react.createElement)("span", null, asBool(param.default) ? "true" : "false")) : param.type === "number" ? (0, react.createElement)("input", {
1548
1616
  className: "dsc-input dsc-input--sm dsc-param-default",
1549
- value: String(param.default),
1617
+ inputMode: param.numberKind === "int" ? "numeric" : "decimal",
1618
+ value: numDraft[param.id] ?? String(param.default),
1550
1619
  placeholder: t("wfParamDefault"),
1620
+ title: numberRangeHint(param),
1551
1621
  onChange: (event) => {
1552
1622
  const raw = event.target.value;
1553
- updateDefault(index, param.type === "number" ? Number(raw) || 0 : raw);
1623
+ setNumDraft((prev) => ({
1624
+ ...prev,
1625
+ [param.id]: raw
1626
+ }));
1627
+ const parsed = Number(raw);
1628
+ if (raw.trim() !== "" && Number.isFinite(parsed)) updateDefault(index, parsed);
1629
+ },
1630
+ onBlur: () => {
1631
+ const raw = numDraft[param.id];
1632
+ setNumDraft((prev) => {
1633
+ const next = { ...prev };
1634
+ delete next[param.id];
1635
+ return next;
1636
+ });
1637
+ if (raw === void 0) return;
1638
+ const parsed = Number(raw);
1639
+ const value = raw.trim() === "" || !Number.isFinite(parsed) ? 0 : parsed;
1640
+ updateDefault(index, param.numberKind === "int" ? Math.round(value) : value);
1554
1641
  }
1555
- }), (0, react.createElement)("div", { className: "dsc-param-meta" }, (0, react.createElement)("span", { className: "dsc-meta" }, `${param.type} · #${param.nodeId}.${param.inputKey}`), param.type === "number" ? (0, react.createElement)("label", { className: "dsc-param-random" }, (0, react.createElement)("input", {
1642
+ }) : (0, react.createElement)("input", {
1643
+ className: "dsc-input dsc-input--sm dsc-param-default",
1644
+ value: String(param.default),
1645
+ placeholder: t("wfParamDefault"),
1646
+ onChange: (event) => updateDefault(index, event.target.value)
1647
+ }), (0, react.createElement)("div", { className: "dsc-param-meta" }, (0, react.createElement)("span", { className: "dsc-meta" }, `${param.type}${param.type === "number" ? `/${param.numberKind ?? "?"}` : ""} · #${param.nodeId}.${param.inputKey}`), param.type === "number" ? (0, react.createElement)("label", { className: "dsc-param-random" }, (0, react.createElement)("input", {
1556
1648
  type: "checkbox",
1557
1649
  checked: param.random === true,
1558
1650
  onChange: (event) => update(index, { random: event.target.checked })
@@ -1582,10 +1674,10 @@ window.__ModuleLoader__.load({
1582
1674
  className: "dsc-input dsc-input--sm",
1583
1675
  value: advInput,
1584
1676
  onChange: (event) => setAdvInput(event.target.value)
1585
- }, (0, react.createElement)("option", { value: "" }, "—"), advInputs.filter(([, value]) => value === null || typeof value !== "object").map(([key, value]) => (0, react.createElement)("option", {
1677
+ }, (0, react.createElement)("option", { value: "" }, "—"), advInputs.map(([key, value]) => (0, react.createElement)("option", {
1586
1678
  key,
1587
1679
  value: key
1588
- }, `${key} (${value === null ? "null" : typeof value})`))) : null, (0, react.createElement)("input", {
1680
+ }, `${key} (${inputValueKind(value)})`))) : null, (0, react.createElement)("input", {
1589
1681
  className: "dsc-input dsc-input--sm",
1590
1682
  value: advLabel,
1591
1683
  placeholder: t("wfParamCustomLabel"),
@@ -1747,26 +1839,52 @@ window.__ModuleLoader__.load({
1747
1839
  return (0, react.createElement)("div", { className: "dsc-err" }, `${t("error")}: ${message}`);
1748
1840
  }
1749
1841
  /** Load area at the bottom of the workflow library, modeled on the ComfyUI
1750
- * LoadImage node: a big preview of the currently selected image; clicking it
1751
- * opens a picker (全部/已导入/已生成 tabs + paste-to-upload + masonry grid).
1752
- * Selecting a file makes it the default source image for image-to-image runs
1753
- * (the workflow's image parameter defaults to this file name). */
1842
+ * LoadImage node — a list of **slots** rather than a single image.
1843
+ *
1844
+ * A lone slot stretches across the panel; from two slots on they share a
1845
+ * fixed width and wrap into rows. A slot is either filled with a file or
1846
+ * empty (added but not yet picked, or emptied through the picker's "空"
1847
+ * tile); the whole card is the pick button, and hovering reveals an × in the
1848
+ * corner that removes the slot. Filled slots feed the workflow's loader
1849
+ * parameters in order, so a run that takes two reference images picks up
1850
+ * slots 1 and 2 without the agent naming files. */
1754
1851
  function LoadArea(props) {
1755
- const [current, setCurrent] = (0, react.useState)(null);
1852
+ const [slots, setSlots] = (0, react.useState)([]);
1756
1853
  const [files, setFiles] = (0, react.useState)([]);
1757
- const [pickerOpen, setPickerOpen] = (0, react.useState)(false);
1854
+ /** Slot index the picker is currently filling; null = picker closed. */
1855
+ const [pickerFor, setPickerFor] = (0, react.useState)(null);
1758
1856
  const [busy, setBusy] = (0, react.useState)(false);
1759
1857
  const [error, setError] = (0, react.useState)(null);
1760
1858
  const refresh = (0, react.useCallback)(async () => {
1761
1859
  const data = await fetch("/comfyui/loadarea").then((response) => response.json()).catch(() => void 0);
1762
1860
  if (data?.ok === true) {
1763
- setCurrent(data.current ?? null);
1861
+ setSlots(data.slots ?? (data.current !== null && data.current !== void 0 ? [data.current] : []));
1764
1862
  setFiles(data.files ?? []);
1765
1863
  }
1766
1864
  }, []);
1767
1865
  (0, react.useEffect)(() => {
1768
1866
  refresh();
1769
1867
  }, [refresh]);
1868
+ const post = async (body) => {
1869
+ setBusy(true);
1870
+ setError(null);
1871
+ try {
1872
+ const response = await fetch("/comfyui/current-image", {
1873
+ method: "POST",
1874
+ headers: { "Content-Type": "application/json" },
1875
+ body: JSON.stringify(body)
1876
+ });
1877
+ const data = await response.json().catch(() => ({}));
1878
+ if (data.ok !== true) throw new Error(data.error ?? `HTTP ${response.status}`);
1879
+ await refresh();
1880
+ return true;
1881
+ } catch (cause) {
1882
+ setError(cause instanceof Error ? cause.message : String(cause));
1883
+ return false;
1884
+ } finally {
1885
+ setBusy(false);
1886
+ }
1887
+ };
1770
1888
  const upload = async (file) => {
1771
1889
  setBusy(true);
1772
1890
  setError(null);
@@ -1784,38 +1902,92 @@ window.__ModuleLoader__.load({
1784
1902
  }
1785
1903
  };
1786
1904
  const pick = async (file) => {
1787
- await fetch("/comfyui/current-image", {
1788
- method: "POST",
1789
- headers: { "Content-Type": "application/json" },
1790
- body: JSON.stringify({
1791
- name: file.name,
1792
- kind: file.kind,
1793
- source: file.source
1794
- })
1795
- }).catch(() => void 0);
1796
- setCurrent(file);
1797
- setPickerOpen(false);
1798
- props.onNotice(`${props.t("wfLoadPicked")}:${file.name}`);
1905
+ const index = pickerFor;
1906
+ setPickerFor(null);
1907
+ if (await post({
1908
+ name: file.name,
1909
+ kind: file.kind,
1910
+ source: file.source,
1911
+ ...index !== null ? { index } : {}
1912
+ })) props.onNotice(`${props.t("wfLoadPicked")}:${file.name}`);
1799
1913
  };
1800
- return (0, react.createElement)("div", { className: "dsc-loadarea" }, (0, react.createElement)("div", { className: "dsc-loadarea-head" }, (0, react.createElement)("span", { className: "dsc-loadarea-title" }, props.t("wfLoadArea")), (0, react.createElement)("span", { className: "dsc-meta" }, props.t("wfLoadHint"))), (0, react.createElement)("button", {
1801
- className: "dsc-loadarea-main",
1802
- onClick: () => setPickerOpen(true),
1803
- title: props.t("wfLoadHint"),
1804
- disabled: busy
1805
- }, current !== null ? (0, react.createElement)("img", {
1806
- className: "dsc-loadarea-img",
1807
- src: current.url,
1808
- alt: "",
1809
- loading: "lazy"
1810
- }) : (0, react.createElement)("span", { className: "dsc-loadarea-empty" }, props.t("wfLoadEmpty")), (0, react.createElement)("span", { className: "dsc-loadarea-name" }, current !== null ? `${current.name}${current.width !== void 0 && current.height !== void 0 ? `(${current.width}×${current.height})` : ""}` : "")), pickerOpen ? (0, react.createElement)(LoadPicker, {
1914
+ /** "+ 添加加载区": opens a slot and goes straight to the picker for it, so
1915
+ * adding material is one gesture; closing the picker leaves the empty slot. */
1916
+ const addSlot = async () => {
1917
+ const index = slots.length;
1918
+ if (await post({ action: "addSlot" })) setPickerFor(index);
1919
+ };
1920
+ const loaded = slots.filter((slot) => slot !== null).length;
1921
+ return (0, react.createElement)("div", { className: "dsc-loadarea" }, (0, react.createElement)("div", { className: "dsc-loadarea-head" }, (0, react.createElement)("span", { className: "dsc-loadarea-title" }, props.t("wfLoadArea")), (0, react.createElement)("span", { className: "dsc-meta" }, slots.length === 0 ? props.t("wfLoadEmpty") : props.t("wfLoadSummary", {
1922
+ slots: slots.length,
1923
+ loaded
1924
+ }))), (0, react.createElement)("div", { className: "dsc-loadslots" }, slots.map((slot, index) => (0, react.createElement)(LoadSlot, {
1925
+ key: index,
1926
+ t: props.t,
1927
+ slot,
1928
+ index,
1929
+ busy,
1930
+ wide: slots.length === 1,
1931
+ onPick: () => setPickerFor(index),
1932
+ onRemove: () => {
1933
+ post({
1934
+ action: "removeSlot",
1935
+ index
1936
+ });
1937
+ }
1938
+ }))), (0, react.createElement)("div", { className: "dsc-toolbar" }, (0, react.createElement)("button", {
1939
+ className: "dsc-btn",
1940
+ disabled: busy,
1941
+ onClick: () => {
1942
+ addSlot();
1943
+ }
1944
+ }, props.t("wfLoadAddSlot"))), pickerFor !== null ? (0, react.createElement)(LoadPicker, {
1811
1945
  t: props.t,
1812
1946
  files,
1813
- current: current?.name ?? null,
1947
+ current: (pickerFor < slots.length ? slots[pickerFor]?.name : void 0) ?? null,
1814
1948
  onUpload: upload,
1815
1949
  onPick: pick,
1816
- onClose: () => setPickerOpen(false)
1950
+ onClear: () => {
1951
+ const index = pickerFor;
1952
+ setPickerFor(null);
1953
+ post({
1954
+ action: "clear",
1955
+ index
1956
+ });
1957
+ },
1958
+ onClose: () => setPickerFor(null)
1817
1959
  }) : null, error !== null ? (0, react.createElement)("div", { className: "dsc-hint dsc-hint--error" }, error) : null);
1818
1960
  }
1961
+ /** One load-area slot card. The card is the pick button (empty slots show
1962
+ * "加载位 N / 添加素材"); the × in the corner, revealed on hover, removes the
1963
+ * slot. Emptying a slot without removing it is done from the picker's "空"
1964
+ * tile, so the card carries no button row of its own. */
1965
+ function LoadSlot(props) {
1966
+ const { t, slot, index } = props;
1967
+ const size = slot?.width !== void 0 && slot.height !== void 0 ? `(${slot.width}×${slot.height})` : "";
1968
+ return (0, react.createElement)("div", { className: props.wide ? "dsc-loadslot dsc-loadslot--wide" : "dsc-loadslot" }, (0, react.createElement)("button", {
1969
+ className: "dsc-loadslot-pick",
1970
+ onClick: props.onPick,
1971
+ disabled: props.busy,
1972
+ title: slot !== null ? slot.name : t("wfLoadHint")
1973
+ }, slot === null ? (0, react.createElement)("span", { className: "dsc-loadslot-empty" }, (0, react.createElement)("span", { className: "dsc-loadslot-index" }, `${t("wfLoadSlotLabel")} ${index + 1}`), (0, react.createElement)("span", { className: "dsc-loadslot-add" }, t("wfLoadAddMedia"))) : slot.kind === "video" ? (0, react.createElement)("video", {
1974
+ className: "dsc-loadslot-media",
1975
+ src: slot.url,
1976
+ preload: "metadata",
1977
+ muted: true
1978
+ }) : slot.kind === "audio" ? (0, react.createElement)("span", { className: "dsc-loadslot-empty" }, "♪") : (0, react.createElement)("img", {
1979
+ className: "dsc-loadslot-media",
1980
+ src: slot.url,
1981
+ alt: "",
1982
+ loading: "lazy"
1983
+ }), slot !== null ? (0, react.createElement)("span", { className: "dsc-loadslot-name" }, `${slot.name}${size}`) : null), (0, react.createElement)("button", {
1984
+ className: "dsc-loadslot-x",
1985
+ disabled: props.busy,
1986
+ title: t("wfLoadRemoveSlot"),
1987
+ "aria-label": t("wfLoadRemoveSlot"),
1988
+ onClick: props.onRemove
1989
+ }, "×"));
1990
+ }
1819
1991
  /** The load-area picker dialog: tab bar (全部/已导入/已生成) with a
1820
1992
  * paste/click upload zone on the right, and a masonry image grid below. */
1821
1993
  function LoadPicker(props) {
@@ -1947,15 +2119,35 @@ window.__ModuleLoader__.load({
1947
2119
  }, typeOptions.map((option) => (0, react.createElement)("option", {
1948
2120
  key: option.id,
1949
2121
  value: option.id
1950
- }, option.label)))), filtered.length === 0 ? (0, react.createElement)("div", { className: "dsc-picker-empty" }, props.t("wfLoadNoFiles")) : (0, react.createElement)("div", { className: "dsc-picker-grid" }, columns.map((column, columnIndex) => (0, react.createElement)("div", {
2122
+ }, option.label)))), filtered.length === 0 && props.onClear === void 0 ? (0, react.createElement)("div", { className: "dsc-picker-empty" }, props.t("wfLoadNoFiles")) : (0, react.createElement)("div", { className: "dsc-picker-grid" }, columns.map((column, columnIndex) => (0, react.createElement)("div", {
1951
2123
  key: columnIndex,
1952
2124
  className: "dsc-picker-col"
1953
- }, column.map((file) => (0, react.createElement)("button", {
2125
+ }, columnIndex === 0 && props.onClear !== void 0 ? (0, react.createElement)("div", {
2126
+ className: `dsc-picker-card dsc-picker-card--none${props.current === null ? " dsc-picker-card--active" : ""}`,
2127
+ role: "button",
2128
+ tabIndex: 0,
2129
+ onClick: () => props.onClear?.(),
2130
+ onKeyDown: (event) => {
2131
+ if (event.key === "Enter" || event.key === " ") {
2132
+ event.preventDefault();
2133
+ props.onClear?.();
2134
+ }
2135
+ },
2136
+ title: props.t("wfLoadNoneHint")
2137
+ }, (0, react.createElement)("span", { className: "dsc-picker-thumb dsc-picker-thumb--media" }, "∅"), (0, react.createElement)("span", { className: "dsc-picker-name" }, props.t("wfLoadNone"))) : null, column.map((file) => (0, react.createElement)("div", {
1954
2138
  key: `${file.source}:${file.name}`,
1955
2139
  className: `dsc-picker-card${file.name === props.current ? " dsc-picker-card--active" : ""}`,
2140
+ role: "button",
2141
+ tabIndex: 0,
1956
2142
  onClick: () => {
1957
2143
  props.onPick(file);
1958
2144
  },
2145
+ onKeyDown: (event) => {
2146
+ if (event.key === "Enter" || event.key === " ") {
2147
+ event.preventDefault();
2148
+ props.onPick(file);
2149
+ }
2150
+ },
1959
2151
  title: file.name,
1960
2152
  ref: (el) => {
1961
2153
  if (el !== null) cardRefs.current.set(`${file.source}:${file.name}`, el);
@@ -1966,7 +2158,19 @@ window.__ModuleLoader__.load({
1966
2158
  src: file.url,
1967
2159
  alt: "",
1968
2160
  loading: "lazy"
1969
- }) : (0, react.createElement)("span", { className: "dsc-picker-thumb dsc-picker-thumb--media" }, file.kind === "video" ? "🎞" : "🎵"), (0, react.createElement)("span", { className: "dsc-picker-name" }, file.name), file.width !== void 0 && file.height !== void 0 ? (0, react.createElement)("span", { className: "dsc-meta" }, `${file.width}×${file.height}`) : null))))), uploadFlash !== null ? (0, react.createElement)("div", { className: "dsc-picker-toast" }, uploadFlash) : null));
2161
+ }) : file.kind === "video" ? (0, react.createElement)("video", {
2162
+ className: "dsc-picker-thumb dsc-picker-player",
2163
+ src: file.url,
2164
+ controls: true,
2165
+ preload: "metadata",
2166
+ onClick: (event) => event.stopPropagation()
2167
+ }) : (0, react.createElement)("audio", {
2168
+ className: "dsc-picker-player dsc-picker-player--audio",
2169
+ src: file.url,
2170
+ controls: true,
2171
+ preload: "metadata",
2172
+ onClick: (event) => event.stopPropagation()
2173
+ }), (0, react.createElement)("span", { className: "dsc-picker-name" }, file.name), file.width !== void 0 && file.height !== void 0 ? (0, react.createElement)("span", { className: "dsc-meta" }, `${file.width}×${file.height}`) : null))))), uploadFlash !== null ? (0, react.createElement)("div", { className: "dsc-picker-toast" }, uploadFlash) : null));
1970
2174
  }
1971
2175
  /** Tag editor: preset classification chips (图生图 etc.) toggle on/off, plus
1972
2176
  * free-form custom tags added with Enter and removed by clicking. */
@@ -2467,6 +2671,10 @@ window.__ModuleLoader__.load({
2467
2671
  const [assets, setAssets] = (0, react.useState)(null);
2468
2672
  const [error, setError] = (0, react.useState)(null);
2469
2673
  const [filter, setFilter] = (0, react.useState)(() => panelStore.getAssetFilter());
2674
+ /** Asset awaiting the delete confirmation, if any. */
2675
+ const [pendingDelete, setPendingDelete] = (0, react.useState)(null);
2676
+ const [deleting, setDeleting] = (0, react.useState)(false);
2677
+ const [notice, setNotice] = (0, react.useState)(null);
2470
2678
  const load = async () => {
2471
2679
  try {
2472
2680
  const data = await getJson("/comfyui/assets");
@@ -2487,7 +2695,24 @@ window.__ModuleLoader__.load({
2487
2695
  cancelled = true;
2488
2696
  };
2489
2697
  }, []);
2490
- if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
2698
+ /** Delete the record and (when the output directory is known) its files. */
2699
+ const confirmDelete = async (asset) => {
2700
+ setDeleting(true);
2701
+ try {
2702
+ const result = await postJson("/comfyui/assets/delete", { promptId: asset.promptId });
2703
+ if (result.ok !== true) throw new Error(result.error ?? "delete failed");
2704
+ setPendingDelete(null);
2705
+ setNotice(t("assetDeleteDone", { deleted: result.deleted ?? 0 }));
2706
+ if (Array.isArray(result.failures) && result.failures.length > 0) setError(result.failures.join("; "));
2707
+ await load();
2708
+ } catch (cause) {
2709
+ setError(t("assetDeleteFailed", { message: cause instanceof Error ? cause.message : String(cause) }));
2710
+ setPendingDelete(null);
2711
+ } finally {
2712
+ setDeleting(false);
2713
+ }
2714
+ };
2715
+ if (error !== null && assets === null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
2491
2716
  t,
2492
2717
  message: error
2493
2718
  }));
@@ -2513,20 +2738,64 @@ window.__ModuleLoader__.load({
2513
2738
  }, name === "" ? "—" : name))), (0, react.createElement)("button", {
2514
2739
  className: "dsc-btn",
2515
2740
  onClick: () => void load()
2516
- }, t("refresh"))), visible.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("assetEmpty")) : (0, react.createElement)("div", { className: "dsc-assets-grid" }, visible.map((asset) => (0, react.createElement)(AssetThumb, {
2741
+ }, t("refresh"))), error !== null ? (0, react.createElement)(ErrorNote, {
2742
+ t,
2743
+ message: error
2744
+ }) : null, notice !== null ? (0, react.createElement)("div", { className: "dsc-ok" }, notice) : null, visible.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("assetEmpty")) : (0, react.createElement)("div", { className: "dsc-assets-grid" }, visible.map((asset) => (0, react.createElement)(AssetThumb, {
2517
2745
  key: asset.promptId,
2518
2746
  asset,
2519
2747
  t,
2520
- onClick: () => openAsset(asset)
2521
- }))));
2748
+ onClick: () => openAsset(asset),
2749
+ onDelete: () => {
2750
+ setNotice(null);
2751
+ setPendingDelete(asset);
2752
+ }
2753
+ }))), pendingDelete !== null ? (0, react.createElement)(ConfirmDialog, {
2754
+ t,
2755
+ title: t("assetDeleteTitle"),
2756
+ body: t("assetDeleteBody", { count: pendingDelete.media.length }),
2757
+ detail: pendingDelete.media.map((item) => item.filename).join("、"),
2758
+ busy: deleting,
2759
+ onConfirm: () => {
2760
+ confirmDelete(pendingDelete);
2761
+ },
2762
+ onCancel: () => setPendingDelete(null)
2763
+ }) : null);
2764
+ }
2765
+ /** Modal confirmation for a destructive action (asset deletion). */
2766
+ function ConfirmDialog(props) {
2767
+ return (0, react.createElement)("div", {
2768
+ className: "dsc-picker-overlay",
2769
+ onClick: props.onCancel
2770
+ }, (0, react.createElement)("div", {
2771
+ className: "dsc-confirm",
2772
+ onClick: (event) => event.stopPropagation()
2773
+ }, (0, react.createElement)("div", { className: "dsc-confirm-title" }, props.title), (0, react.createElement)("div", { className: "dsc-confirm-body" }, props.body), props.detail !== void 0 && props.detail !== "" ? (0, react.createElement)("div", { className: "dsc-confirm-detail" }, props.detail) : null, (0, react.createElement)("div", { className: "dsc-confirm-actions" }, (0, react.createElement)("button", {
2774
+ className: "dsc-btn",
2775
+ disabled: props.busy,
2776
+ onClick: props.onCancel
2777
+ }, props.t("cancel")), (0, react.createElement)("button", {
2778
+ className: "dsc-btn dsc-btn--danger",
2779
+ disabled: props.busy,
2780
+ onClick: props.onConfirm
2781
+ }, props.t("confirmDelete")))));
2522
2782
  }
2523
2783
  function AssetThumb(props) {
2524
2784
  const first = props.asset.media[0];
2525
2785
  const [broken, setBroken] = (0, react.useState)(false);
2786
+ const trash = (0, react.createElement)("button", {
2787
+ className: "dsc-asset-trash",
2788
+ title: props.t("assetDelete"),
2789
+ "aria-label": props.t("assetDelete"),
2790
+ onClick: (event) => {
2791
+ event.stopPropagation();
2792
+ props.onDelete();
2793
+ }
2794
+ }, "🗑");
2526
2795
  if (first === void 0 || broken) return (0, react.createElement)("div", {
2527
2796
  className: "dsc-asset dsc-asset--empty",
2528
2797
  onClick: props.onClick
2529
- }, (0, react.createElement)("div", null, props.t("assetGone")), (0, react.createElement)("div", { className: "dsc-meta" }, props.asset.workflowName ?? "—"));
2798
+ }, (0, react.createElement)("div", null, props.t("assetGone")), (0, react.createElement)("div", { className: "dsc-meta" }, props.asset.workflowName ?? "—"), trash);
2530
2799
  return (0, react.createElement)("div", {
2531
2800
  className: "dsc-asset",
2532
2801
  onClick: props.onClick
@@ -2540,7 +2809,7 @@ window.__ModuleLoader__.load({
2540
2809
  alt: first.filename,
2541
2810
  loading: "lazy",
2542
2811
  onError: () => setBroken(true)
2543
- }), (0, react.createElement)("div", { className: "dsc-asset-meta" }, props.asset.workflowName ?? "—"));
2812
+ }), (0, react.createElement)("div", { className: "dsc-asset-meta" }, props.asset.workflowName ?? "—"), trash);
2544
2813
  }
2545
2814
  /**
2546
2815
  * Tab 3: the task center — active tasks (pending / generating) shown as
@@ -2878,7 +3147,17 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
2878
3147
  .dsc-mode-label { font-size: 13px; }
2879
3148
  /* --- assets tab --- */
2880
3149
  .dsc-assets-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
2881
- .dsc-asset { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden; cursor: pointer; background: var(--dsw-alias-bg-layer-1); }
3150
+ .dsc-asset { position: relative; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden; cursor: pointer; background: var(--dsw-alias-bg-layer-1); }
3151
+ /* Destructive, so it only appears while the pointer is on the card. */
3152
+ .dsc-asset-trash {
3153
+ position: absolute; top: 4px; right: 4px; width: 26px; height: 26px; padding: 0; line-height: 1;
3154
+ display: flex; align-items: center; justify-content: center;
3155
+ border: 1px solid var(--dsw-alias-state-error-primary); border-radius: 6px;
3156
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-state-error-primary);
3157
+ font-size: 14px; cursor: pointer; opacity: 0; transition: opacity 0.12s;
3158
+ }
3159
+ .dsc-asset:hover .dsc-asset-trash, .dsc-asset-trash:focus-visible { opacity: 1; }
3160
+ .dsc-asset-trash:hover { background: var(--dsw-alias-state-error-primary); color: #fff; }
2882
3161
  .dsc-asset img, .dsc-asset video { width: 100%; height: 110px; object-fit: cover; display: block; background: #000; }
2883
3162
  .dsc-asset--empty {
2884
3163
  height: 110px; padding: 8px; box-sizing: border-box;
@@ -2886,6 +3165,14 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
2886
3165
  font-size: 11px; color: var(--dsw-alias-label-secondary); text-align: center;
2887
3166
  }
2888
3167
  .dsc-asset-meta { padding: 3px 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3168
+ /* confirmation modal (asset deletion) */
3169
+ .dsc-confirm { width: min(360px, 88vw); display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32); }
3170
+ .dsc-confirm-title { font-weight: 600; font-size: 14px; }
3171
+ .dsc-confirm-body { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
3172
+ .dsc-confirm-detail { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); word-break: break-all; max-height: 96px; overflow-y: auto; }
3173
+ .dsc-confirm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
3174
+ .dsc-btn--danger { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
3175
+ .dsc-btn--danger:hover:not(:disabled) { background: var(--dsw-alias-state-error-primary); color: #fff; }
2889
3176
  .dsc-asset-detail-media { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
2890
3177
  .dsc-asset-detail-media img, .dsc-asset-detail-media video { width: 100%; max-height: 320px; object-fit: contain; background: #000; border-radius: 8px; }
2891
3178
  .dsc-asset-detail-file { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; }
@@ -2926,12 +3213,36 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
2926
3213
  .dsc-loadarea { margin-top: 14px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }
2927
3214
  .dsc-loadarea-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
2928
3215
  .dsc-loadarea-title { font-weight: 600; font-size: 12px; color: var(--dsw-alias-label-primary); }
2929
- .dsc-loadarea-main { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 10px; background: var(--dsw-alias-bg-layer-1); cursor: pointer; transition: border-color 0.12s; }
2930
- .dsc-loadarea-main:hover { border-color: #3b82f6; }
2931
- .dsc-loadarea-main:disabled { opacity: 0.6; cursor: default; }
2932
- .dsc-loadarea-img { width: 100%; max-height: 260px; object-fit: contain; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); }
2933
- .dsc-loadarea-empty { display: flex; align-items: center; justify-content: center; height: 120px; width: 100%; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-tertiary); font-size: 13px; }
2934
- .dsc-loadarea-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--dsw-alias-label-primary); }
3216
+ /* load-area slots: slot 0 is the primary (big) source, the rest are thumbs.
3217
+ Each slot carries its own actions (add / clear media / delete slot). */
3218
+ .dsc-loadslots { display: flex; flex-wrap: wrap; gap: 8px; }
3219
+ /* One slot stretches across the panel; from two on they share a fixed width
3220
+ and wrap into rows. The × sits in the corner and appears on hover. */
3221
+ .dsc-loadslot { position: relative; display: flex; flex-direction: column; gap: 4px; width: 128px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 6px; background: var(--dsw-alias-bg-layer-1); }
3222
+ .dsc-loadslot--wide { width: 100%; }
3223
+ .dsc-loadslot-pick { display: flex; flex-direction: column; align-items: center; gap: 4px; border: none; border-radius: 8px; padding: 0; background: transparent; color: inherit; cursor: pointer; width: 100%; }
3224
+ .dsc-loadslot-media { width: 100%; height: 104px; object-fit: contain; border-radius: 6px; background: var(--dsw-alias-bg-layer-2); }
3225
+ .dsc-loadslot--wide .dsc-loadslot-media { height: 220px; }
3226
+ .dsc-loadslot-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 100%; height: 104px; border-radius: 6px; border: 1px dashed var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); font-size: 12px; }
3227
+ .dsc-loadslot--wide .dsc-loadslot-empty { height: 120px; font-size: 13px; }
3228
+ .dsc-loadslot-name { width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--dsw-alias-label-primary); text-align: center; }
3229
+ .dsc-loadslot-index { color: var(--dsw-alias-label-tertiary); }
3230
+ .dsc-loadslot-add { color: var(--dsw-alias-label-secondary); }
3231
+ .dsc-loadslot-pick:hover .dsc-loadslot-add { color: var(--dsw-alias-brand-primary); }
3232
+ .dsc-loadslot-x {
3233
+ position: absolute; top: 3px; right: 3px; width: 26px; height: 26px; padding: 0; line-height: 1;
3234
+ display: flex; align-items: center; justify-content: center;
3235
+ border: 1px solid var(--dsw-alias-border-l2); border-radius: 50%;
3236
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary);
3237
+ font-size: 18px; cursor: pointer; opacity: 0; transition: opacity 0.12s;
3238
+ }
3239
+ .dsc-loadslot:hover .dsc-loadslot-x, .dsc-loadslot-x:focus-visible { opacity: 1; }
3240
+ .dsc-loadslot-x:hover { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
3241
+ .dsc-picker-player { width: 100%; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); }
3242
+ video.dsc-picker-player { max-height: 180px; }
3243
+ .dsc-picker-player--audio { height: 34px; }
3244
+ .dsc-loadslot-x:disabled { opacity: 0; cursor: default; }
3245
+ .dsc-picker-card--none .dsc-picker-thumb--media { font-size: 26px; color: var(--dsw-alias-label-tertiary); }
2935
3246
  /* picker overlay: dims the page, dialog floats in the center */
2936
3247
  .dsc-picker-overlay { position: fixed; inset: 0; z-index: 9990; background: rgba(6, 8, 12, 0.6); display: flex; align-items: center; justify-content: center; padding: 24px; }
2937
3248
  .dsc-picker { position: relative; display: flex; flex-direction: column; width: min(860px, 92vw); height: min(560px, 84vh); 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; }
@@ -2950,7 +3261,8 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
2950
3261
  .dsc-picker-card--active { border-color: var(--dsw-alias-brand-primary); box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
2951
3262
  .dsc-picker-thumb { width: 100%; height: auto; object-fit: contain; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); }
2952
3263
  .dsc-picker-thumb--media { display: flex; align-items: center; justify-content: center; height: 100px; font-size: 22px; }
2953
- .dsc-picker-name { font-size: 11px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3264
+ .dsc-picker-name { font-size: 13px; line-height: 1.5; padding: 2px 0; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3265
+ .dsc-picker-card:hover .dsc-picker-name { color: var(--dsw-alias-brand-primary); }
2954
3266
  /* --- workflow tags: filter bar, chips, editor --- */
2955
3267
  .dsc-tag-filter { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 0 2px; }
2956
3268
  .dsc-tag-chip { border: 1px solid var(--dsw-alias-border-l1); border-radius: 999px; padding: 2px 10px; font-size: 12px; cursor: pointer; background: transparent; color: var(--dsw-alias-label-primary); }
@@ -3012,6 +3324,7 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3012
3324
  .dsc-param-default { min-width: 0; }
3013
3325
  .dsc-param-meta { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
3014
3326
  .dsc-param-random { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
3327
+ .dsc-param-bool { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-primary); cursor: pointer; }
3015
3328
  .dsc-param-advanced { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 8px; padding: 8px; }
3016
3329
  `;
3017
3330
  /** Inject the stylesheet once (idempotent), owned by this plugin for HMR. */