dsh-comfyui 0.3.0-beta.6 → 0.5.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,16 +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
34
  skillsDir: "技能包目录",
35
- skillsDirHint: "技能包(每个工作流的 SKILL.md 与参考文档)的存放目录。留空表示放在插件数据目录下的 skills/。填绝对路径可以把这些文档放到别的盘、同步盘或版本库里;相对路径会被忽略。改这里不会自动搬走已有的技能包,需要你手动把旧目录里的文件夹移过去。当前实际使用:{current}",
35
+ skillsDirHint: "工作流技能包(SKILL.md 与参考文档)目录。留空 = 数据目录下 skills/;填绝对路径可放到别的盘或同步盘。改后需手动搬移已有目录。当前实际使用:{current}",
36
36
  language: "界面语言",
37
- languageHint: "切换插件界面语言(中文 / English)。切换后页面会自动刷新。",
37
+ languageHint: "界面语言,切换后页面自动刷新。",
38
38
  cardGenerating: "ComfyUI 生成中…",
39
39
  cardBackground: "后台任务",
40
40
  cardBackgroundDone: "已完成",
@@ -51,6 +51,7 @@ window.__ModuleLoader__.load({
51
51
  cardMode: "模式",
52
52
  cardListed: "已列出 {runs} 个运行工作流,{graphs} 个图工作流",
53
53
  panelTitle: "ComfyUI 面板",
54
+ panelReset: "复位位置与大小",
54
55
  close: "关闭",
55
56
  lbPrev: "上一张",
56
57
  lbNext: "下一张",
@@ -60,6 +61,10 @@ window.__ModuleLoader__.load({
60
61
  error: "出错",
61
62
  refresh: "刷新",
62
63
  ours: "本插件",
64
+ connChecking: "正在检测 ComfyUI 连接…",
65
+ connOk: "连接正常 · ComfyUI {version} · {ms} ms",
66
+ connFailTitle: "无法连接 ComfyUI",
67
+ connFailBody: "后端未能连上 ComfyUI({message})。请先启动本机 ComfyUI,确保后端能检测到其端口;若使用远程服务器,请到设置页检查服务器地址并点「测试连接」。",
63
68
  wfAdd: "新建工作流",
64
69
  wfName: "名称",
65
70
  wfDesc: "概述",
@@ -130,7 +135,34 @@ window.__ModuleLoader__.load({
130
135
  wfEmpty: "没有检测到任何工作流,请通过新建、导入或提取来创建执行流程。",
131
136
  wfLibrarySection: "API 工作流",
132
137
  wfRefresh: "刷新",
133
- wfImportFile: "导入文件",
138
+ wfImportFile: "导入 JSON",
139
+ exportPresets: "导出预设",
140
+ importPresets: "导入预设",
141
+ transferExportTitle: "导出工作流预设",
142
+ transferImportTitle: "导入工作流预设",
143
+ transferExportHint: "共 {total} 个可导出工作流,已选 {selected} 个。运行参数与技能包会一并导出。",
144
+ transferSelectAll: "全选",
145
+ transferSelectNone: "全不选",
146
+ transferExportRun: "导出 ({n})",
147
+ transferImportRun: "导入 ({n})",
148
+ transferExporting: "正在导出…",
149
+ transferImporting: "正在导入…",
150
+ transferExportDone: "已导出 {n} 个工作流:{names}(浏览器正在下载预设包)",
151
+ transferExportFile: "文件 {file}({size} KB)",
152
+ transferExportWarnings: "导出警告:{warnings}",
153
+ transferSelectedList: "将导出:{names}",
154
+ transferExportEmpty: "没有可导出的工作流",
155
+ transferPickFile: "点击选择或拖入 .zip 预设包",
156
+ transferPickHint: "导入会创建为新工作流,不会覆盖库中已有的工作流",
157
+ transferPackageInfo: "包内共 {n} 个工作流 · 导出于 {date}",
158
+ transferParamBadge: "参数 {n}",
159
+ transferSkillBadge: "技能包 · {n} 个文件",
160
+ transferSkillRequired: "技能包 · {n} 个文件 · 必读",
161
+ transferImportDone: "已导入 {n} 个工作流",
162
+ transferImportedItem: "✓ {src} → {dst}",
163
+ transferImportFailedItem: "✗ {src}:{reason}",
164
+ transferImportNote: "重名工作流自动追加(导入)后缀",
165
+ transferFinish: "完成",
134
166
  wfComfyuiSection: "ComfyUI 工作流",
135
167
  wfComfyuiHint: "面板自动检测 ComfyUI 中保存的工作流。\n注:非 API 流程,无法直接执行,需经过提取转化为 API 工作流,或自行导入 API 工作流即可执行。",
136
168
  wfComfyuiEmpty: "ComfyUI 端没有检测到保存的工作流",
@@ -174,7 +206,7 @@ window.__ModuleLoader__.load({
174
206
  skillBadgeRequired: "技能包·必读",
175
207
  skillBadgeHint: "该工作流挂了技能包:Agent 运行前会读它。",
176
208
  skillTitle: "技能包",
177
- skillIntro: "技能包是写给 Agent 看的一份说明书:什么时候用这个工作流、关键参数怎么填、哪些环节容易翻车。它不会常驻在对话里——Agent 只有在 comfyui_workflow list 里看到摘要、并决定要用这个工作流时,才会读取正文;正文里点名的参考文档,它才会再去打开。适合参数多、坑多、或带风格合集的复杂工作流。",
209
+ skillIntro: "点击「启用技能包」后,可为这个工作流添加给 Agent 看的额外 skills 说明:什么时候用、参数怎么填、有哪些坑。",
178
210
  skillEnable: "启用技能包",
179
211
  skillDetach: "停用(保留文件)",
180
212
  skillDestroy: "销毁技能包",
@@ -182,11 +214,10 @@ window.__ModuleLoader__.load({
182
214
  skillDestroyConfirmButton: "确认销毁",
183
215
  skillDropHint: "可把文件拖到这里导入",
184
216
  skillRequired: "运行前必读",
185
- skillRequiredHint: "勾选后,Agent 未读技能包就调用本工作流会被拒绝并提示先读。",
186
217
  skillDir: "目录",
187
218
  skillSummary: "摘要",
188
219
  skillSummaryPlaceholder: "一句话说明这个工作流适合做什么",
189
- skillSummaryHint: "这句话会出现在 comfyui_workflow list 里,Agent 靠它决定要不要读正文。建议 40 字以内。",
220
+ skillSummaryHint: "Agent 靠它决定要不要读正文,等同 skills 里的 description 字段。",
190
221
  skillSave: "保存",
191
222
  skillSaved: "已保存",
192
223
  skillDirty: "未保存",
@@ -202,6 +233,9 @@ window.__ModuleLoader__.load({
202
233
  skillReveal: "📂 打开目录",
203
234
  skillRevealHint: "在文件管理器中打开这个技能包的文件夹,方便直接拷贝文件进去。打开的是运行 DSH 那台机器上的目录;若你正从另一台机器访问本页面,请按下方显示的路径自行前往。",
204
235
  skillRevealed: "已打开",
236
+ skillRefresh: "刷新",
237
+ skillRefreshHint: "重新读取技能包目录,显示 Agent 写入或文件管理器里新增/替换的文件。",
238
+ skillRefreshed: "已刷新",
205
239
  skillNewDirPlaceholder: "目录名,如 templates",
206
240
  skillNewDirHint: "技能包可以有任意一层子目录。常用:{presets};也可以自己起名(字母/数字/汉字/下划线/连字符)。",
207
241
  skillDirCreated: "已创建目录",
@@ -242,7 +276,7 @@ window.__ModuleLoader__.load({
242
276
  baseUrl: "Server URL",
243
277
  baseUrlHint: "ComfyUI HTTP address (default http://127.0.0.1:8188).",
244
278
  apiKeyEnv: "API key env var",
245
- apiKeyEnvHint: "Credential-store or environment variable name (default COMFYUI_API_KEY); usually needed for remote servers.",
279
+ apiKeyEnvHint: "Name of the key: env var or credential store (default COMFYUI_API_KEY).",
246
280
  hasApiKey: "API key configured",
247
281
  noApiKey: "No API key configured (usually not needed for local servers)",
248
282
  save: "Save",
@@ -254,16 +288,16 @@ window.__ModuleLoader__.load({
254
288
  testFail: "Connection failed: {message}",
255
289
  configNotWritable: "Settings service unavailable — edit cordis.yml instead.",
256
290
  mediaHost: "Media base URL",
257
- 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.",
291
+ 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.",
258
292
  comfyuiDirs: "ComfyUI directories",
259
- 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.",
293
+ comfyuiDirsHint: "ComfyUI install roots on this machine (multiple allowed: mapped/multiple/portable installs). The agent uses them to locate models, workflows and voice libraries.",
260
294
  comfyuiDirAdd: "+ Add directory",
261
295
  comfyuiDirRemove: "Remove this directory",
262
296
  comfyuiDirPlaceholder: "e.g. D:\\ComfyUI",
263
297
  skillsDir: "Skill pack directory",
264
- skillsDirHint: "Where skill packs live (each workflow SKILL.md and its reference files). Empty means skills/ under the plugin data directory. An absolute path keeps these documents on another drive, in a synced folder, or under version control; a relative path is ignored. Changing this does not move existing packs — move the folders yourself. Currently in use: {current}",
298
+ 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}",
265
299
  language: "Language",
266
- languageHint: "Switch the plugin UI language (中文 / English). The page reloads after switching.",
300
+ languageHint: "UI language; the page reloads after switching.",
267
301
  cardGenerating: "ComfyUI generating…",
268
302
  cardBackground: "Background job",
269
303
  cardBackgroundDone: "Completed",
@@ -280,6 +314,7 @@ window.__ModuleLoader__.load({
280
314
  cardMode: "Mode",
281
315
  cardListed: "Listed {runs} runnable workflows, {graphs} graph workflows",
282
316
  panelTitle: "ComfyUI Panel",
317
+ panelReset: "Reset position & size",
283
318
  close: "Close",
284
319
  lbPrev: "Previous image",
285
320
  lbNext: "Next image",
@@ -289,6 +324,10 @@ window.__ModuleLoader__.load({
289
324
  error: "Error",
290
325
  refresh: "Refresh",
291
326
  ours: "ours",
327
+ connChecking: "Checking the ComfyUI connection…",
328
+ connOk: "Connected · ComfyUI {version} · {ms} ms",
329
+ connFailTitle: "Cannot reach ComfyUI",
330
+ connFailBody: "The backend could not reach ComfyUI ({message}). Start the local ComfyUI and make sure the backend can see its port; for a remote server, check the URL in settings and run \"Test connection\".",
292
331
  wfAdd: "New workflow",
293
332
  wfName: "Name",
294
333
  wfDesc: "Overview",
@@ -359,7 +398,34 @@ window.__ModuleLoader__.load({
359
398
  wfEmpty: "No workflows detected — create one via New, Import, or Extract.",
360
399
  wfLibrarySection: "API workflows",
361
400
  wfRefresh: "Refresh",
362
- wfImportFile: "Import file",
401
+ wfImportFile: "Import JSON",
402
+ exportPresets: "Export presets",
403
+ importPresets: "Import presets",
404
+ transferExportTitle: "Export workflow presets",
405
+ transferImportTitle: "Import workflow presets",
406
+ transferExportHint: "{total} exportable workflows, {selected} selected. Parameters and skill packs are included.",
407
+ transferSelectAll: "Select all",
408
+ transferSelectNone: "Select none",
409
+ transferExportRun: "Export ({n})",
410
+ transferImportRun: "Import ({n})",
411
+ transferExporting: "Exporting…",
412
+ transferImporting: "Importing…",
413
+ transferExportDone: "Exported {n} workflows: {names} (the browser is downloading the package)",
414
+ transferExportFile: "File {file} ({size} KB)",
415
+ transferExportWarnings: "Export warnings: {warnings}",
416
+ transferSelectedList: "Will export: {names}",
417
+ transferExportEmpty: "No exportable workflows",
418
+ transferPickFile: "Click to choose, or drop a .zip preset package here",
419
+ transferPickHint: "Import creates new workflows — nothing in your library is overwritten",
420
+ transferPackageInfo: "{n} workflows in package · exported {date}",
421
+ transferParamBadge: "{n} params",
422
+ transferSkillBadge: "Skill pack · {n} files",
423
+ transferSkillRequired: "Skill pack · {n} files · required",
424
+ transferImportDone: "Imported {n} workflows",
425
+ transferImportedItem: "✓ {src} → {dst}",
426
+ transferImportFailedItem: "✗ {src}: {reason}",
427
+ transferImportNote: "Name clashes get an \"(imported)\" suffix",
428
+ transferFinish: "Done",
363
429
  wfComfyuiSection: "ComfyUI workflows",
364
430
  wfComfyuiHint: "Auto-detects workflows saved in ComfyUI.\nNote: these are UI graphs, not directly runnable — extract them into API workflows, or import an API workflow instead.",
365
431
  wfComfyuiEmpty: "No workflows detected on the ComfyUI server",
@@ -403,7 +469,7 @@ window.__ModuleLoader__.load({
403
469
  skillBadgeRequired: "Skill pack (required)",
404
470
  skillBadgeHint: "This workflow has a skill pack: the agent reads it before running.",
405
471
  skillTitle: "Skill pack",
406
- skillIntro: "A skill pack is a manual written for the agent: when to use this workflow, how to fill its key parameters, which stages go wrong. It never sits in the conversation — the agent sees only a one-line summary in comfyui_workflow list, loads the body when it picks this workflow, and opens a reference file only when the body points at one. Worth it for workflows with many parameters, real pitfalls, or a collection of style presets.",
472
+ 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.",
407
473
  skillEnable: "Create skill pack",
408
474
  skillDetach: "Detach (keep files)",
409
475
  skillDestroy: "Destroy pack",
@@ -411,11 +477,10 @@ window.__ModuleLoader__.load({
411
477
  skillDestroyConfirmButton: "Destroy",
412
478
  skillDropHint: "Drop files here to import",
413
479
  skillRequired: "Required before running",
414
- skillRequiredHint: "When checked, running this workflow is refused until the agent has loaded the pack.",
415
480
  skillDir: "Directory",
416
481
  skillSummary: "Summary",
417
482
  skillSummaryPlaceholder: "One line on what this workflow is for",
418
- skillSummaryHint: "This line appears in comfyui_workflow list; the agent uses it to decide whether to load the body. Keep it short.",
483
+ skillSummaryHint: "The agent uses it to decide whether to read the body — like the description field of a skill.",
419
484
  skillSave: "Save",
420
485
  skillSaved: "Saved",
421
486
  skillDirty: "Unsaved",
@@ -431,6 +496,9 @@ window.__ModuleLoader__.load({
431
496
  skillReveal: "📂 Open folder",
432
497
  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.",
433
498
  skillRevealed: "Opened",
499
+ skillRefresh: "Refresh",
500
+ skillRefreshHint: "Re-read the pack directory to show files added or replaced by the agent or the file manager.",
501
+ skillRefreshed: "Refreshed",
434
502
  skillNewDirPlaceholder: "Folder name, e.g. templates",
435
503
  skillNewDirHint: "A pack may hold any one-level sub-directory. Common ones: {presets}; custom names work too (letters, digits, CJK, underscore, dash).",
436
504
  skillDirCreated: "Folder created",
@@ -991,49 +1059,49 @@ window.__ModuleLoader__.load({
991
1059
  * and the session-header trigger are separate registrations in one bundle,
992
1060
  * so they coordinate through this module-level store plus useSyncExternalStore.
993
1061
  */
994
- const listeners = /* @__PURE__ */ new Set();
1062
+ const listeners$1 = /* @__PURE__ */ new Set();
995
1063
  let open = false;
996
1064
  let tab = "workflows";
997
1065
  let assetFilter = "";
998
- function emit() {
999
- for (const listener of listeners) listener();
1066
+ function emit$1() {
1067
+ for (const listener of listeners$1) listener();
1000
1068
  }
1001
1069
  const panelStore = {
1002
1070
  isOpen: () => open,
1003
1071
  toggle: () => {
1004
1072
  open = !open;
1005
- emit();
1073
+ emit$1();
1006
1074
  },
1007
1075
  open: () => {
1008
1076
  if (!open) {
1009
1077
  open = true;
1010
- emit();
1078
+ emit$1();
1011
1079
  }
1012
1080
  },
1013
1081
  close: () => {
1014
1082
  if (open) {
1015
1083
  open = false;
1016
- emit();
1084
+ emit$1();
1017
1085
  }
1018
1086
  },
1019
1087
  getTab: () => tab,
1020
1088
  setTab: (next) => {
1021
1089
  if (tab !== next) {
1022
1090
  tab = next;
1023
- emit();
1091
+ emit$1();
1024
1092
  }
1025
1093
  },
1026
1094
  getAssetFilter: () => assetFilter,
1027
1095
  setAssetFilter: (name) => {
1028
1096
  if (assetFilter !== name) {
1029
1097
  assetFilter = name;
1030
- emit();
1098
+ emit$1();
1031
1099
  }
1032
1100
  },
1033
1101
  subscribe(listener) {
1034
- listeners.add(listener);
1102
+ listeners$1.add(listener);
1035
1103
  return () => {
1036
- listeners.delete(listener);
1104
+ listeners$1.delete(listener);
1037
1105
  };
1038
1106
  }
1039
1107
  };
@@ -1046,10 +1114,132 @@ window.__ModuleLoader__.load({
1046
1114
  return (0, react.useSyncExternalStore)(panelStore.subscribe, panelStore.getTab, panelStore.getTab);
1047
1115
  }
1048
1116
  //#endregion
1117
+ //#region src/client/connection.tsx
1118
+ /**
1119
+ * Connection probe + reminder toast. Opening the panel used to give zero
1120
+ * feedback when ComfyUI was down: the workflows/queue tabs just stayed empty
1121
+ * with no explanation. The header trigger now fires a real backend probe
1122
+ * (POST /comfyui/test → the host connects to the configured ComfyUI server —
1123
+ * local port or remote URL alike); when it fails, the panel closes again —
1124
+ * the trigger must not stay highlighted against a dead backend — and a fixed
1125
+ * toast at the top of the page tells the user to start ComfyUI or fix the
1126
+ * server address. The probe never blocks the panel: it opens immediately,
1127
+ * the toast settles whenever the probe answers.
1128
+ */
1129
+ const listeners = /* @__PURE__ */ new Set();
1130
+ let state = { status: "hidden" };
1131
+ /** Dedupe concurrent probes (rapid trigger clicks). */
1132
+ let inFlight = null;
1133
+ /** Skip re-probing while a "connected" verdict is still fresh. */
1134
+ let lastOkAt = 0;
1135
+ /** The ok toast only shows on fail→ok transitions, never on healthy opens. */
1136
+ let hadFailure = false;
1137
+ let okHideTimer = null;
1138
+ function emit() {
1139
+ for (const listener of listeners) listener();
1140
+ }
1141
+ function setState(next) {
1142
+ state = next;
1143
+ emit();
1144
+ }
1145
+ function clearOkTimer() {
1146
+ if (okHideTimer !== null) {
1147
+ clearTimeout(okHideTimer);
1148
+ okHideTimer = null;
1149
+ }
1150
+ }
1151
+ /** Close button on the toast; also ends the transient ok feedback early. */
1152
+ function dismissConnectionToast() {
1153
+ clearOkTimer();
1154
+ if (state.status !== "hidden") setState({ status: "hidden" });
1155
+ }
1156
+ /**
1157
+ * Probe the ComfyUI server through the host and surface the result as toast
1158
+ * state. A success within the last 30s skips the round-trip entirely, so
1159
+ * healthy repeated panel opens do not hammer the server; failures always
1160
+ * re-probe on the next open (the user may have just started ComfyUI).
1161
+ */
1162
+ function probeConnection() {
1163
+ if (inFlight !== null) return inFlight;
1164
+ if (Date.now() - lastOkAt < 3e4) return Promise.resolve();
1165
+ if (state.status !== "fail") {
1166
+ clearOkTimer();
1167
+ setState({ status: "probing" });
1168
+ }
1169
+ inFlight = (async () => {
1170
+ try {
1171
+ const payload = await postJson("/comfyui/test", {});
1172
+ if (payload.ok === true) {
1173
+ lastOkAt = Date.now();
1174
+ const recovered = hadFailure;
1175
+ hadFailure = false;
1176
+ if (recovered) {
1177
+ setState({
1178
+ status: "ok",
1179
+ version: payload.version,
1180
+ latencyMs: payload.latencyMs
1181
+ });
1182
+ clearOkTimer();
1183
+ okHideTimer = setTimeout(dismissConnectionToast, 4e3);
1184
+ } else setState({ status: "hidden" });
1185
+ } else {
1186
+ hadFailure = true;
1187
+ setState({
1188
+ status: "fail",
1189
+ message: payload.error ?? "unknown error"
1190
+ });
1191
+ panelStore.close();
1192
+ }
1193
+ } catch (error) {
1194
+ hadFailure = true;
1195
+ setState({
1196
+ status: "fail",
1197
+ message: error instanceof Error ? error.message : String(error)
1198
+ });
1199
+ panelStore.close();
1200
+ } finally {
1201
+ inFlight = null;
1202
+ }
1203
+ })();
1204
+ return inFlight;
1205
+ }
1206
+ function getConnState() {
1207
+ return state;
1208
+ }
1209
+ function subscribe(listener) {
1210
+ listeners.add(listener);
1211
+ return () => {
1212
+ listeners.delete(listener);
1213
+ };
1214
+ }
1215
+ /** Fixed top-center status toast; mounted next to the header trigger so it
1216
+ * renders (and can be dismissed) even when the panel is closed again. The
1217
+ * probing variant fades in after a short CSS delay, so a fast successful
1218
+ * probe never paints anything. */
1219
+ function ConnectionToast({ t }) {
1220
+ const conn = (0, react.useSyncExternalStore)(subscribe, getConnState, getConnState);
1221
+ if (conn.status === "hidden") return null;
1222
+ return (0, react.createElement)("div", {
1223
+ className: `dsc-conn-toast dsc-conn-toast--${conn.status}`,
1224
+ role: "status"
1225
+ }, (0, react.createElement)("span", {
1226
+ className: "dsc-conn-toast-dot",
1227
+ "aria-hidden": true
1228
+ }), (0, react.createElement)("div", { className: "dsc-conn-toast-body" }, conn.status === "fail" ? (0, react.createElement)("div", { className: "dsc-conn-toast-body-inner" }, (0, react.createElement)("div", { className: "dsc-conn-toast-title" }, t("connFailTitle")), (0, react.createElement)("div", { className: "dsc-conn-toast-text" }, t("connFailBody", { message: conn.message ?? "" }))) : (0, react.createElement)("div", { className: "dsc-conn-toast-title" }, conn.status === "probing" ? t("connChecking") : t("connOk", {
1229
+ version: conn.version ?? "unknown",
1230
+ ms: conn.latencyMs ?? 0
1231
+ }))), (0, react.createElement)("button", {
1232
+ className: "dsc-conn-toast-close",
1233
+ "aria-label": t("close"),
1234
+ onClick: dismissConnectionToast
1235
+ }, "✕"));
1236
+ }
1237
+ //#endregion
1049
1238
  //#region src/client/trigger.tsx
1050
1239
  /**
1051
1240
  * The session-header trigger (conversation.session.header.actions, id
1052
- * 'comfyui'): a labelled button that toggles the ComfyUI panel.
1241
+ * 'comfyui'): a labelled button that toggles the ComfyUI panel, plus the
1242
+ * connection reminder toast shown when the backend cannot reach ComfyUI.
1053
1243
  */
1054
1244
  /**
1055
1245
  * ComfyUI brand glyph (mono), inlined from @lobehub/icons-static-svg
@@ -1069,16 +1259,22 @@ window.__ModuleLoader__.load({
1069
1259
  "aria-hidden": true
1070
1260
  }, (0, react.createElement)("path", { d: "M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" }));
1071
1261
  }
1072
- /** Header-action button toggling the ComfyUI panel. */
1262
+ /** Header-action button toggling the ComfyUI panel. Opening it also fires a
1263
+ * backend connectivity probe: when ComfyUI is down, a reminder toast explains
1264
+ * why the panel stays empty instead of the click appearing to do nothing. */
1073
1265
  function ComfyUITrigger({ t }) {
1074
1266
  const open = usePanelOpen();
1075
- return (0, react.createElement)("button", {
1267
+ return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("button", {
1076
1268
  className: "dsc-trigger",
1077
1269
  title: t("panelTitle"),
1078
1270
  "aria-label": t("panelTitle"),
1079
1271
  "aria-pressed": open,
1080
- onClick: () => panelStore.toggle()
1081
- }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle")));
1272
+ onClick: () => {
1273
+ const opening = !panelStore.isOpen();
1274
+ panelStore.toggle();
1275
+ if (opening) probeConnection();
1276
+ }
1277
+ }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle"))), (0, react.createElement)(ConnectionToast, { t }));
1082
1278
  }
1083
1279
  //#endregion
1084
1280
  //#region src/client/panel.tsx
@@ -1110,6 +1306,41 @@ window.__ModuleLoader__.load({
1110
1306
  localStorage.setItem(GEOM_KEY, JSON.stringify(geom));
1111
1307
  } catch {}
1112
1308
  }
1309
+ /** Default panel width from styles.ts (`.dsc-panel` width var), used only to
1310
+ * estimate the panel extent while clamping a saved position. */
1311
+ const PANEL_WIDTH_FALLBACK = 400;
1312
+ /** Approx. height of the drag-handle band (`.dsc-panel-head`). */
1313
+ const HEAD_BAND = 48;
1314
+ /** Minimum margin kept between the panel and the viewport edge. */
1315
+ const EDGE = 8;
1316
+ /**
1317
+ * Clamp a top-left corner so the drag-handle band always stays inside the
1318
+ * viewport. The failure this prevents: once the header is pushed past the top
1319
+ * edge (or a saved position from a larger monitor no longer fits), there is no
1320
+ * grab surface left and the panel cannot be moved back at all.
1321
+ */
1322
+ function clampPos(x, y, width, vw, vh) {
1323
+ const maxX = Math.max(EDGE, vw - width - EDGE);
1324
+ const maxY = Math.max(EDGE, vh - HEAD_BAND - EDGE);
1325
+ return {
1326
+ x: Math.min(Math.max(x, EDGE), maxX),
1327
+ y: Math.min(Math.max(y, EDGE), maxY)
1328
+ };
1329
+ }
1330
+ /** Snap persisted geometry back within reach of the cursor. Geometry that
1331
+ * never carried an explicit position (the CSS-anchored default) is returned
1332
+ * unchanged; geometry already inside the safe area keeps its object identity
1333
+ * so callers can skip pointless re-renders. */
1334
+ function healGeom(geom, vw, vh) {
1335
+ if (geom.x === void 0 || geom.y === void 0) return geom;
1336
+ const pos = clampPos(geom.x, geom.y, geom.width ?? PANEL_WIDTH_FALLBACK, vw, vh);
1337
+ if (pos.x === geom.x && pos.y === geom.y) return geom;
1338
+ return {
1339
+ ...geom,
1340
+ x: pos.x,
1341
+ y: pos.y
1342
+ };
1343
+ }
1113
1344
  /** Fallback suggestions when the config route has not answered yet; the
1114
1345
  * authoritative list comes from the host (`presetDirs`). */
1115
1346
  const SKILL_FALLBACK_DIRS = [
@@ -1897,7 +2128,7 @@ window.__ModuleLoader__.load({
1897
2128
  function ComfyUIPanel({ t }) {
1898
2129
  const open = usePanelOpen();
1899
2130
  const tab = usePanelTab();
1900
- const [geom, setGeom] = (0, react.useState)(loadGeom);
2131
+ const [geom, setGeom] = (0, react.useState)(() => healGeom(loadGeom(), window.innerWidth, window.innerHeight));
1901
2132
  const [dragging, setDragging] = (0, react.useState)(null);
1902
2133
  const [lightbox, setLightbox] = (0, react.useState)(null);
1903
2134
  const panelRef = (0, react.useRef)(null);
@@ -1908,9 +2139,18 @@ window.__ModuleLoader__.load({
1908
2139
  index
1909
2140
  });
1910
2141
  (0, react.useEffect)(() => {
1911
- if (open) return;
2142
+ if (open) {
2143
+ setGeom((prev) => healGeom(prev, window.innerWidth, window.innerHeight));
2144
+ return;
2145
+ }
1912
2146
  saveGeom(geom);
1913
2147
  }, [open]);
2148
+ (0, react.useEffect)(() => {
2149
+ if (!open) return;
2150
+ const onResize = () => setGeom((prev) => healGeom(prev, window.innerWidth, window.innerHeight));
2151
+ window.addEventListener("resize", onResize);
2152
+ return () => window.removeEventListener("resize", onResize);
2153
+ }, [open]);
1914
2154
  const startDrag = (event, mode) => {
1915
2155
  if (event.button !== 0) return;
1916
2156
  if (mode === "move") {
@@ -1934,12 +2174,14 @@ window.__ModuleLoader__.load({
1934
2174
  const moveDrag = (event) => {
1935
2175
  const d = dragRef.current;
1936
2176
  if (d === null) return;
1937
- if (dragging === "move") setGeom((prev) => ({
1938
- ...prev,
1939
- x: Math.round(d.origX + event.clientX - d.startX),
1940
- y: Math.round(d.origY + event.clientY - d.startY)
1941
- }));
1942
- else if (dragging === "resize") {
2177
+ if (dragging === "move") {
2178
+ 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);
2179
+ setGeom((prev) => ({
2180
+ ...prev,
2181
+ x: pos.x,
2182
+ y: pos.y
2183
+ }));
2184
+ } else if (dragging === "resize") {
1943
2185
  const width = Math.max(300, Math.min(720, d.width + event.clientX - d.startX));
1944
2186
  const height = Math.max(200, Math.min(Math.max(300, window.innerHeight - 40), d.height + event.clientY - d.startY));
1945
2187
  setGeom((prev) => ({
@@ -1978,11 +2220,22 @@ window.__ModuleLoader__.load({
1978
2220
  onPointerMove: (event) => moveDrag(event),
1979
2221
  onPointerUp: () => endDrag(),
1980
2222
  onPointerCancel: () => endDrag()
1981
- }, (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", {
2223
+ }, (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", {
2224
+ className: "dsc-panel-reset",
2225
+ type: "button",
2226
+ title: t("panelReset"),
2227
+ "aria-label": t("panelReset"),
2228
+ onClick: () => {
2229
+ setGeom({});
2230
+ try {
2231
+ localStorage.removeItem(GEOM_KEY);
2232
+ } catch {}
2233
+ }
2234
+ }, "↺"), (0, react.createElement)("button", {
1982
2235
  className: "dsc-panel-close",
1983
2236
  "aria-label": t("close"),
1984
2237
  onClick: () => panelStore.close()
1985
- }, "✕")), (0, react.createElement)("div", { className: "dsc-tabs" }, (0, react.createElement)(TabButton, {
2238
+ }, "✕"))), (0, react.createElement)("div", { className: "dsc-tabs" }, (0, react.createElement)(TabButton, {
1986
2239
  t,
1987
2240
  active: tab === "workflows",
1988
2241
  label: t("tabWorkflows"),
@@ -2470,6 +2723,28 @@ window.__ModuleLoader__.load({
2470
2723
  setError(cause instanceof Error ? cause.message : String(cause));
2471
2724
  }
2472
2725
  }, [props.workflow.id, openFile]);
2726
+ /** Re-read the pack listing and — only when nothing is unsaved — the file
2727
+ * currently open, so files added or replaced outside this panel (the agent
2728
+ * writing via comfyui_skill, or the file manager after `打开目录`) show up
2729
+ * without leaving and re-entering the view. */
2730
+ const refreshFiles = async () => {
2731
+ setBusy(true);
2732
+ setNotice(null);
2733
+ try {
2734
+ const data = await getJson(`/comfyui/workflows/skill?id=${encodeURIComponent(props.workflow.id)}`);
2735
+ setEnabled(data.enabled === true);
2736
+ setPack(data.pack ?? null);
2737
+ if (Array.isArray(data.presetDirs) && data.presetDirs.length > 0) setPresetDirs(data.presetDirs);
2738
+ setError(null);
2739
+ props.onChanged();
2740
+ if (data.enabled === true && !dirty) await openFile(active);
2741
+ setNotice(t("skillRefreshed"));
2742
+ } catch (cause) {
2743
+ setError(cause instanceof Error ? cause.message : String(cause));
2744
+ } finally {
2745
+ setBusy(false);
2746
+ }
2747
+ };
2473
2748
  (0, react.useEffect)(() => {
2474
2749
  refresh();
2475
2750
  }, [refresh]);
@@ -2634,6 +2909,12 @@ window.__ModuleLoader__.load({
2634
2909
  /** Where a new file may go: the pack's own directories first, then the
2635
2910
  * presets it has not used yet. */
2636
2911
  const targetDirs = [...packDirs, ...presetDirs.filter((dir) => !packDirs.includes(dir))];
2912
+ /** One row in the pack file list; clicking opens it for editing. */
2913
+ const fileButton = (file) => (0, react.createElement)("button", {
2914
+ key: file.path,
2915
+ className: `dsc-skill-file${file.path === active ? " dsc-skill-file--active" : ""}`,
2916
+ onClick: () => selectFile(file.path)
2917
+ }, (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)));
2637
2918
  const fileList = (0, react.createElement)("div", {
2638
2919
  className: "dsc-skill-files",
2639
2920
  onDragOver: (event) => event.preventDefault(),
@@ -2677,7 +2958,12 @@ window.__ModuleLoader__.load({
2677
2958
  disabled: busy,
2678
2959
  title: t("skillRevealHint"),
2679
2960
  onClick: () => void revealDir()
2680
- }, t("skillReveal"))), creatingDir === null ? null : (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("input", {
2961
+ }, t("skillReveal")), (0, react.createElement)("button", {
2962
+ className: "dsc-btn dsc-btn--sm",
2963
+ disabled: busy,
2964
+ title: t("skillRefreshHint"),
2965
+ onClick: () => void refreshFiles()
2966
+ }, t("skillRefresh"))), creatingDir === null ? null : (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("input", {
2681
2967
  className: "dsc-input dsc-input--sm",
2682
2968
  value: creatingDir,
2683
2969
  placeholder: t("skillNewDirPlaceholder"),
@@ -2695,14 +2981,14 @@ window.__ModuleLoader__.load({
2695
2981
  }, t("skillCreate")), (0, react.createElement)("button", {
2696
2982
  className: "dsc-btn dsc-btn--sm",
2697
2983
  onClick: () => setCreatingDir(null)
2698
- }, t("cancel")))), (0, react.createElement)("div", { className: "dsc-skill-drop-hint" }, t("skillDropHint")), buckets.map((bucket) => bucket.items.length === 0 && bucket.label !== "" ? null : (0, react.createElement)("div", {
2699
- key: bucket.label === "" ? "." : bucket.label,
2984
+ }, 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", {
2985
+ key: ".",
2700
2986
  className: "dsc-skill-bucket"
2701
- }, bucket.label !== "" ? (0, react.createElement)("div", { className: "dsc-skill-bucket-name" }, `${bucket.label}/`) : null, bucket.items.map((file) => (0, react.createElement)("button", {
2702
- key: file.path,
2703
- className: `dsc-skill-file${file.path === active ? " dsc-skill-file--active" : ""}`,
2704
- onClick: () => selectFile(file.path)
2705
- }, (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)))))), creating === null ? null : (0, react.createElement)("div", { className: "dsc-skill-create" }, (0, react.createElement)("select", {
2987
+ }, bucket.items.map(fileButton)) : (0, react.createElement)("details", {
2988
+ key: bucket.label,
2989
+ className: "dsc-fold dsc-skill-fold",
2990
+ open: true
2991
+ }, (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", {
2706
2992
  className: "dsc-input dsc-input--sm",
2707
2993
  value: creating.bucket,
2708
2994
  onChange: (event) => setCreating({
@@ -2818,7 +3104,7 @@ window.__ModuleLoader__.load({
2818
3104
  action: "require",
2819
3105
  required: event.target.checked
2820
3106
  })
2821
- }), t("skillRequired")), (0, react.createElement)("span", { className: "dsc-hint" }, t("skillRequiredHint"))), pack !== null ? (0, react.createElement)("div", {
3107
+ }), t("skillRequired"))), pack !== null ? (0, react.createElement)("div", {
2822
3108
  className: "dsc-skill-dir",
2823
3109
  title: pack.dir
2824
3110
  }, `${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", {
@@ -2875,6 +3161,8 @@ window.__ModuleLoader__.load({
2875
3161
  const [busy, setBusy] = (0, react.useState)(false);
2876
3162
  const [viewing, setViewing] = (0, react.useState)(null);
2877
3163
  const [extract, setExtract] = (0, react.useState)(null);
3164
+ /** Open transfer dialog: 'export' | 'import', null = closed. */
3165
+ const [transfer, setTransfer] = (0, react.useState)(null);
2878
3166
  const [tagFilter, setTagFilter] = (0, react.useState)(null);
2879
3167
  const [skillFor, setSkillFor] = (0, react.useState)(null);
2880
3168
  /** A delete waiting on the user's answer about the workflow's skill pack. */
@@ -3078,6 +3366,25 @@ window.__ModuleLoader__.load({
3078
3366
  onRun: (state) => runExtract(state),
3079
3367
  onClose: () => setExtract(null)
3080
3368
  });
3369
+ if (transfer === "export") return (0, react.createElement)(ExportDialog, {
3370
+ t,
3371
+ workflows: list ?? [],
3372
+ onClose: () => setTransfer(null),
3373
+ onDone: (notice) => {
3374
+ setTransfer(null);
3375
+ setNotice(notice);
3376
+ load();
3377
+ }
3378
+ });
3379
+ if (transfer === "import") return (0, react.createElement)(ImportDialog, {
3380
+ t,
3381
+ onClose: () => setTransfer(null),
3382
+ onDone: (notice) => {
3383
+ setTransfer(null);
3384
+ setNotice(notice);
3385
+ load();
3386
+ }
3387
+ });
3081
3388
  if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
3082
3389
  t,
3083
3390
  message: error
@@ -3286,6 +3593,20 @@ window.__ModuleLoader__.load({
3286
3593
  event.target.value = "";
3287
3594
  }
3288
3595
  })), (0, react.createElement)("button", {
3596
+ className: "dsc-btn",
3597
+ disabled: busy,
3598
+ onClick: () => {
3599
+ setError(null);
3600
+ setTransfer("export");
3601
+ }
3602
+ }, t("exportPresets")), (0, react.createElement)("button", {
3603
+ className: "dsc-btn",
3604
+ disabled: busy,
3605
+ onClick: () => {
3606
+ setError(null);
3607
+ setTransfer("import");
3608
+ }
3609
+ }, t("importPresets")), (0, react.createElement)("button", {
3289
3610
  className: "dsc-btn",
3290
3611
  disabled: busy,
3291
3612
  onClick: () => void load()
@@ -3368,6 +3689,348 @@ window.__ModuleLoader__.load({
3368
3689
  onClick: onClose
3369
3690
  }, t("wfCancel")))));
3370
3691
  }
3692
+ /** Shared checkbox row for the transfer dialogs: name, badges, description. */
3693
+ function TransferItem(props) {
3694
+ return (0, react.createElement)("label", { className: "dsc-transfer-item" }, (0, react.createElement)("input", {
3695
+ type: "checkbox",
3696
+ checked: props.checked,
3697
+ onChange: props.onToggle
3698
+ }), (0, react.createElement)("span", {
3699
+ className: "dsc-transfer-item-name",
3700
+ title: props.name
3701
+ }, props.name), props.paramBadge !== null ? (0, react.createElement)("span", { className: "dsc-badge" }, props.paramBadge) : null, props.skillBadge !== null ? (0, react.createElement)("span", { className: "dsc-badge dsc-badge--ok" }, props.skillBadge) : null, props.description !== "" ? (0, react.createElement)("span", {
3702
+ className: "dsc-transfer-item-desc",
3703
+ title: props.description
3704
+ }, props.description) : null, props.warnings.length > 0 ? (0, react.createElement)("span", {
3705
+ className: "dsc-transfer-item-desc dsc-danger-text",
3706
+ title: props.warnings.join(";")
3707
+ }, props.warnings.join(";")) : null);
3708
+ }
3709
+ /** While a transfer dialog is open, swallow drag events aimed at the host
3710
+ * page. The conversation shell attaches dropped files to the chat with
3711
+ * document-level bubble listeners, so dragging a preset toward the dialog
3712
+ * also dropped it into the conversation. Window-capture listeners run before
3713
+ * any document-bubble handler and shield everything OUTSIDE the dialog;
3714
+ * events landing inside the dialog pass through — the dialog box itself must
3715
+ * stopPropagation() them at React's dispatch root, or they bubble on to the
3716
+ * document and the shell attaches the file anyway. */
3717
+ function DragGuard() {
3718
+ (0, react.useEffect)(() => {
3719
+ const guard = (event) => {
3720
+ if (event.target instanceof Element && event.target.closest(".dsc-transfer") !== null) return;
3721
+ event.preventDefault();
3722
+ event.stopPropagation();
3723
+ };
3724
+ for (const type of [
3725
+ "dragenter",
3726
+ "dragover",
3727
+ "drop"
3728
+ ]) window.addEventListener(type, guard, true);
3729
+ return () => {
3730
+ for (const type of [
3731
+ "dragenter",
3732
+ "dragover",
3733
+ "drop"
3734
+ ]) window.removeEventListener(type, guard, true);
3735
+ };
3736
+ }, []);
3737
+ return null;
3738
+ }
3739
+ /** Export dialog: pick library workflows (checkboxes + select-all, all
3740
+ * pre-selected) and download one preset package. Packaging happens host-side;
3741
+ * the dialog lists, selects, and submits a native form download. */
3742
+ function ExportDialog(props) {
3743
+ const { t, workflows } = props;
3744
+ const [selected, setSelected] = (0, react.useState)(() => new Set(workflows.map((workflow) => workflow.id)));
3745
+ const [busy, setBusy] = (0, react.useState)(false);
3746
+ const toggle = (id) => {
3747
+ setSelected((prev) => {
3748
+ const next = new Set(prev);
3749
+ if (next.has(id)) next.delete(id);
3750
+ else next.add(id);
3751
+ return next;
3752
+ });
3753
+ };
3754
+ const allSelected = workflows.length > 0 && selected.size === workflows.length;
3755
+ const toggleAll = () => {
3756
+ setSelected(allSelected ? /* @__PURE__ */ new Set() : new Set(workflows.map((workflow) => workflow.id)));
3757
+ };
3758
+ /** Download natively: a hidden form GETs `/comfyui/workflows/export/<file
3759
+ * name>?ids=…` and the server answers with `content-disposition:
3760
+ * attachment`, so the browser — not JS blob plumbing (fetch → blob →
3761
+ * objectURL → revoke, which one environment turned into a 0-byte file) —
3762
+ * owns the transfer. The page never navigates because the response is an
3763
+ * attachment.
3764
+ *
3765
+ * GET with the name in the path is deliberate: this setup's download
3766
+ * manager mangles POST downloads (renames them, or re-fetches the URL on
3767
+ * its own), which is where the phantom "skills sometimes missing" zips
3768
+ * came from. A GET is the one verb every manager handles correctly — and
3769
+ * since the name rides in the URL, saved name == receipt name even under
3770
+ * interception, and a manager's own re-fetch still gets a genuine export
3771
+ * of the same ids (the route is a pure read).
3772
+ *
3773
+ * The form response is invisible to JS, so the notice is fed from
3774
+ * `export/last` — the server's record of what it ACTUALLY packaged — not
3775
+ * from the checkboxes. */
3776
+ const exportSelected = () => {
3777
+ if (busy) return;
3778
+ setBusy(true);
3779
+ (async () => {
3780
+ let previousAt = null;
3781
+ try {
3782
+ previousAt = (await getJson("/comfyui/workflows/export/last")).last?.at ?? null;
3783
+ } catch {}
3784
+ const now = /* @__PURE__ */ new Date();
3785
+ const p = (n, width = 2) => String(n).padStart(width, "0");
3786
+ const filename = `dsh-comfyui-presets-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}-${p(now.getMilliseconds(), 3)}-${Math.random().toString(36).slice(2, 8)}.zip`;
3787
+ const form = document.createElement("form");
3788
+ form.method = "GET";
3789
+ form.action = `/comfyui/workflows/export/${filename}`;
3790
+ form.style.display = "none";
3791
+ for (const id of selected) {
3792
+ const input = document.createElement("input");
3793
+ input.type = "hidden";
3794
+ input.name = "ids";
3795
+ input.value = id;
3796
+ form.append(input);
3797
+ }
3798
+ document.body.append(form);
3799
+ form.submit();
3800
+ form.remove();
3801
+ let last = null;
3802
+ for (let attempt = 0; attempt < 10 && last === null; attempt++) {
3803
+ await new Promise((resolve) => window.setTimeout(resolve, 350));
3804
+ try {
3805
+ const payload = await getJson("/comfyui/workflows/export/last");
3806
+ if (payload.last !== null && payload.last.at !== previousAt) last = payload.last;
3807
+ } catch {}
3808
+ }
3809
+ const names = (last?.names ?? selectedNames()).join("、");
3810
+ let notice = t("transferExportDone", {
3811
+ n: last?.count ?? selected.size,
3812
+ names
3813
+ });
3814
+ if (last !== null) notice += " " + t("transferExportFile", {
3815
+ file: last.filename,
3816
+ size: Math.max(1, Math.round(last.size / 1024))
3817
+ });
3818
+ if (last !== null && last.warnings.length > 0) notice += " " + t("transferExportWarnings", { warnings: last.warnings.join(";") });
3819
+ props.onDone(notice);
3820
+ })();
3821
+ };
3822
+ /** Names behind the current selection, dialog order, with pack-bearing
3823
+ * entries annotated. Shown next to the submit button so "what will be
3824
+ * packaged — and will it carry skills" is readable at the decision point:
3825
+ * a selection that happens to exclude the one pack-bearing workflow looks
3826
+ * exactly like "skills export unreliably" from the zip alone. */
3827
+ const selectedNames = () => workflows.filter((workflow) => selected.has(workflow.id)).map((workflow) => workflow.skillDir !== void 0 ? `${workflow.name} (${t("skillBadge")})` : workflow.name);
3828
+ return (0, react.createElement)("div", {
3829
+ className: "dsc-picker-overlay",
3830
+ onClick: props.onClose
3831
+ }, (0, react.createElement)(DragGuard), (0, react.createElement)("div", {
3832
+ className: "dsc-transfer",
3833
+ onClick: (event) => event.stopPropagation(),
3834
+ onDragEnter: (event) => {
3835
+ event.preventDefault();
3836
+ event.stopPropagation();
3837
+ },
3838
+ onDragOver: (event) => {
3839
+ event.preventDefault();
3840
+ event.stopPropagation();
3841
+ },
3842
+ onDrop: (event) => {
3843
+ event.preventDefault();
3844
+ event.stopPropagation();
3845
+ }
3846
+ }, (0, react.createElement)("div", { className: "dsc-transfer-head" }, (0, react.createElement)("span", { className: "dsc-transfer-title" }, t("transferExportTitle")), (0, react.createElement)("button", {
3847
+ className: "dsc-panel-close",
3848
+ "aria-label": t("close"),
3849
+ onClick: props.onClose
3850
+ }, "✕")), workflows.length === 0 ? (0, react.createElement)("div", { className: "dsc-transfer-empty" }, t("transferExportEmpty")) : (0, react.createElement)("div", { className: "dsc-transfer-body" }, (0, react.createElement)("div", { className: "dsc-transfer-bar" }, (0, react.createElement)("span", { className: "dsc-hint" }, t("transferExportHint", {
3851
+ total: workflows.length,
3852
+ selected: selected.size
3853
+ })), (0, react.createElement)("button", {
3854
+ className: "dsc-btn dsc-btn--sm",
3855
+ onClick: toggleAll
3856
+ }, allSelected ? t("transferSelectNone") : t("transferSelectAll"))), (0, react.createElement)("div", { className: "dsc-transfer-list" }, workflows.map((workflow) => (0, react.createElement)(TransferItem, {
3857
+ key: workflow.id,
3858
+ name: workflow.name,
3859
+ description: workflow.description,
3860
+ paramBadge: (workflow.parameters ?? []).length > 0 ? t("transferParamBadge", { n: (workflow.parameters ?? []).length }) : null,
3861
+ skillBadge: workflow.skillDir !== void 0 ? t("skillBadge") : null,
3862
+ warnings: [],
3863
+ checked: selected.has(workflow.id),
3864
+ onToggle: () => toggle(workflow.id)
3865
+ })))), selected.size > 0 ? (0, react.createElement)("div", { className: "dsc-transfer-note" }, (0, react.createElement)("span", {
3866
+ className: "dsc-hint",
3867
+ title: selectedNames().join("、")
3868
+ }, t("transferSelectedList", { names: selectedNames().join("、") }))) : null, (0, react.createElement)("div", { className: "dsc-transfer-actions" }, workflows.length === 0 ? null : (0, react.createElement)("button", {
3869
+ className: "dsc-btn dsc-btn--primary",
3870
+ disabled: busy || selected.size === 0,
3871
+ onClick: () => exportSelected()
3872
+ }, busy ? t("transferExporting") : t("transferExportRun", { n: selected.size })), (0, react.createElement)("button", {
3873
+ className: "dsc-btn",
3874
+ disabled: busy,
3875
+ onClick: props.onClose
3876
+ }, t("cancel")))));
3877
+ }
3878
+ /** Import dialog: choose a preset package (file picker or drag-drop), see
3879
+ * what it offers, then import the selection as NEW workflows. The file is
3880
+ * POSTed twice by design — analyze and apply parse the same bytes, so the
3881
+ * host needs no temp-file staging or cleanup. */
3882
+ function ImportDialog(props) {
3883
+ const { t } = props;
3884
+ const [file, setFile] = (0, react.useState)(null);
3885
+ const [analysis, setAnalysis] = (0, react.useState)(null);
3886
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
3887
+ const [busy, setBusy] = (0, react.useState)(false);
3888
+ const [error, setError] = (0, react.useState)(null);
3889
+ const [results, setResults] = (0, react.useState)(null);
3890
+ const [imported, setImported] = (0, react.useState)(0);
3891
+ const [dragOver, setDragOver] = (0, react.useState)(false);
3892
+ /** Phase 1: upload the package for analysis (no server-side writes). */
3893
+ const analyze = async (picked) => {
3894
+ setBusy(true);
3895
+ setError(null);
3896
+ try {
3897
+ const data = await postRaw("/comfyui/workflows/import/analyze", await picked.arrayBuffer());
3898
+ if (data.ok !== true || data.analysis === void 0) throw new Error(data.error ?? "failed to analyze");
3899
+ setFile(picked);
3900
+ setAnalysis(data.analysis);
3901
+ setSelected(new Set(data.analysis.workflows.map((entry) => entry.index)));
3902
+ } catch (cause) {
3903
+ setError(cause instanceof Error ? cause.message : String(cause));
3904
+ } finally {
3905
+ setBusy(false);
3906
+ }
3907
+ };
3908
+ const toggle = (index) => {
3909
+ setSelected((prev) => {
3910
+ const next = new Set(prev);
3911
+ if (next.has(index)) next.delete(index);
3912
+ else next.add(index);
3913
+ return next;
3914
+ });
3915
+ };
3916
+ const allSelected = analysis !== null && analysis.workflows.length > 0 && selected.size === analysis.workflows.length;
3917
+ const toggleAll = () => {
3918
+ if (analysis === null) return;
3919
+ setSelected(allSelected ? /* @__PURE__ */ new Set() : new Set(analysis.workflows.map((entry) => entry.index)));
3920
+ };
3921
+ /** Phase 2: re-upload the same bytes with the selection as manifest indexes. */
3922
+ const importSelected = async () => {
3923
+ if (file === null) return;
3924
+ setBusy(true);
3925
+ setError(null);
3926
+ try {
3927
+ const bytes = await file.arrayBuffer();
3928
+ const data = await postRaw(`/comfyui/workflows/import/apply?select=${[...selected].join(",")}`, bytes);
3929
+ if (data.ok !== true || data.results === void 0) throw new Error(data.error ?? "failed to import");
3930
+ setResults(data.results);
3931
+ setImported(data.imported ?? 0);
3932
+ } catch (cause) {
3933
+ setError(cause instanceof Error ? cause.message : String(cause));
3934
+ } finally {
3935
+ setBusy(false);
3936
+ }
3937
+ };
3938
+ const finish = () => {
3939
+ if (imported > 0) props.onDone(t("transferImportDone", { n: imported }));
3940
+ else props.onClose();
3941
+ };
3942
+ return (0, react.createElement)("div", {
3943
+ className: "dsc-picker-overlay",
3944
+ onClick: props.onClose
3945
+ }, (0, react.createElement)(DragGuard), (0, react.createElement)("div", {
3946
+ className: "dsc-transfer",
3947
+ onClick: (event) => event.stopPropagation(),
3948
+ onDragEnter: (event) => {
3949
+ event.preventDefault();
3950
+ event.stopPropagation();
3951
+ },
3952
+ onDragOver: (event) => {
3953
+ event.preventDefault();
3954
+ event.stopPropagation();
3955
+ },
3956
+ onDrop: (event) => {
3957
+ event.preventDefault();
3958
+ event.stopPropagation();
3959
+ }
3960
+ }, (0, react.createElement)("div", { className: "dsc-transfer-head" }, (0, react.createElement)("span", { className: "dsc-transfer-title" }, t("transferImportTitle")), (0, react.createElement)("button", {
3961
+ className: "dsc-panel-close",
3962
+ "aria-label": t("close"),
3963
+ onClick: props.onClose
3964
+ }, "✕")), (0, react.createElement)("div", { className: "dsc-transfer-body" }, results !== null ? (0, react.createElement)("div", { className: "dsc-transfer-list" }, results.map((outcome) => (0, react.createElement)("div", {
3965
+ key: outcome.index,
3966
+ className: `dsc-transfer-item dsc-transfer-item--static${outcome.ok ? " dsc-transfer-item--ok" : " dsc-transfer-item--fail"}`
3967
+ }, (0, react.createElement)("span", {
3968
+ className: "dsc-transfer-item-name",
3969
+ title: outcome.name
3970
+ }, outcome.ok ? t("transferImportedItem", {
3971
+ src: outcome.name,
3972
+ dst: outcome.newName ?? ""
3973
+ }) : t("transferImportFailedItem", {
3974
+ src: outcome.name,
3975
+ reason: outcome.error ?? ""
3976
+ })), outcome.warnings.length > 0 ? (0, react.createElement)("span", {
3977
+ className: "dsc-transfer-item-desc",
3978
+ title: outcome.warnings.join(";")
3979
+ }, outcome.warnings.join(";")) : null))) : analysis === null ? (0, react.createElement)("label", {
3980
+ className: `dsc-transfer-drop${dragOver ? " dsc-transfer-drop--over" : ""}`,
3981
+ onDragOver: (event) => {
3982
+ event.preventDefault();
3983
+ setDragOver(true);
3984
+ },
3985
+ onDragLeave: () => setDragOver(false),
3986
+ onDrop: (event) => {
3987
+ event.preventDefault();
3988
+ setDragOver(false);
3989
+ const dropped = event.dataTransfer?.files?.[0];
3990
+ if (dropped !== void 0) analyze(dropped);
3991
+ }
3992
+ }, (0, react.createElement)("input", {
3993
+ type: "file",
3994
+ accept: ".zip,application/zip,application/x-zip-compressed",
3995
+ style: { display: "none" },
3996
+ onChange: (event) => {
3997
+ const picked = event.target.files?.[0];
3998
+ if (picked !== void 0) analyze(picked);
3999
+ event.target.value = "";
4000
+ }
4001
+ }), busy ? t("transferImporting") : t("transferPickFile")) : (0, react.createElement)("div", { className: "dsc-transfer-body" }, (0, react.createElement)("div", { className: "dsc-transfer-bar" }, (0, react.createElement)("span", { className: "dsc-hint" }, t("transferPackageInfo", {
4002
+ n: analysis.workflows.length,
4003
+ date: formatTs(analysis.exportedAt)
4004
+ })), (0, react.createElement)("button", {
4005
+ className: "dsc-btn dsc-btn--sm",
4006
+ onClick: toggleAll
4007
+ }, allSelected ? t("transferSelectNone") : t("transferSelectAll"))), (0, react.createElement)("div", { className: "dsc-transfer-list" }, analysis.workflows.map((entry) => (0, react.createElement)(TransferItem, {
4008
+ key: entry.index,
4009
+ name: entry.name,
4010
+ description: entry.description,
4011
+ paramBadge: entry.paramCount > 0 ? t("transferParamBadge", { n: entry.paramCount }) : null,
4012
+ skillBadge: entry.skill !== null ? entry.skill.required ? t("transferSkillRequired", { n: entry.skill.fileCount }) : t("transferSkillBadge", { n: entry.skill.fileCount }) : null,
4013
+ warnings: entry.warnings,
4014
+ checked: selected.has(entry.index),
4015
+ onToggle: () => toggle(entry.index)
4016
+ })))), error !== null ? (0, react.createElement)(ErrorNote, {
4017
+ t,
4018
+ message: error
4019
+ }) : null, analysis !== null && results === null ? (0, react.createElement)("div", { className: "dsc-hint" }, t("transferImportNote")) : null, analysis === null && results === null ? (0, react.createElement)("div", { className: "dsc-hint" }, t("transferPickHint")) : null), (0, react.createElement)("div", { className: "dsc-transfer-actions" }, results !== null ? (0, react.createElement)("button", {
4020
+ className: "dsc-btn dsc-btn--primary",
4021
+ onClick: finish
4022
+ }, t("transferFinish")) : analysis !== null ? (0, react.createElement)("button", {
4023
+ className: "dsc-btn dsc-btn--primary",
4024
+ disabled: busy || selected.size === 0,
4025
+ onClick: () => {
4026
+ importSelected();
4027
+ }
4028
+ }, busy ? t("transferImporting") : t("transferImportRun", { n: selected.size })) : null, (0, react.createElement)("button", {
4029
+ className: "dsc-btn",
4030
+ disabled: busy,
4031
+ onClick: results !== null ? finish : props.onClose
4032
+ }, t("cancel")))));
4033
+ }
3371
4034
  /** Read-only inspection of a ComfyUI-side saved workflow: node list + JSON. */
3372
4035
  function WorkflowView(props) {
3373
4036
  const nodes = Array.isArray(props.graph.nodes) ? props.graph.nodes.filter((node) => node.mode !== 4 && typeof node.type === "string" && node.type !== "") : [];
@@ -3894,6 +4557,40 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3894
4557
  .dsc-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); border-color: var(--dsw-alias-border-l1); }
3895
4558
  .dsc-trigger[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
3896
4559
  .dsc-trigger-glyph { font-size: 13px; line-height: 1; }
4560
+ /* --- connection reminder toast: fixed at the top of the page, mounted by the
4561
+ header trigger when the backend probe cannot reach ComfyUI. The probing
4562
+ state fades in after a short delay so a fast successful probe never paints;
4563
+ the fail state is sticky until dismissed or a later probe recovers. --- */
4564
+ .dsc-conn-toast {
4565
+ position: fixed; top: 14px; left: 50%;
4566
+ z-index: 980;
4567
+ display: flex; align-items: flex-start; gap: 10px;
4568
+ max-width: min(560px, 92vw);
4569
+ padding: 10px 12px;
4570
+ background: var(--dsw-alias-bg-layer-2);
4571
+ border: 1px solid var(--dsw-alias-border-l2);
4572
+ border-radius: 10px;
4573
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
4574
+ color: var(--dsw-alias-label-primary);
4575
+ font-size: 12px; line-height: 1.5;
4576
+ animation: dsc-conn-toast-in 0.16s ease-out both;
4577
+ }
4578
+ .dsc-conn-toast--probing { animation-delay: 0.45s; }
4579
+ .dsc-conn-toast--ok { border-color: var(--dsw-alias-state-success-primary); }
4580
+ .dsc-conn-toast--fail { border-color: var(--dsw-alias-state-error-primary); }
4581
+ .dsc-conn-toast-dot { flex: none; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; }
4582
+ .dsc-conn-toast--probing .dsc-conn-toast-dot { background: var(--dsw-alias-state-warn-primary); animation: dsc-conn-dot 1s ease-in-out infinite; }
4583
+ .dsc-conn-toast--ok .dsc-conn-toast-dot { background: var(--dsw-alias-state-success-primary); }
4584
+ .dsc-conn-toast--fail .dsc-conn-toast-dot { background: var(--dsw-alias-state-error-primary); }
4585
+ .dsc-conn-toast-body { min-width: 0; }
4586
+ .dsc-conn-toast-title { font-weight: 600; }
4587
+ .dsc-conn-toast--fail .dsc-conn-toast-title { color: var(--dsw-alias-state-error-primary); }
4588
+ .dsc-conn-toast--ok .dsc-conn-toast-title { color: var(--dsw-alias-state-success-primary); }
4589
+ .dsc-conn-toast-text { color: var(--dsw-alias-label-secondary); margin-top: 2px; overflow-wrap: anywhere; }
4590
+ .dsc-conn-toast-close { flex: none; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-size: 13px; padding: 2px 4px; line-height: 1; }
4591
+ .dsc-conn-toast-close:hover { color: var(--dsw-alias-label-primary); }
4592
+ @keyframes dsc-conn-toast-in { from { opacity: 0; transform: translate(-50%, -6px); } to { opacity: 1; transform: translate(-50%, 0); } }
4593
+ @keyframes dsc-conn-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
3897
4594
  /* --- panel shell: floating window, percentage-anchored to the page header
3898
4595
  (top) and the composer (bottom), draggable by the header and resizable via
3899
4596
  the corner handle. Follows the host light/dark theme. --- */
@@ -3926,6 +4623,9 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3926
4623
  }
3927
4624
  .dsc-panel-resize:hover { opacity: 1; }
3928
4625
  .dsc-panel-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 14px; }
4626
+ .dsc-panel-head-actions { display: inline-flex; align-items: center; gap: 2px; }
4627
+ .dsc-panel-reset { border: none; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-size: 13px; padding: 4px 6px; line-height: 1; }
4628
+ .dsc-panel-reset:hover { color: var(--dsw-alias-label-primary); }
3929
4629
  .dsc-panel-close { border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-size: 14px; padding: 4px 8px; }
3930
4630
  .dsc-panel-close:hover { color: var(--dsw-alias-label-primary); }
3931
4631
  .dsc-tabs { display: flex; border-bottom: 1px solid var(--dsw-alias-border-l1); }
@@ -3976,7 +4676,12 @@ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--
3976
4676
  .dsc-skill-files { flex: 0 0 190px; min-width: 160px; display: flex; flex-direction: column; gap: 6px; }
3977
4677
  .dsc-skill-main { flex: 1 1 300px; min-width: 260px; display: flex; flex-direction: column; gap: 6px; }
3978
4678
  .dsc-skill-bucket { display: flex; flex-direction: column; gap: 2px; }
3979
- .dsc-skill-bucket-name { font-size: 11px; color: var(--dsw-alias-label-secondary); margin-top: 4px; }
4679
+ /* Foldable pack directories: each folder renders as a <details> whose header
4680
+ stays put while its files collapse, keeping crowded packs scannable. */
4681
+ .dsc-skill-fold { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; margin: 2px 0; }
4682
+ .dsc-skill-fold summary { padding: 5px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary); border-radius: 8px; }
4683
+ .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; }
4684
+ .dsc-skill-fold .dsc-skill-bucket { padding: 4px 6px 6px; }
3980
4685
  .dsc-skill-file {
3981
4686
  display: flex; align-items: center; justify-content: space-between; gap: 6px;
3982
4687
  border: 1px solid transparent; border-radius: 6px; background: transparent;
@@ -4191,6 +4896,28 @@ video.dsc-picker-player { max-height: 180px; }
4191
4896
  .dsc-param-random { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
4192
4897
  .dsc-param-bool { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-primary); cursor: pointer; }
4193
4898
  .dsc-param-advanced { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 8px; padding: 8px; }
4899
+ /* --- workflow preset transfer dialogs (export / import) --- */
4900
+ .dsc-transfer { position: relative; display: flex; flex-direction: column; width: min(580px, 92vw); max-height: min(640px, 86vh); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: 0 16px 56px rgba(0, 0, 0, 0.5); overflow: hidden; }
4901
+ .dsc-transfer-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
4902
+ .dsc-transfer-title { font-weight: 600; font-size: 13px; color: var(--dsw-alias-label-primary); }
4903
+ .dsc-transfer-body { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; overflow-y: auto; min-height: 0; }
4904
+ .dsc-transfer-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
4905
+ .dsc-transfer-list { display: flex; flex-direction: column; gap: 4px; }
4906
+ .dsc-transfer-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; cursor: pointer; font-size: 12px; color: var(--dsw-alias-label-primary); }
4907
+ .dsc-transfer-item:hover { border-color: var(--dsw-alias-brand-primary); }
4908
+ .dsc-transfer-item--static, .dsc-transfer-item--static:hover { cursor: default; border-color: var(--dsw-alias-border-l1); }
4909
+ .dsc-transfer-item--ok { border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent); }
4910
+ .dsc-transfer-item--fail { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); }
4911
+ .dsc-transfer-item-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 240px; }
4912
+ .dsc-transfer-item-desc { color: var(--dsw-alias-label-tertiary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 40px; text-align: right; }
4913
+ .dsc-transfer-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
4914
+ .dsc-transfer-note { padding: 6px 12px 0; }
4915
+ .dsc-transfer-note .dsc-hint { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4916
+ .dsc-transfer-drop { display: flex; align-items: center; justify-content: center; min-height: 150px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 10px; color: var(--dsw-alias-label-secondary); font-size: 13px; cursor: pointer; text-align: center; padding: 12px; transition: border-color 0.12s, color 0.12s; }
4917
+ .dsc-transfer-drop:hover, .dsc-transfer-drop--over { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
4918
+ .dsc-transfer-empty { padding: 40px 12px; text-align: center; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
4919
+ .dsc-btn--primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
4920
+ .dsc-btn--primary:disabled { opacity: 0.5; cursor: default; }
4194
4921
  `;
4195
4922
  /** Inject the stylesheet once (idempotent), owned by this plugin for HMR. */
4196
4923
  function injectStyles() {