dsh-comfyui 0.3.0-beta.5 → 0.4.0

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
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
13
13
  baseUrl: "服务器地址",
14
14
  baseUrlHint: "ComfyUI 的 HTTP 地址(默认 http://127.0.0.1:8188)。",
15
15
  apiKeyEnv: "API Key 环境变量名",
16
- apiKeyEnvHint: "凭据存储或环境变量中的密钥名(默认 COMFYUI_API_KEY),远程服务通常需要。",
16
+ apiKeyEnvHint: "密钥名:环境变量或凭据存储(默认 COMFYUI_API_KEY)。",
17
17
  hasApiKey: "已配置 API Key",
18
18
  noApiKey: "未配置 API Key(本地服务通常不需要)",
19
19
  save: "保存",
@@ -25,14 +25,16 @@ window.__ModuleLoader__.load({
25
25
  testFail: "连接失败:{message}",
26
26
  configNotWritable: "设置服务不可用,请通过 cordis.yml 修改配置。",
27
27
  mediaHost: "媒体访问地址",
28
- mediaHostHint: "生成图片/媒体在聊天中使用的完整地址(如 http://192.168.1.5:3080)。留空时自动使用浏览器访问本服务器的地址;内网或反向代理下若图片不显示,请在此填写你访问本页面所用的地址。",
28
+ mediaHostHint: "媒体在聊天中显示的完整地址(如 http://192.168.1.5:3080)。留空自动检测;图片不显示时填你访问本页的地址。",
29
29
  comfyuiDirs: "ComfyUI 目录",
30
- comfyuiDirsHint: "本机 ComfyUI 安装目录,可添加多个(目录映射 / 多实例 / 便携版)。Agent 会据此直接定位你的 ComfyUI 文件(models、工作流、TTS-Audio-Suite 音色库等),无需再向你询问。",
30
+ comfyuiDirsHint: "本机 ComfyUI 安装目录(可多个,支持映射/多实例/便携版)。Agent 据此定位 models、工作流、音色库等文件。",
31
31
  comfyuiDirAdd: "+ 添加目录",
32
32
  comfyuiDirRemove: "移除该目录",
33
33
  comfyuiDirPlaceholder: "如 D:\\ComfyUI",
34
+ skillsDir: "技能包目录",
35
+ skillsDirHint: "工作流技能包(SKILL.md 与参考文档)目录。留空 = 数据目录下 skills/;填绝对路径可放到别的盘或同步盘。改后需手动搬移已有目录。当前实际使用:{current}",
34
36
  language: "界面语言",
35
- languageHint: "切换插件界面语言(中文 / English)。切换后页面会自动刷新。",
37
+ languageHint: "界面语言,切换后页面自动刷新。",
36
38
  cardGenerating: "ComfyUI 生成中…",
37
39
  cardBackground: "后台任务",
38
40
  cardBackgroundDone: "已完成",
@@ -49,6 +51,7 @@ window.__ModuleLoader__.load({
49
51
  cardMode: "模式",
50
52
  cardListed: "已列出 {runs} 个运行工作流,{graphs} 个图工作流",
51
53
  panelTitle: "ComfyUI 面板",
54
+ panelReset: "复位位置与大小",
52
55
  close: "关闭",
53
56
  lbPrev: "上一张",
54
57
  lbNext: "下一张",
@@ -167,6 +170,54 @@ window.__ModuleLoader__.load({
167
170
  assetDeleteFailed: "删除失败:{message}",
168
171
  confirmDelete: "确定删除",
169
172
  cancel: "取消",
173
+ skillButton: "技能包",
174
+ skillBadge: "技能包",
175
+ skillBadgeRequired: "技能包·必读",
176
+ skillBadgeHint: "该工作流挂了技能包:Agent 运行前会读它。",
177
+ skillTitle: "技能包",
178
+ skillIntro: "点击「启用技能包」后,可为这个工作流添加给 Agent 看的额外 skills 说明:什么时候用、参数怎么填、有哪些坑。",
179
+ skillEnable: "启用技能包",
180
+ skillDetach: "停用(保留文件)",
181
+ skillDestroy: "销毁技能包",
182
+ skillDestroyConfirm: "⚠ 永久删除整个技能包({count} 个文件),不可恢复",
183
+ skillDestroyConfirmButton: "确认销毁",
184
+ skillDropHint: "可把文件拖到这里导入",
185
+ skillRequired: "运行前必读",
186
+ skillDir: "目录",
187
+ skillSummary: "摘要",
188
+ skillSummaryPlaceholder: "一句话说明这个工作流适合做什么",
189
+ skillSummaryHint: "Agent 靠它决定要不要读正文,等同 skills 里的 description 字段。",
190
+ skillSave: "保存",
191
+ skillSaved: "已保存",
192
+ skillDirty: "未保存",
193
+ skillDirtyPrompt: "当前文件有未保存的修改。",
194
+ skillSaveAndSwitch: "保存并切换",
195
+ skillDiscard: "丢弃修改",
196
+ skillImport: "↑ 导入文件",
197
+ skillImportHint: "导入本地文件到技能包:md/txt/json/yaml 进 references,脚本进 scripts,图片/csv 进 assets(也可直接拖进左栏)。",
198
+ skillImported: "已导入",
199
+ skillImageHint: "图片只能预览,不能在此编辑;Agent 会在正文点名它时才去读取。",
200
+ skillNewFile: "+ 新建文件",
201
+ skillNewDir: "+ 新建目录",
202
+ skillReveal: "📂 打开目录",
203
+ skillRevealHint: "在文件管理器中打开这个技能包的文件夹,方便直接拷贝文件进去。打开的是运行 DSH 那台机器上的目录;若你正从另一台机器访问本页面,请按下方显示的路径自行前往。",
204
+ skillRevealed: "已打开",
205
+ skillRefresh: "刷新",
206
+ skillRefreshHint: "重新读取技能包目录,显示 Agent 写入或文件管理器里新增/替换的文件。",
207
+ skillRefreshed: "已刷新",
208
+ skillNewDirPlaceholder: "目录名,如 templates",
209
+ skillNewDirHint: "技能包可以有任意一层子目录。常用:{presets};也可以自己起名(字母/数字/汉字/下划线/连字符)。",
210
+ skillDirCreated: "已创建目录",
211
+ skillNewPlaceholder: "文件名(默认 .md)",
212
+ skillCreate: "创建",
213
+ skillRename: "重命名",
214
+ skillDeleteFile: "删除文件",
215
+ skillDeleteConfirm: "删除文件",
216
+ skillReadFailed: "读取失败",
217
+ skillFailed: "操作失败",
218
+ skillDeleteWithWorkflow: "该工作流有技能包,要一起删除吗?",
219
+ skillDeleteBoth: "一起删除",
220
+ skillKeepPack: "只删工作流",
170
221
  queueEmpty: "当前没有任务",
171
222
  queueError: "无法读取队列(ComfyUI 未运行?)",
172
223
  jobFilterLabel: "历史",
@@ -194,7 +245,7 @@ window.__ModuleLoader__.load({
194
245
  baseUrl: "Server URL",
195
246
  baseUrlHint: "ComfyUI HTTP address (default http://127.0.0.1:8188).",
196
247
  apiKeyEnv: "API key env var",
197
- apiKeyEnvHint: "Credential-store or environment variable name (default COMFYUI_API_KEY); usually needed for remote servers.",
248
+ apiKeyEnvHint: "Name of the key: env var or credential store (default COMFYUI_API_KEY).",
198
249
  hasApiKey: "API key configured",
199
250
  noApiKey: "No API key configured (usually not needed for local servers)",
200
251
  save: "Save",
@@ -206,14 +257,16 @@ window.__ModuleLoader__.load({
206
257
  testFail: "Connection failed: {message}",
207
258
  configNotWritable: "Settings service unavailable — edit cordis.yml instead.",
208
259
  mediaHost: "Media base URL",
209
- mediaHostHint: "Absolute base URL used for generated media in chat (e.g. http://192.168.1.5:3080). Leave empty to auto-detect the address your browser uses to reach this server; fill it in when images do not load over LAN or a reverse proxy.",
260
+ mediaHostHint: "Base URL for media shown in chat (e.g. http://192.168.1.5:3080). Auto-detected when empty; set it if images do not load over LAN or a reverse proxy.",
210
261
  comfyuiDirs: "ComfyUI directories",
211
- comfyuiDirsHint: "ComfyUI install root(s) on this machine; add multiple for mapped/multiple installs. The agent reads these to locate your ComfyUI files (models, workflows, the TTS-Audio-Suite voice library) directly instead of asking you.",
262
+ comfyuiDirsHint: "ComfyUI install roots on this machine (multiple allowed: mapped/multiple/portable installs). The agent uses them to locate models, workflows and voice libraries.",
212
263
  comfyuiDirAdd: "+ Add directory",
213
264
  comfyuiDirRemove: "Remove this directory",
214
265
  comfyuiDirPlaceholder: "e.g. D:\\ComfyUI",
266
+ skillsDir: "Skill pack directory",
267
+ skillsDirHint: "Directory for workflow skill packs (SKILL.md + reference files). Empty = skills/ under the plugin data dir; an absolute path may target another drive or a synced folder. Existing packs are not moved automatically. Currently in use: {current}",
215
268
  language: "Language",
216
- languageHint: "Switch the plugin UI language (中文 / English). The page reloads after switching.",
269
+ languageHint: "UI language; the page reloads after switching.",
217
270
  cardGenerating: "ComfyUI generating…",
218
271
  cardBackground: "Background job",
219
272
  cardBackgroundDone: "Completed",
@@ -230,6 +283,7 @@ window.__ModuleLoader__.load({
230
283
  cardMode: "Mode",
231
284
  cardListed: "Listed {runs} runnable workflows, {graphs} graph workflows",
232
285
  panelTitle: "ComfyUI Panel",
286
+ panelReset: "Reset position & size",
233
287
  close: "Close",
234
288
  lbPrev: "Previous image",
235
289
  lbNext: "Next image",
@@ -348,6 +402,54 @@ window.__ModuleLoader__.load({
348
402
  assetDeleteFailed: "Delete failed: {message}",
349
403
  confirmDelete: "Delete",
350
404
  cancel: "Cancel",
405
+ skillButton: "Skill pack",
406
+ skillBadge: "Skill pack",
407
+ skillBadgeRequired: "Skill pack (required)",
408
+ skillBadgeHint: "This workflow has a skill pack: the agent reads it before running.",
409
+ skillTitle: "Skill pack",
410
+ skillIntro: "Click \"Create skill pack\" to add extra skill instructions for this workflow: when to use it, how to fill its parameters, and which pitfalls to avoid.",
411
+ skillEnable: "Create skill pack",
412
+ skillDetach: "Detach (keep files)",
413
+ skillDestroy: "Destroy pack",
414
+ skillDestroyConfirm: "⚠ Permanently delete the whole pack ({count} files) — cannot be undone",
415
+ skillDestroyConfirmButton: "Destroy",
416
+ skillDropHint: "Drop files here to import",
417
+ skillRequired: "Required before running",
418
+ skillDir: "Directory",
419
+ skillSummary: "Summary",
420
+ skillSummaryPlaceholder: "One line on what this workflow is for",
421
+ skillSummaryHint: "The agent uses it to decide whether to read the body — like the description field of a skill.",
422
+ skillSave: "Save",
423
+ skillSaved: "Saved",
424
+ skillDirty: "Unsaved",
425
+ skillDirtyPrompt: "The current file has unsaved changes.",
426
+ skillSaveAndSwitch: "Save and switch",
427
+ skillDiscard: "Discard changes",
428
+ skillImport: "Import files",
429
+ skillImportHint: "Import local files into the pack: md/txt/json/yaml go to references, scripts to scripts, images/csv to assets (drag onto the list works too).",
430
+ skillImported: "Imported",
431
+ skillImageHint: "Images are preview-only here; the agent reads one only when the body points at it.",
432
+ skillNewFile: "+ New file",
433
+ skillNewDir: "+ New folder",
434
+ skillReveal: "📂 Open folder",
435
+ skillRevealHint: "Open this pack folder in the file manager so you can copy files straight in. It opens on the machine running DSH; if you are viewing this page from another machine, use the path shown below instead.",
436
+ skillRevealed: "Opened",
437
+ skillRefresh: "Refresh",
438
+ skillRefreshHint: "Re-read the pack directory to show files added or replaced by the agent or the file manager.",
439
+ skillRefreshed: "Refreshed",
440
+ skillNewDirPlaceholder: "Folder name, e.g. templates",
441
+ skillNewDirHint: "A pack may hold any one-level sub-directory. Common ones: {presets}; custom names work too (letters, digits, CJK, underscore, dash).",
442
+ skillDirCreated: "Folder created",
443
+ skillNewPlaceholder: "File name (.md by default)",
444
+ skillCreate: "Create",
445
+ skillRename: "Rename",
446
+ skillDeleteFile: "Delete file",
447
+ skillDeleteConfirm: "Delete file",
448
+ skillReadFailed: "Read failed",
449
+ skillFailed: "Operation failed",
450
+ skillDeleteWithWorkflow: "This workflow has a skill pack. Delete it too?",
451
+ skillDeleteBoth: "Delete both",
452
+ skillKeepPack: "Workflow only",
351
453
  queueEmpty: "No tasks right now",
352
454
  queueError: "Cannot read the queue (ComfyUI not running?)",
353
455
  jobFilterLabel: "History",
@@ -691,6 +793,19 @@ window.__ModuleLoader__.load({
691
793
  }
692
794
  return response.json();
693
795
  }
796
+ /** POST raw bytes (an imported file) and read the JSON reply. */
797
+ async function postRaw(url, bytes) {
798
+ const response = await fetch(url, {
799
+ method: "POST",
800
+ headers: { "content-type": "application/octet-stream" },
801
+ body: bytes
802
+ });
803
+ if (!response.ok) {
804
+ const payload = await response.json().catch(() => ({}));
805
+ throw new Error(payload.error ?? `HTTP ${response.status}`);
806
+ }
807
+ return response.json();
808
+ }
694
809
  //#endregion
695
810
  //#region src/client/settings.tsx
696
811
  /**
@@ -705,6 +820,7 @@ window.__ModuleLoader__.load({
705
820
  const [apiKeyEnv, setApiKeyEnv] = (0, react.useState)("");
706
821
  const [mediaHost, setMediaHost] = (0, react.useState)("");
707
822
  const [comfyuiDirs, setComfyuiDirs] = (0, react.useState)([]);
823
+ const [skillsDir, setSkillsDir] = (0, react.useState)("");
708
824
  const [loadError, setLoadError] = (0, react.useState)(null);
709
825
  const [saving, setSaving] = (0, react.useState)(false);
710
826
  const [saveState, setSaveState] = (0, react.useState)("idle");
@@ -720,6 +836,7 @@ window.__ModuleLoader__.load({
720
836
  setApiKeyEnv(view.apiKeyEnv);
721
837
  setMediaHost(view.mediaHost ?? "");
722
838
  setComfyuiDirs(Array.isArray(view.comfyuiDirs) ? view.comfyuiDirs : []);
839
+ setSkillsDir(view.skillsDir ?? "");
723
840
  }).catch((error) => {
724
841
  if (cancelled) return;
725
842
  setLoadError(error instanceof Error ? error.message : String(error));
@@ -737,7 +854,8 @@ window.__ModuleLoader__.load({
737
854
  baseUrl,
738
855
  apiKeyEnv,
739
856
  mediaHost,
740
- comfyuiDirs: dirs
857
+ comfyuiDirs: dirs,
858
+ skillsDir: skillsDir.trim()
741
859
  } });
742
860
  if (payload.config !== void 0) {
743
861
  setConfig(payload.config);
@@ -745,6 +863,7 @@ window.__ModuleLoader__.load({
745
863
  setApiKeyEnv(payload.config.apiKeyEnv);
746
864
  setMediaHost(payload.config.mediaHost ?? "");
747
865
  setComfyuiDirs(payload.config.comfyuiDirs ?? []);
866
+ setSkillsDir(payload.config.skillsDir ?? "");
748
867
  }
749
868
  setSaveState("saved");
750
869
  } catch (error) {
@@ -850,7 +969,15 @@ window.__ModuleLoader__.load({
850
969
  setComfyuiDirs([...comfyuiDirs, ""]);
851
970
  setSaveState("idle");
852
971
  }
853
- }, t("comfyuiDirAdd")), (0, react.createElement)("div", { className: "dsc-hint" }, t("comfyuiDirsHint"))), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
972
+ }, t("comfyuiDirAdd")), (0, react.createElement)("div", { className: "dsc-hint" }, t("comfyuiDirsHint"))), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("skillsDir")), (0, react.createElement)("input", {
973
+ className: "dsc-input",
974
+ value: skillsDir,
975
+ placeholder: config.skillsRoot,
976
+ onChange: (event) => {
977
+ setSkillsDir(event.target.value);
978
+ setSaveState("idle");
979
+ }
980
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("skillsDirHint", { current: config.skillsRoot }))), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
854
981
  className: "dsc-btn",
855
982
  disabled: saving || !config.writable,
856
983
  onClick: () => void save()
@@ -989,6 +1116,62 @@ window.__ModuleLoader__.load({
989
1116
  localStorage.setItem(GEOM_KEY, JSON.stringify(geom));
990
1117
  } catch {}
991
1118
  }
1119
+ /** Default panel width from styles.ts (`.dsc-panel` width var), used only to
1120
+ * estimate the panel extent while clamping a saved position. */
1121
+ const PANEL_WIDTH_FALLBACK = 400;
1122
+ /** Approx. height of the drag-handle band (`.dsc-panel-head`). */
1123
+ const HEAD_BAND = 48;
1124
+ /** Minimum margin kept between the panel and the viewport edge. */
1125
+ const EDGE = 8;
1126
+ /**
1127
+ * Clamp a top-left corner so the drag-handle band always stays inside the
1128
+ * viewport. The failure this prevents: once the header is pushed past the top
1129
+ * edge (or a saved position from a larger monitor no longer fits), there is no
1130
+ * grab surface left and the panel cannot be moved back at all.
1131
+ */
1132
+ function clampPos(x, y, width, vw, vh) {
1133
+ const maxX = Math.max(EDGE, vw - width - EDGE);
1134
+ const maxY = Math.max(EDGE, vh - HEAD_BAND - EDGE);
1135
+ return {
1136
+ x: Math.min(Math.max(x, EDGE), maxX),
1137
+ y: Math.min(Math.max(y, EDGE), maxY)
1138
+ };
1139
+ }
1140
+ /** Snap persisted geometry back within reach of the cursor. Geometry that
1141
+ * never carried an explicit position (the CSS-anchored default) is returned
1142
+ * unchanged; geometry already inside the safe area keeps its object identity
1143
+ * so callers can skip pointless re-renders. */
1144
+ function healGeom(geom, vw, vh) {
1145
+ if (geom.x === void 0 || geom.y === void 0) return geom;
1146
+ const pos = clampPos(geom.x, geom.y, geom.width ?? PANEL_WIDTH_FALLBACK, vw, vh);
1147
+ if (pos.x === geom.x && pos.y === geom.y) return geom;
1148
+ return {
1149
+ ...geom,
1150
+ x: pos.x,
1151
+ y: pos.y
1152
+ };
1153
+ }
1154
+ /** Fallback suggestions when the config route has not answered yet; the
1155
+ * authoritative list comes from the host (`presetDirs`). */
1156
+ const SKILL_FALLBACK_DIRS = [
1157
+ "references",
1158
+ "scripts",
1159
+ "assets"
1160
+ ];
1161
+ /** Imported binaries land in assets/; the editor previews these instead of
1162
+ * trying to read them as text. */
1163
+ const SKILL_IMAGE_EXTENSIONS = [
1164
+ ".png",
1165
+ ".jpg",
1166
+ ".jpeg",
1167
+ ".webp",
1168
+ ".gif",
1169
+ ".svg"
1170
+ ];
1171
+ function isSkillImage(path) {
1172
+ const dot = path.lastIndexOf(".");
1173
+ return dot !== -1 && SKILL_IMAGE_EXTENSIONS.includes(path.slice(dot).toLowerCase());
1174
+ }
992
1175
  /** Preset workflow classification tags (users can add custom ones). Stored as
993
1176
  * plain text, so the tag identity is the label itself. */
994
1177
  const PRESET_TAGS = [
@@ -1755,7 +1938,7 @@ window.__ModuleLoader__.load({
1755
1938
  function ComfyUIPanel({ t }) {
1756
1939
  const open = usePanelOpen();
1757
1940
  const tab = usePanelTab();
1758
- const [geom, setGeom] = (0, react.useState)(loadGeom);
1941
+ const [geom, setGeom] = (0, react.useState)(() => healGeom(loadGeom(), window.innerWidth, window.innerHeight));
1759
1942
  const [dragging, setDragging] = (0, react.useState)(null);
1760
1943
  const [lightbox, setLightbox] = (0, react.useState)(null);
1761
1944
  const panelRef = (0, react.useRef)(null);
@@ -1766,9 +1949,18 @@ window.__ModuleLoader__.load({
1766
1949
  index
1767
1950
  });
1768
1951
  (0, react.useEffect)(() => {
1769
- if (open) return;
1952
+ if (open) {
1953
+ setGeom((prev) => healGeom(prev, window.innerWidth, window.innerHeight));
1954
+ return;
1955
+ }
1770
1956
  saveGeom(geom);
1771
1957
  }, [open]);
1958
+ (0, react.useEffect)(() => {
1959
+ if (!open) return;
1960
+ const onResize = () => setGeom((prev) => healGeom(prev, window.innerWidth, window.innerHeight));
1961
+ window.addEventListener("resize", onResize);
1962
+ return () => window.removeEventListener("resize", onResize);
1963
+ }, [open]);
1772
1964
  const startDrag = (event, mode) => {
1773
1965
  if (event.button !== 0) return;
1774
1966
  if (mode === "move") {
@@ -1792,12 +1984,14 @@ window.__ModuleLoader__.load({
1792
1984
  const moveDrag = (event) => {
1793
1985
  const d = dragRef.current;
1794
1986
  if (d === null) return;
1795
- if (dragging === "move") setGeom((prev) => ({
1796
- ...prev,
1797
- x: Math.round(d.origX + event.clientX - d.startX),
1798
- y: Math.round(d.origY + event.clientY - d.startY)
1799
- }));
1800
- else if (dragging === "resize") {
1987
+ if (dragging === "move") {
1988
+ const pos = clampPos(Math.round(d.origX + event.clientX - d.startX), Math.round(d.origY + event.clientY - d.startY), d.width, window.innerWidth, window.innerHeight);
1989
+ setGeom((prev) => ({
1990
+ ...prev,
1991
+ x: pos.x,
1992
+ y: pos.y
1993
+ }));
1994
+ } else if (dragging === "resize") {
1801
1995
  const width = Math.max(300, Math.min(720, d.width + event.clientX - d.startX));
1802
1996
  const height = Math.max(200, Math.min(Math.max(300, window.innerHeight - 40), d.height + event.clientY - d.startY));
1803
1997
  setGeom((prev) => ({
@@ -1836,11 +2030,22 @@ window.__ModuleLoader__.load({
1836
2030
  onPointerMove: (event) => moveDrag(event),
1837
2031
  onPointerUp: () => endDrag(),
1838
2032
  onPointerCancel: () => endDrag()
1839
- }, (0, react.createElement)("span", { className: "dsc-panel-title" }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle"))), (0, react.createElement)("button", {
2033
+ }, (0, react.createElement)("span", { className: "dsc-panel-title" }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle"))), (0, react.createElement)("span", { className: "dsc-panel-head-actions" }, (0, react.createElement)("button", {
2034
+ className: "dsc-panel-reset",
2035
+ type: "button",
2036
+ title: t("panelReset"),
2037
+ "aria-label": t("panelReset"),
2038
+ onClick: () => {
2039
+ setGeom({});
2040
+ try {
2041
+ localStorage.removeItem(GEOM_KEY);
2042
+ } catch {}
2043
+ }
2044
+ }, "↺"), (0, react.createElement)("button", {
1840
2045
  className: "dsc-panel-close",
1841
2046
  "aria-label": t("close"),
1842
2047
  onClick: () => panelStore.close()
1843
- }, "✕")), (0, react.createElement)("div", { className: "dsc-tabs" }, (0, react.createElement)(TabButton, {
2048
+ }, "✕"))), (0, react.createElement)("div", { className: "dsc-tabs" }, (0, react.createElement)(TabButton, {
1844
2049
  t,
1845
2050
  active: tab === "workflows",
1846
2051
  label: t("tabWorkflows"),
@@ -2259,6 +2464,503 @@ window.__ModuleLoader__.load({
2259
2464
  }
2260
2465
  }));
2261
2466
  }
2467
+ /**
2468
+ * The skill-pack editor for one workflow: attach/detach the pack, toggle the
2469
+ * run-time gate, and manage its files (SKILL.md plus references/ and scripts/).
2470
+ *
2471
+ * The pack is what `comfyui_workflow action: skill` hands the agent, so this
2472
+ * view is the only place its content is authored. Each file saves explicitly —
2473
+ * no autosave — because the model reads whatever is on disk the moment it asks.
2474
+ * SKILL.md is edited as a summary field plus a body: the `---` frontmatter that
2475
+ * carries the summary is composed on the host, never typed here.
2476
+ */
2477
+ function SkillPackEditor(props) {
2478
+ const { t } = props;
2479
+ const [pack, setPack] = (0, react.useState)(null);
2480
+ const [enabled, setEnabled] = (0, react.useState)(props.workflow.skillDir !== void 0);
2481
+ const [active, setActive] = (0, react.useState)("SKILL.md");
2482
+ const [summary, setSummary] = (0, react.useState)("");
2483
+ const [text, setText] = (0, react.useState)("");
2484
+ const [dirty, setDirty] = (0, react.useState)(false);
2485
+ const [pendingOpen, setPendingOpen] = (0, react.useState)(null);
2486
+ const [creating, setCreating] = (0, react.useState)(null);
2487
+ const [creatingDir, setCreatingDir] = (0, react.useState)(null);
2488
+ const [presetDirs, setPresetDirs] = (0, react.useState)(SKILL_FALLBACK_DIRS);
2489
+ const [renaming, setRenaming] = (0, react.useState)(null);
2490
+ const [confirming, setConfirming] = (0, react.useState)(null);
2491
+ const [busy, setBusy] = (0, react.useState)(false);
2492
+ const [error, setError] = (0, react.useState)(null);
2493
+ const [notice, setNotice] = (0, react.useState)(null);
2494
+ /** Load one file into the editor; SKILL.md arrives pre-split into summary + body. */
2495
+ const openFile = (0, react.useCallback)(async (path) => {
2496
+ if (isSkillImage(path)) {
2497
+ setActive(path);
2498
+ setSummary("");
2499
+ setText("");
2500
+ setDirty(false);
2501
+ setError(null);
2502
+ return;
2503
+ }
2504
+ try {
2505
+ const data = await getJson(`/comfyui/workflows/skill?id=${encodeURIComponent(props.workflow.id)}&path=${encodeURIComponent(path)}`);
2506
+ if (data.ok !== true) {
2507
+ setError(data.error ?? t("skillReadFailed"));
2508
+ return;
2509
+ }
2510
+ setActive(path);
2511
+ if (path === "SKILL.md") {
2512
+ setSummary(data.summary ?? "");
2513
+ setText(data.body ?? "");
2514
+ } else {
2515
+ setSummary("");
2516
+ setText(data.content ?? "");
2517
+ }
2518
+ setDirty(false);
2519
+ setError(null);
2520
+ } catch (cause) {
2521
+ setError(cause instanceof Error ? cause.message : String(cause));
2522
+ }
2523
+ }, [props.workflow.id, t]);
2524
+ /** Re-read the pack listing, then the requested file. */
2525
+ const refresh = (0, react.useCallback)(async (path) => {
2526
+ try {
2527
+ const data = await getJson(`/comfyui/workflows/skill?id=${encodeURIComponent(props.workflow.id)}`);
2528
+ setEnabled(data.enabled === true);
2529
+ setPack(data.pack ?? null);
2530
+ if (Array.isArray(data.presetDirs) && data.presetDirs.length > 0) setPresetDirs(data.presetDirs);
2531
+ if (data.enabled === true) await openFile(path ?? "SKILL.md");
2532
+ } catch (cause) {
2533
+ setError(cause instanceof Error ? cause.message : String(cause));
2534
+ }
2535
+ }, [props.workflow.id, openFile]);
2536
+ /** Re-read the pack listing and — only when nothing is unsaved — the file
2537
+ * currently open, so files added or replaced outside this panel (the agent
2538
+ * writing via comfyui_skill, or the file manager after `打开目录`) show up
2539
+ * without leaving and re-entering the view. */
2540
+ const refreshFiles = async () => {
2541
+ setBusy(true);
2542
+ setNotice(null);
2543
+ try {
2544
+ const data = await getJson(`/comfyui/workflows/skill?id=${encodeURIComponent(props.workflow.id)}`);
2545
+ setEnabled(data.enabled === true);
2546
+ setPack(data.pack ?? null);
2547
+ if (Array.isArray(data.presetDirs) && data.presetDirs.length > 0) setPresetDirs(data.presetDirs);
2548
+ setError(null);
2549
+ props.onChanged();
2550
+ if (data.enabled === true && !dirty) await openFile(active);
2551
+ setNotice(t("skillRefreshed"));
2552
+ } catch (cause) {
2553
+ setError(cause instanceof Error ? cause.message : String(cause));
2554
+ } finally {
2555
+ setBusy(false);
2556
+ }
2557
+ };
2558
+ (0, react.useEffect)(() => {
2559
+ refresh();
2560
+ }, [refresh]);
2561
+ /** Every mutation goes through here so the listing and the flags stay in sync. */
2562
+ const mutate = async (body, after) => {
2563
+ setBusy(true);
2564
+ setNotice(null);
2565
+ try {
2566
+ const data = await postJson("/comfyui/workflows/skill", {
2567
+ id: props.workflow.id,
2568
+ ...body
2569
+ });
2570
+ if (data.ok !== true) {
2571
+ setError(data.error ?? t("skillFailed"));
2572
+ return false;
2573
+ }
2574
+ setEnabled(data.enabled === true);
2575
+ setPack(data.pack ?? null);
2576
+ setError(null);
2577
+ props.onChanged();
2578
+ if (after !== void 0 && data.enabled === true) await openFile(after);
2579
+ return true;
2580
+ } catch (cause) {
2581
+ setError(cause instanceof Error ? cause.message : String(cause));
2582
+ return false;
2583
+ } finally {
2584
+ setBusy(false);
2585
+ }
2586
+ };
2587
+ const saveFile = async () => {
2588
+ const body = {
2589
+ action: "write",
2590
+ path: active,
2591
+ content: text
2592
+ };
2593
+ if (active === "SKILL.md") body.summary = summary;
2594
+ const ok = await mutate(body);
2595
+ if (ok) {
2596
+ setDirty(false);
2597
+ setNotice(`${t("skillSaved")} ${active}`);
2598
+ }
2599
+ return ok;
2600
+ };
2601
+ /** Switching files with unsaved edits parks the request until the user decides. */
2602
+ const selectFile = (path) => {
2603
+ if (path === active) return;
2604
+ if (dirty) {
2605
+ setPendingOpen(path);
2606
+ return;
2607
+ }
2608
+ openFile(path);
2609
+ };
2610
+ const createFile = async () => {
2611
+ if (creating === null) return;
2612
+ const name = creating.name.trim();
2613
+ if (name === "") return;
2614
+ const path = `${creating.bucket}/${name.includes(".") ? name : `${name}.md`}`;
2615
+ if (await mutate({
2616
+ action: "write",
2617
+ path,
2618
+ content: ""
2619
+ }, path)) setCreating(null);
2620
+ };
2621
+ /** Open the pack directory in the desktop file manager (on the machine
2622
+ * running DSH — the hint says so, and the path stays visible below). */
2623
+ const revealDir = async () => {
2624
+ setNotice(null);
2625
+ try {
2626
+ const data = await postJson("/comfyui/workflows/skill/reveal", { id: props.workflow.id });
2627
+ if (data.ok !== true) {
2628
+ setError(data.error ?? t("skillFailed"));
2629
+ return;
2630
+ }
2631
+ setError(null);
2632
+ setNotice(`${t("skillRevealed")} ${data.dir ?? ""}`);
2633
+ } catch (cause) {
2634
+ setError(cause instanceof Error ? cause.message : String(cause));
2635
+ }
2636
+ };
2637
+ const createDir = async () => {
2638
+ if (creatingDir === null) return;
2639
+ const name = creatingDir.trim();
2640
+ if (name === "") return;
2641
+ if (await mutate({
2642
+ action: "mkdir",
2643
+ name
2644
+ })) {
2645
+ setCreatingDir(null);
2646
+ setNotice(`${t("skillDirCreated")} ${name}/`);
2647
+ }
2648
+ };
2649
+ const applyRename = async () => {
2650
+ if (renaming === null) return;
2651
+ const to = renaming.to.trim();
2652
+ if (to === "") return;
2653
+ const bucket = renaming.from.includes("/") ? `${renaming.from.split("/")[0] ?? ""}/` : "";
2654
+ const target = to.includes("/") ? to : `${bucket}${to}`;
2655
+ if (await mutate({
2656
+ action: "rename",
2657
+ path: renaming.from,
2658
+ to: target
2659
+ }, target)) setRenaming(null);
2660
+ };
2661
+ /** Import local files into the pack. The destination bucket comes from the
2662
+ * host (one extension rule, shared with the agent-facing listing), so the
2663
+ * panel only sends the name and the bytes. */
2664
+ const importFiles = async (files) => {
2665
+ if (files.length === 0) return;
2666
+ setBusy(true);
2667
+ setNotice(null);
2668
+ let imported = null;
2669
+ let failed = 0;
2670
+ for (const file of files) try {
2671
+ const bytes = await file.arrayBuffer();
2672
+ const data = await postRaw(`/comfyui/workflows/skill/import?id=${encodeURIComponent(props.workflow.id)}&name=${encodeURIComponent(file.name)}`, bytes);
2673
+ if (data.ok !== true) {
2674
+ failed += 1;
2675
+ setError(data.error ?? t("skillFailed"));
2676
+ continue;
2677
+ }
2678
+ setPack(data.pack ?? null);
2679
+ imported = data.path ?? null;
2680
+ } catch (cause) {
2681
+ failed += 1;
2682
+ setError(cause instanceof Error ? cause.message : String(cause));
2683
+ }
2684
+ setBusy(false);
2685
+ props.onChanged();
2686
+ if (imported !== null) {
2687
+ if (failed === 0) {
2688
+ setError(null);
2689
+ setNotice(`${t("skillImported")} ${imported}`);
2690
+ }
2691
+ await openFile(imported);
2692
+ }
2693
+ };
2694
+ const head = (0, react.createElement)("div", { className: "dsc-view-head" }, (0, react.createElement)("button", {
2695
+ className: "dsc-btn",
2696
+ onClick: props.onBack
2697
+ }, t("wfViewBack")), (0, react.createElement)("div", {
2698
+ className: "dsc-wf-name",
2699
+ title: props.workflow.name
2700
+ }, `${t("skillTitle")} · ${props.workflow.name}`));
2701
+ if (!enabled) return (0, react.createElement)("div", null, head, (0, react.createElement)("div", { className: "dsc-hint" }, t("skillIntro")), error !== null ? (0, react.createElement)(ErrorNote, {
2702
+ t,
2703
+ message: error
2704
+ }) : null, (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2705
+ className: "dsc-btn",
2706
+ disabled: busy,
2707
+ onClick: () => void mutate({ action: "enable" }, "SKILL.md")
2708
+ }, t("skillEnable"))));
2709
+ const files = pack?.files ?? [];
2710
+ const impliedDirs = files.filter((file) => file.path.includes("/")).map((file) => file.path.split("/")[0] ?? "").filter((dir) => dir !== "");
2711
+ const packDirs = [.../* @__PURE__ */ new Set([...pack?.dirs ?? [], ...impliedDirs])].sort((a, b) => a.localeCompare(b));
2712
+ const buckets = [{
2713
+ label: "",
2714
+ items: files.filter((file) => !file.path.includes("/"))
2715
+ }, ...packDirs.map((bucket) => ({
2716
+ label: bucket,
2717
+ items: files.filter((file) => file.path.startsWith(`${bucket}/`))
2718
+ }))];
2719
+ /** Where a new file may go: the pack's own directories first, then the
2720
+ * presets it has not used yet. */
2721
+ const targetDirs = [...packDirs, ...presetDirs.filter((dir) => !packDirs.includes(dir))];
2722
+ /** One row in the pack file list; clicking opens it for editing. */
2723
+ const fileButton = (file) => (0, react.createElement)("button", {
2724
+ key: file.path,
2725
+ className: `dsc-skill-file${file.path === active ? " dsc-skill-file--active" : ""}`,
2726
+ onClick: () => selectFile(file.path)
2727
+ }, (0, react.createElement)("span", { className: "dsc-skill-file-name" }, file.path.includes("/") ? file.path.split("/")[1] : file.path), (0, react.createElement)("span", { className: "dsc-skill-file-size" }, formatBytes(file.size)));
2728
+ const fileList = (0, react.createElement)("div", {
2729
+ className: "dsc-skill-files",
2730
+ onDragOver: (event) => event.preventDefault(),
2731
+ onDrop: (event) => {
2732
+ event.preventDefault();
2733
+ const dropped = Array.from(event.dataTransfer?.files ?? []);
2734
+ if (dropped.length > 0) importFiles(dropped);
2735
+ }
2736
+ }, (0, react.createElement)("div", { className: "dsc-skill-actions" }, (0, react.createElement)("label", {
2737
+ className: "dsc-btn dsc-btn--sm",
2738
+ title: t("skillImportHint")
2739
+ }, t("skillImport"), (0, react.createElement)("input", {
2740
+ type: "file",
2741
+ multiple: true,
2742
+ style: { display: "none" },
2743
+ disabled: busy,
2744
+ onChange: (event) => {
2745
+ const picked = Array.from(event.target.files ?? []);
2746
+ event.target.value = "";
2747
+ if (picked.length > 0) importFiles(picked);
2748
+ }
2749
+ })), creating === null ? (0, react.createElement)("button", {
2750
+ className: "dsc-btn dsc-btn--sm",
2751
+ disabled: busy,
2752
+ onClick: () => {
2753
+ setCreatingDir(null);
2754
+ setCreating({
2755
+ bucket: targetDirs[0] ?? "references",
2756
+ name: ""
2757
+ });
2758
+ }
2759
+ }, t("skillNewFile")) : null, creatingDir === null ? (0, react.createElement)("button", {
2760
+ className: "dsc-btn dsc-btn--sm",
2761
+ disabled: busy,
2762
+ onClick: () => {
2763
+ setCreating(null);
2764
+ setCreatingDir("");
2765
+ }
2766
+ }, t("skillNewDir")) : null, (0, react.createElement)("button", {
2767
+ className: "dsc-btn dsc-btn--sm",
2768
+ disabled: busy,
2769
+ title: t("skillRevealHint"),
2770
+ onClick: () => void revealDir()
2771
+ }, t("skillReveal")), (0, react.createElement)("button", {
2772
+ className: "dsc-btn dsc-btn--sm",
2773
+ disabled: busy,
2774
+ title: t("skillRefreshHint"),
2775
+ onClick: () => void refreshFiles()
2776
+ }, t("skillRefresh"))), creatingDir === null ? null : (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("input", {
2777
+ className: "dsc-input dsc-input--sm",
2778
+ value: creatingDir,
2779
+ placeholder: t("skillNewDirPlaceholder"),
2780
+ onChange: (event) => setCreatingDir(event.target.value),
2781
+ onKeyDown: (event) => {
2782
+ if (event.key === "Enter") {
2783
+ event.preventDefault();
2784
+ createDir();
2785
+ }
2786
+ }
2787
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("skillNewDirHint", { presets: presetDirs.join(" / ") })), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2788
+ className: "dsc-btn dsc-btn--sm",
2789
+ disabled: busy,
2790
+ onClick: () => void createDir()
2791
+ }, t("skillCreate")), (0, react.createElement)("button", {
2792
+ className: "dsc-btn dsc-btn--sm",
2793
+ onClick: () => setCreatingDir(null)
2794
+ }, t("cancel")))), (0, react.createElement)("div", { className: "dsc-skill-drop-hint" }, t("skillDropHint")), buckets.map((bucket) => bucket.items.length === 0 && bucket.label !== "" ? null : bucket.label === "" ? (0, react.createElement)("div", {
2795
+ key: ".",
2796
+ className: "dsc-skill-bucket"
2797
+ }, bucket.items.map(fileButton)) : (0, react.createElement)("details", {
2798
+ key: bucket.label,
2799
+ className: "dsc-fold dsc-skill-fold",
2800
+ open: true
2801
+ }, (0, react.createElement)("summary", null, `${bucket.label}/`), (0, react.createElement)("div", { className: "dsc-skill-bucket" }, bucket.items.map(fileButton)))), creating === null ? null : (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("select", {
2802
+ className: "dsc-input dsc-input--sm",
2803
+ value: creating.bucket,
2804
+ onChange: (event) => setCreating({
2805
+ ...creating,
2806
+ bucket: event.target.value
2807
+ })
2808
+ }, targetDirs.map((bucket) => (0, react.createElement)("option", {
2809
+ key: bucket,
2810
+ value: bucket
2811
+ }, `${bucket}/`))), (0, react.createElement)("input", {
2812
+ className: "dsc-input dsc-input--sm",
2813
+ value: creating.name,
2814
+ placeholder: t("skillNewPlaceholder"),
2815
+ onChange: (event) => setCreating({
2816
+ ...creating,
2817
+ name: event.target.value
2818
+ }),
2819
+ onKeyDown: (event) => {
2820
+ if (event.key === "Enter") {
2821
+ event.preventDefault();
2822
+ createFile();
2823
+ }
2824
+ }
2825
+ }), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2826
+ className: "dsc-btn dsc-btn--sm",
2827
+ disabled: busy,
2828
+ onClick: () => void createFile()
2829
+ }, t("skillCreate")), (0, react.createElement)("button", {
2830
+ className: "dsc-btn dsc-btn--sm",
2831
+ onClick: () => setCreating(null)
2832
+ }, t("cancel")))));
2833
+ const editor = (0, react.createElement)("div", { className: "dsc-skill-main" }, (0, react.createElement)("div", { className: "dsc-skill-editor-head" }, (0, react.createElement)("span", { className: "dsc-skill-path" }, active), dirty ? (0, react.createElement)("span", { className: "dsc-badge dsc-badge--warn" }, t("skillDirty")) : null), active === "SKILL.md" ? (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("skillSummary")), (0, react.createElement)("input", {
2834
+ className: "dsc-input",
2835
+ value: summary,
2836
+ placeholder: t("skillSummaryPlaceholder"),
2837
+ onChange: (event) => {
2838
+ setSummary(event.target.value);
2839
+ setDirty(true);
2840
+ }
2841
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("skillSummaryHint"))) : null, isSkillImage(active) ? (0, react.createElement)("div", { className: "dsc-skill-preview" }, (0, react.createElement)("img", {
2842
+ src: `/comfyui/workflows/skill/raw?id=${encodeURIComponent(props.workflow.id)}&path=${encodeURIComponent(active)}`,
2843
+ alt: active
2844
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("skillImageHint"))) : (0, react.createElement)("textarea", {
2845
+ className: "dsc-textarea dsc-textarea--skill",
2846
+ value: text,
2847
+ spellCheck: false,
2848
+ onChange: (event) => {
2849
+ setText(event.target.value);
2850
+ setDirty(true);
2851
+ },
2852
+ onKeyDown: (event) => {
2853
+ if (event.key !== "Tab") return;
2854
+ event.preventDefault();
2855
+ const area = event.target;
2856
+ const start = area.selectionStart;
2857
+ const end = area.selectionEnd;
2858
+ setText(`${text.slice(0, start)} ${text.slice(end)}`);
2859
+ setDirty(true);
2860
+ window.requestAnimationFrame(() => {
2861
+ area.selectionStart = start + 2;
2862
+ area.selectionEnd = start + 2;
2863
+ });
2864
+ }
2865
+ }), (0, react.createElement)("div", { className: "dsc-row" }, isSkillImage(active) ? null : (0, react.createElement)("button", {
2866
+ className: "dsc-btn",
2867
+ disabled: busy || !dirty,
2868
+ onClick: () => void saveFile()
2869
+ }, t("skillSave")), active !== "SKILL.md" ? (0, react.createElement)("button", {
2870
+ className: "dsc-btn",
2871
+ disabled: busy,
2872
+ onClick: () => setRenaming({
2873
+ from: active,
2874
+ to: active.includes("/") ? active.split("/")[1] ?? "" : active
2875
+ })
2876
+ }, t("skillRename")) : null, active !== "SKILL.md" ? (0, react.createElement)("button", {
2877
+ className: "dsc-btn",
2878
+ disabled: busy,
2879
+ onClick: () => setConfirming(active)
2880
+ }, t("skillDeleteFile")) : null), confirming !== null && confirming !== "*pack*" ? (0, react.createElement)("div", { className: "dsc-dialog dsc-dialog--danger" }, (0, react.createElement)("div", { className: "dsc-dialog-head dsc-danger-text" }, `${t("skillDeleteConfirm")} ${confirming}`), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2881
+ className: "dsc-btn dsc-btn--danger",
2882
+ disabled: busy,
2883
+ onClick: () => {
2884
+ const target = confirming;
2885
+ setConfirming(null);
2886
+ mutate({
2887
+ action: "delete",
2888
+ path: target
2889
+ }, "SKILL.md");
2890
+ }
2891
+ }, t("confirmDelete")), (0, react.createElement)("button", {
2892
+ className: "dsc-btn",
2893
+ onClick: () => setConfirming(null)
2894
+ }, t("cancel")))) : null, renaming !== null ? (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("input", {
2895
+ className: "dsc-input dsc-input--sm",
2896
+ value: renaming.to,
2897
+ onChange: (event) => setRenaming({
2898
+ ...renaming,
2899
+ to: event.target.value
2900
+ })
2901
+ }), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2902
+ className: "dsc-btn dsc-btn--sm",
2903
+ disabled: busy,
2904
+ onClick: () => void applyRename()
2905
+ }, t("skillRename")), (0, react.createElement)("button", {
2906
+ className: "dsc-btn dsc-btn--sm",
2907
+ onClick: () => setRenaming(null)
2908
+ }, t("cancel")))) : null);
2909
+ return (0, react.createElement)("div", null, head, (0, react.createElement)("div", { className: "dsc-row dsc-skill-flags" }, (0, react.createElement)("label", { className: "dsc-check" }, (0, react.createElement)("input", {
2910
+ type: "checkbox",
2911
+ checked: pack?.required === true,
2912
+ disabled: busy,
2913
+ onChange: (event) => void mutate({
2914
+ action: "require",
2915
+ required: event.target.checked
2916
+ })
2917
+ }), t("skillRequired"))), pack !== null ? (0, react.createElement)("div", {
2918
+ className: "dsc-skill-dir",
2919
+ title: pack.dir
2920
+ }, `${t("skillDir")}: ${pack.dir}`) : null, pendingOpen !== null ? (0, react.createElement)("div", { className: "dsc-dialog" }, (0, react.createElement)("div", { className: "dsc-dialog-head" }, t("skillDirtyPrompt")), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2921
+ className: "dsc-btn",
2922
+ disabled: busy,
2923
+ onClick: () => {
2924
+ const target = pendingOpen;
2925
+ setPendingOpen(null);
2926
+ saveFile().then((ok) => {
2927
+ if (ok) openFile(target);
2928
+ });
2929
+ }
2930
+ }, t("skillSaveAndSwitch")), (0, react.createElement)("button", {
2931
+ className: "dsc-btn",
2932
+ onClick: () => {
2933
+ const target = pendingOpen;
2934
+ setPendingOpen(null);
2935
+ setDirty(false);
2936
+ openFile(target);
2937
+ }
2938
+ }, t("skillDiscard")), (0, react.createElement)("button", {
2939
+ className: "dsc-btn",
2940
+ onClick: () => setPendingOpen(null)
2941
+ }, t("cancel")))) : null, notice !== null ? (0, react.createElement)("div", { className: "dsc-ok" }, notice) : null, error !== null ? (0, react.createElement)(ErrorNote, {
2942
+ t,
2943
+ message: error
2944
+ }) : null, (0, react.createElement)("div", { className: "dsc-skill" }, fileList, editor), (0, react.createElement)("div", { className: "dsc-skill-footer" }, (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2945
+ className: "dsc-btn",
2946
+ disabled: busy,
2947
+ onClick: () => void mutate({ action: "disable" }).then(() => props.onBack())
2948
+ }, t("skillDetach")), (0, react.createElement)("button", {
2949
+ className: "dsc-btn dsc-btn--danger",
2950
+ disabled: busy,
2951
+ onClick: () => setConfirming("*pack*")
2952
+ }, t("skillDestroy"))), confirming === "*pack*" ? (0, react.createElement)("div", { className: "dsc-dialog dsc-dialog--danger" }, (0, react.createElement)("div", { className: "dsc-dialog-head dsc-danger-text" }, t("skillDestroyConfirm", { count: pack?.files.length ?? 0 })), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2953
+ className: "dsc-btn dsc-btn--danger",
2954
+ disabled: busy,
2955
+ onClick: () => {
2956
+ setConfirming(null);
2957
+ mutate({ action: "destroy" }).then(() => props.onBack());
2958
+ }
2959
+ }, t("skillDestroyConfirmButton")), (0, react.createElement)("button", {
2960
+ className: "dsc-btn",
2961
+ onClick: () => setConfirming(null)
2962
+ }, t("cancel")))) : null));
2963
+ }
2262
2964
  function WorkflowsTab({ t }) {
2263
2965
  const [list, setList] = (0, react.useState)(null);
2264
2966
  const [comfyui, setComfyui] = (0, react.useState)(null);
@@ -2270,6 +2972,9 @@ window.__ModuleLoader__.load({
2270
2972
  const [viewing, setViewing] = (0, react.useState)(null);
2271
2973
  const [extract, setExtract] = (0, react.useState)(null);
2272
2974
  const [tagFilter, setTagFilter] = (0, react.useState)(null);
2975
+ const [skillFor, setSkillFor] = (0, react.useState)(null);
2976
+ /** A delete waiting on the user's answer about the workflow's skill pack. */
2977
+ const [deleting, setDeleting] = (0, react.useState)(null);
2273
2978
  const load = async () => {
2274
2979
  try {
2275
2980
  const data = await getJson("/comfyui/workflows");
@@ -2360,11 +3065,16 @@ window.__ModuleLoader__.load({
2360
3065
  setBusy(false);
2361
3066
  }
2362
3067
  };
2363
- const remove = async (id) => {
3068
+ /** Delete a workflow. A workflow carrying a skill pack asks first: the pack
3069
+ * holds hand-written notes that nothing else can restore. */
3070
+ const remove = async (id, deleteSkill = false) => {
2364
3071
  setBusy(true);
2365
3072
  setError(null);
2366
3073
  try {
2367
- await postJson("/comfyui/workflows/delete", { id });
3074
+ await postJson("/comfyui/workflows/delete", {
3075
+ id,
3076
+ deleteSkill
3077
+ });
2368
3078
  await load();
2369
3079
  } catch (cause) {
2370
3080
  setError(cause instanceof Error ? cause.message : String(cause));
@@ -2372,6 +3082,13 @@ window.__ModuleLoader__.load({
2372
3082
  setBusy(false);
2373
3083
  }
2374
3084
  };
3085
+ const requestRemove = (workflow) => {
3086
+ if (workflow.skillDir !== void 0) {
3087
+ setDeleting(workflow);
3088
+ return;
3089
+ }
3090
+ remove(workflow.id);
3091
+ };
2375
3092
  const openExtract = (file) => {
2376
3093
  setError(null);
2377
3094
  setExtract({
@@ -2433,6 +3150,17 @@ window.__ModuleLoader__.load({
2433
3150
  setBusy(false);
2434
3151
  }
2435
3152
  };
3153
+ if (skillFor !== null) return (0, react.createElement)(SkillPackEditor, {
3154
+ t,
3155
+ workflow: skillFor,
3156
+ onBack: () => {
3157
+ setSkillFor(null);
3158
+ load();
3159
+ },
3160
+ onChanged: () => {
3161
+ load();
3162
+ }
3163
+ });
2436
3164
  if (viewing !== null) return (0, react.createElement)(WorkflowView, {
2437
3165
  t,
2438
3166
  file: viewing.file,
@@ -2551,14 +3279,15 @@ window.__ModuleLoader__.load({
2551
3279
  const apiFold = (0, react.createElement)("details", {
2552
3280
  className: "dsc-fold",
2553
3281
  open: true
2554
- }, (0, react.createElement)("summary", null, `${t("wfLibrarySection")} (${visibleList.length})`), (0, react.createElement)("div", { className: "dsc-fold-body" }, visibleTags.length > 0 || tagFilter !== null ? (0, react.createElement)("div", { className: "dsc-tag-filter" }, (0, react.createElement)("button", {
2555
- className: `dsc-tag-chip${tagFilter === null ? " dsc-tag-chip--active" : ""}`,
2556
- onClick: () => setTagFilter(null)
2557
- }, `${t("wfTagAll")} (${list.length})`), visibleTags.map((tag) => (0, react.createElement)("button", {
3282
+ }, (0, react.createElement)("summary", null, `${t("wfLibrarySection")} (${visibleList.length})`), (0, react.createElement)("div", { className: "dsc-fold-body" }, visibleTags.length > 0 || tagFilter !== null ? (0, react.createElement)("div", { className: "dsc-tag-filter" }, (0, react.createElement)("select", {
3283
+ className: "dsc-input dsc-input--inline",
3284
+ title: t("wfTags"),
3285
+ value: tagFilter ?? "",
3286
+ onChange: (event) => setTagFilter(event.target.value === "" ? null : event.target.value)
3287
+ }, (0, react.createElement)("option", { value: "" }, `${t("wfTagAll")} (${list.length})`), visibleTags.map((tag) => (0, react.createElement)("option", {
2558
3288
  key: tag,
2559
- className: `dsc-tag-chip${tagFilter === tag ? " dsc-tag-chip--active" : ""}`,
2560
- onClick: () => setTagFilter(tagFilter === tag ? null : tag)
2561
- }, `${tag} (${tagCounts.get(tag) ?? 0})`))) : null, visibleList.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("wfEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, visibleList.map((workflow) => (0, react.createElement)("div", {
3289
+ value: tag
3290
+ }, `${tag} (${tagCounts.get(tag) ?? 0})`)))) : null, visibleList.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("wfEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, visibleList.map((workflow) => (0, react.createElement)("div", {
2562
3291
  key: workflow.id,
2563
3292
  className: "dsc-wf",
2564
3293
  title: t("wfNameCopyHint"),
@@ -2573,7 +3302,10 @@ window.__ModuleLoader__.load({
2573
3302
  }, workflow.name), (workflow.tags ?? []).length > 0 ? (0, react.createElement)("div", { className: "dsc-wf-tags" }, (workflow.tags ?? []).map((tag) => (0, react.createElement)("span", {
2574
3303
  key: tag,
2575
3304
  className: "dsc-tag-chip dsc-tag-chip--mini"
2576
- }, tag))) : null), workflow.description !== "" ? (0, react.createElement)("div", { className: "dsc-wf-desc" }, workflow.description) : null, (0, react.createElement)("div", { className: "dsc-wf-updated" }, `${t("wfUpdated")} ${formatTs(workflow.updatedAt)}`), (0, react.createElement)("div", { className: "dsc-wf-actions" }, (0, react.createElement)("button", {
3305
+ }, tag))) : null, workflow.skillDir !== void 0 ? (0, react.createElement)("span", {
3306
+ className: "dsc-badge dsc-badge--ok",
3307
+ title: t("skillBadgeHint")
3308
+ }, workflow.requireSkill === true ? t("skillBadgeRequired") : t("skillBadge")) : null), workflow.description !== "" ? (0, react.createElement)("div", { className: "dsc-wf-desc" }, workflow.description) : null, (0, react.createElement)("div", { className: "dsc-wf-updated" }, `${t("wfUpdated")} ${formatTs(workflow.updatedAt)}`), (0, react.createElement)("div", { className: "dsc-wf-actions" }, (0, react.createElement)("button", {
2577
3309
  className: "dsc-btn",
2578
3310
  disabled: busy,
2579
3311
  onClick: (event) => {
@@ -2598,9 +3330,37 @@ window.__ModuleLoader__.load({
2598
3330
  disabled: busy,
2599
3331
  onClick: (event) => {
2600
3332
  event.stopPropagation();
2601
- remove(workflow.id);
3333
+ setSkillFor(workflow);
3334
+ setError(null);
3335
+ }
3336
+ }, t("skillButton")), (0, react.createElement)("button", {
3337
+ className: "dsc-btn",
3338
+ disabled: busy,
3339
+ onClick: (event) => {
3340
+ event.stopPropagation();
3341
+ requestRemove(workflow);
3342
+ }
3343
+ }, t("wfDelete"))), deleting?.id === workflow.id ? (0, react.createElement)("div", {
3344
+ className: "dsc-dialog dsc-dialog--danger",
3345
+ onClick: (event) => event.stopPropagation()
3346
+ }, (0, react.createElement)("div", { className: "dsc-dialog-head dsc-danger-text" }, t("skillDeleteWithWorkflow")), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
3347
+ className: "dsc-btn dsc-btn--danger",
3348
+ disabled: busy,
3349
+ onClick: () => {
3350
+ setDeleting(null);
3351
+ remove(workflow.id, true);
2602
3352
  }
2603
- }, t("wfDelete"))))))));
3353
+ }, t("skillDeleteBoth")), (0, react.createElement)("button", {
3354
+ className: "dsc-btn",
3355
+ disabled: busy,
3356
+ onClick: () => {
3357
+ setDeleting(null);
3358
+ remove(workflow.id, false);
3359
+ }
3360
+ }, t("skillKeepPack")), (0, react.createElement)("button", {
3361
+ className: "dsc-btn",
3362
+ onClick: () => setDeleting(null)
3363
+ }, t("cancel")))) : null)))));
2604
3364
  return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "dsc-toolbar" }, (0, react.createElement)("button", {
2605
3365
  className: "dsc-btn",
2606
3366
  disabled: busy,
@@ -3262,6 +4022,9 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3262
4022
  }
3263
4023
  .dsc-panel-resize:hover { opacity: 1; }
3264
4024
  .dsc-panel-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 14px; }
4025
+ .dsc-panel-head-actions { display: inline-flex; align-items: center; gap: 2px; }
4026
+ .dsc-panel-reset { border: none; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-size: 13px; padding: 4px 6px; line-height: 1; }
4027
+ .dsc-panel-reset:hover { color: var(--dsw-alias-label-primary); }
3265
4028
  .dsc-panel-close { border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-size: 14px; padding: 4px 8px; }
3266
4029
  .dsc-panel-close:hover { color: var(--dsw-alias-label-primary); }
3267
4030
  .dsc-tabs { display: flex; border-bottom: 1px solid var(--dsw-alias-border-l1); }
@@ -3307,6 +4070,40 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3307
4070
  .dsc-derived-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
3308
4071
  .dsc-derived-name { color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3309
4072
  .dsc-dialog { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px; }
4073
+ /* --- skill pack editor: file list beside a plain-text editor --- */
4074
+ .dsc-skill { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; margin-top: 8px; }
4075
+ .dsc-skill-files { flex: 0 0 190px; min-width: 160px; display: flex; flex-direction: column; gap: 6px; }
4076
+ .dsc-skill-main { flex: 1 1 300px; min-width: 260px; display: flex; flex-direction: column; gap: 6px; }
4077
+ .dsc-skill-bucket { display: flex; flex-direction: column; gap: 2px; }
4078
+ /* Foldable pack directories: each folder renders as a <details> whose header
4079
+ stays put while its files collapse, keeping crowded packs scannable. */
4080
+ .dsc-skill-fold { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; margin: 2px 0; }
4081
+ .dsc-skill-fold summary { padding: 5px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary); border-radius: 8px; }
4082
+ .dsc-skill-fold[open] summary { color: var(--dsw-alias-label-primary); border-bottom: 1px solid var(--dsw-alias-border-l1); border-radius: 8px 8px 0 0; }
4083
+ .dsc-skill-fold .dsc-skill-bucket { padding: 4px 6px 6px; }
4084
+ .dsc-skill-file {
4085
+ display: flex; align-items: center; justify-content: space-between; gap: 6px;
4086
+ border: 1px solid transparent; border-radius: 6px; background: transparent;
4087
+ color: inherit; font-size: 12px; padding: 4px 6px; cursor: pointer; text-align: left;
4088
+ }
4089
+ .dsc-skill-file:hover { background: var(--dsw-alias-bg-layer-2); }
4090
+ .dsc-skill-file--active { border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
4091
+ .dsc-skill-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4092
+ .dsc-skill-file-size { color: var(--dsw-alias-label-secondary); font-size: 11px; flex: none; }
4093
+ .dsc-skill-create { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
4094
+ .dsc-skill-editor-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; }
4095
+ .dsc-skill-path { color: var(--dsw-alias-label-primary); }
4096
+ .dsc-textarea--skill { min-height: 260px; font-size: 12px; line-height: 1.5; }
4097
+ .dsc-skill-preview { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 6px; align-items: center; }
4098
+ .dsc-skill-preview img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 6px; }
4099
+ .dsc-skill-dir { font-size: 11px; color: var(--dsw-alias-label-secondary); margin: 4px 0; word-break: break-all; }
4100
+ .dsc-skill-flags { margin-top: 6px; }
4101
+ .dsc-skill-footer { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
4102
+ .dsc-skill-actions { display: flex; gap: 6px; flex-wrap: wrap; }
4103
+ .dsc-skill-drop-hint { font-size: 11px; color: var(--dsw-alias-label-secondary); border: 1px dashed var(--dsw-alias-border-l1); border-radius: 6px; padding: 4px 6px; text-align: center; }
4104
+ .dsc-dialog--danger { border-color: var(--dsw-alias-state-error-primary); }
4105
+ .dsc-danger-text { color: var(--dsw-alias-state-error-primary); font-weight: 600; font-size: 12px; line-height: 1.5; }
4106
+ .dsc-check { display: flex; align-items: center; gap: 6px; font-size: 12px; }
3310
4107
  .dsc-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
3311
4108
  .dsc-extract-modes { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; }
3312
4109
  .dsc-mode { display: flex; flex-direction: column; gap: 2px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; cursor: pointer; }
@@ -3435,7 +4232,7 @@ video.dsc-picker-player { max-height: 180px; }
3435
4232
  .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; }
3436
4233
  .dsc-picker-card:hover .dsc-picker-name { color: var(--dsw-alias-brand-primary); }
3437
4234
  /* --- workflow tags: filter bar, chips, editor --- */
3438
- .dsc-tag-filter { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 0 2px; }
4235
+ .dsc-tag-filter { display: flex; justify-content: flex-end; padding: 6px 0 2px; }
3439
4236
  .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); }
3440
4237
  .dsc-tag-chip--active { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
3441
4238
  .dsc-tag-chip--mini { font-size: 11px; padding: 1px 8px; cursor: default; border: 1px solid var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }
@@ -3452,6 +4249,7 @@ video.dsc-picker-player { max-height: 180px; }
3452
4249
  .dsc-queue-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
3453
4250
  .dsc-btn { border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; background: transparent; color: var(--dsw-alias-label-primary); }
3454
4251
  .dsc-btn--sm { padding: 1px 6px; font-size: 11px; }
4252
+ .dsc-input--sm { padding: 3px 6px; font-size: 12px; }
3455
4253
  .dsc-btn:disabled { opacity: 0.5; cursor: default; }
3456
4254
  .dsc-job-item { display: flex; align-items: center; gap: 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 7px 10px; background: var(--dsw-alias-bg-layer-1); transition: background 0.12s, border-color 0.12s; }
3457
4255
  .dsc-job-item:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }