dsh-comfyui 0.3.0-beta.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.
@@ -0,0 +1,3089 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-comfyui",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ //#region src/client/i18n.ts
9
+ /** zh/en dictionaries for dsh-comfyui UI (settings page, tool card, panel). */
10
+ const zh = {
11
+ settingsTitle: "ComfyUI",
12
+ settingsDesc: "连接你的 ComfyUI 服务器,让 Agent 直接生成与处理图像、视频。",
13
+ baseUrl: "服务器地址",
14
+ baseUrlHint: "ComfyUI 的 HTTP 地址(默认 http://127.0.0.1:8188)。",
15
+ apiKeyEnv: "API Key 环境变量名",
16
+ apiKeyEnvHint: "凭据存储或环境变量中的密钥名(默认 COMFYUI_API_KEY),远程服务通常需要。",
17
+ hasApiKey: "已配置 API Key",
18
+ noApiKey: "未配置 API Key(本地服务通常不需要)",
19
+ save: "保存",
20
+ saved: "已保存",
21
+ saveFailed: "保存失败",
22
+ test: "测试连接",
23
+ testing: "正在测试…",
24
+ testOk: "连接成功 · ComfyUI {version} · {ms} ms",
25
+ testFail: "连接失败:{message}",
26
+ configNotWritable: "设置服务不可用,请通过 cordis.yml 修改配置。",
27
+ mediaHost: "媒体访问地址",
28
+ mediaHostHint: "生成图片/媒体在聊天中使用的完整地址(如 http://192.168.1.5:3080)。留空时自动使用浏览器访问本服务器的地址;内网或反向代理下若图片不显示,请在此填写你访问本页面所用的地址。",
29
+ language: "界面语言",
30
+ languageHint: "切换插件界面语言(中文 / English)。切换后页面会自动刷新。",
31
+ cardGenerating: "ComfyUI 生成中…",
32
+ cardBackground: "后台任务",
33
+ cardBackgroundDone: "已完成",
34
+ cardPrompt: "任务 ID",
35
+ cardElapsed: "耗时",
36
+ cardMedia: "结果",
37
+ cardEmpty: "无媒体输出",
38
+ cardFailed: "执行失败",
39
+ cardInterrupted: "已中断",
40
+ cardCollect: "使用 job_output 收集后台任务结果",
41
+ cardLoadFailed: "加载失败,点下方下载",
42
+ cardDownload: "下载",
43
+ cardWorkflow: "工作流",
44
+ cardMode: "模式",
45
+ cardListed: "已列出 {runs} 个运行工作流,{graphs} 个图工作流",
46
+ panelTitle: "ComfyUI 面板",
47
+ close: "关闭",
48
+ lbPrev: "上一张",
49
+ lbNext: "下一张",
50
+ tabWorkflows: "工作流",
51
+ tabAssets: "资产",
52
+ tabQueue: "队列",
53
+ error: "出错",
54
+ refresh: "刷新",
55
+ ours: "本插件",
56
+ wfAdd: "新建工作流",
57
+ wfName: "名称",
58
+ wfDesc: "概述",
59
+ wfDescPlaceholder: "告诉 Agent 这个工作流做什么",
60
+ wfNameCopyHint: "点击复制工作流名称",
61
+ wfNameCopied: "已复制工作流名称",
62
+ wfParams: "可调参数",
63
+ wfTags: "标签",
64
+ wfTagAll: "全部",
65
+ wfTagAdd: "自定义标签,回车添加",
66
+ wfTagRemove: "点击移除标签",
67
+ wfParamsEmpty: "未定义参数。点击\"识别参数\"自动提取提示词/分辨率/步数/种子,或\"高级参数\"手动指定任意节点输入。",
68
+ wfParamsDrop: "拖拽/点击/粘贴上传",
69
+ wfPasteHint: "悬停此处后按 Ctrl+V 粘贴剪贴板里的图片/文件",
70
+ wfUploadSection: "加载区",
71
+ wfUploadHint: "点击大图选择图像,或粘贴/上传新图像;选中后图生图默认使用该图像",
72
+ wfUploadZone: "拖拽 / 点击 / 粘贴上传到 ComfyUI input 目录",
73
+ wfUploaded: "已上传",
74
+ wfCopyName: "复制文件名",
75
+ wfLoadArea: "加载区",
76
+ wfLoadHint: "点击选择图像(加载区选中的图像将作为图生图的默认源图)",
77
+ wfLoadEmpty: "未选择图像,点击选择",
78
+ wfLoadPicked: "已加载",
79
+ wfTabAll: "全部",
80
+ wfTabImported: "已导入",
81
+ wfTabGenerated: "已生成",
82
+ wfTypeAll: "全部类型",
83
+ wfUploadedToast: "文件已上传",
84
+ wfPickerUpload: "粘贴 / 点击上传",
85
+ wfLoadNoFiles: "没有可用的图像",
86
+ wfParamsUploading: "上传中…",
87
+ uploadKind_image: "图片",
88
+ uploadKind_video: "视频",
89
+ uploadKind_audio: "音频",
90
+ wfMediaClear: "清空",
91
+ wfMediaEmpty: "文件名(留空=移除该参考位)",
92
+ wfRefSlot: "参考",
93
+ wfParamsRecognize: "识别参数",
94
+ wfParamsRecognizing: "识别中…",
95
+ wfParamsRecognizeFailed: "识别失败:无法连接 ComfyUI 或工作流无效",
96
+ wfParamsAdd: "高级参数",
97
+ wfParamAdd: "添加",
98
+ wfParamLabel: "名称",
99
+ wfParamCustomLabel: "自定义名称",
100
+ wfParamCustomHint: "仅用于展示与 Agent 描述,传参键由系统自动生成;留空使用默认",
101
+ wfParamDefault: "默认值",
102
+ wfParamRandom: "随机",
103
+ wfParamDelete: "删除",
104
+ wfParamNode: "节点",
105
+ wfParamInput: "输入",
106
+ wfJson: "工作流 JSON(ComfyUI UI 导出的 API 格式)",
107
+ wfSave: "保存",
108
+ wfSaveRun: "保存并运行",
109
+ wfCancel: "取消",
110
+ wfEdit: "编辑",
111
+ wfRun: "运行",
112
+ wfDelete: "删除",
113
+ wfQueued: "已入队:",
114
+ wfJsonError: "工作流 JSON 解析失败",
115
+ wfUpdated: "更新于",
116
+ wfEmpty: "没有检测到任何工作流,请通过新建、导入或提取来创建执行流程。",
117
+ wfLibrarySection: "API 工作流",
118
+ wfRefresh: "刷新",
119
+ wfImportFile: "导入文件",
120
+ wfComfyuiSection: "ComfyUI 工作流",
121
+ wfComfyuiHint: "面板自动检测 ComfyUI 中保存的工作流。\n注:非 API 流程,无法直接执行,需经过提取转化为 API 工作流,或自行导入 API 工作流即可执行。",
122
+ wfComfyuiEmpty: "ComfyUI 端没有检测到保存的工作流",
123
+ wfExtracted: "已提取",
124
+ wfNotExtracted: "未提取",
125
+ wfView: "查看",
126
+ wfExtract: "提取",
127
+ wfExtractedNotice: "已提取并加入插件库",
128
+ wfExtractTitle: "提取执行流",
129
+ wfExtractModeAll: "整体提取",
130
+ wfExtractModeAllHint: "所有分量合成一个执行流(运行时全部执行)",
131
+ wfExtractModeSplit: "按分量提取",
132
+ wfExtractModeSplitHint: "每个独立流程一个执行流(推荐)",
133
+ wfExtractModeMain: "只提取主流程",
134
+ wfExtractModeMainHint: "只提取最大分量(通常是当前测试区块)",
135
+ wfExtractRun: "提取",
136
+ wfExtractEmpty: "图里没有可提取的分量",
137
+ wfComponents: "分量",
138
+ wfComponentLabel: "分量",
139
+ wfNodes: "节点",
140
+ wfBypassed: "已绕过节点",
141
+ wfIsolated: "悬空节点",
142
+ wfViewBack: "返回",
143
+ wfViewNodes: "节点清单",
144
+ wfViewJson: "JSON 预览",
145
+ wfViewEmpty: "图中没有可执行的节点",
146
+ assetAll: "全部工作流",
147
+ assetBack: "返回",
148
+ assetEmpty: "还没有生成记录。运行工作流后,结果会出现在这里。",
149
+ assetGone: "源文件已被 ComfyUI 清理",
150
+ queueEmpty: "当前没有任务",
151
+ queueError: "无法读取队列(ComfyUI 未运行?)",
152
+ jobFilterLabel: "历史",
153
+ jobFilterAll: "全部",
154
+ jobFilterPending: "待生成",
155
+ jobFilterInProgress: "生成中",
156
+ jobFilterCompleted: "已完成",
157
+ jobFilterFailed: "失败",
158
+ jobFilterCancelled: "已取消",
159
+ jobActive: "进行中",
160
+ jobDelete: "删除",
161
+ jobInterrupt: "中断",
162
+ jobRerun: "重跑",
163
+ jobViewAssets: "查看资产",
164
+ jobMore: "更多操作",
165
+ jobClearQueue: "清空队列",
166
+ jobClearHistory: "清空历史",
167
+ jobFree: "释放内存",
168
+ jobDuration: "耗时",
169
+ jobOurs: "本插件"
170
+ };
171
+ const en = {
172
+ settingsTitle: "ComfyUI",
173
+ settingsDesc: "Connect your ComfyUI server so the agent can generate and process images and videos directly.",
174
+ baseUrl: "Server URL",
175
+ baseUrlHint: "ComfyUI HTTP address (default http://127.0.0.1:8188).",
176
+ apiKeyEnv: "API key env var",
177
+ apiKeyEnvHint: "Credential-store or environment variable name (default COMFYUI_API_KEY); usually needed for remote servers.",
178
+ hasApiKey: "API key configured",
179
+ noApiKey: "No API key configured (usually not needed for local servers)",
180
+ save: "Save",
181
+ saved: "Saved",
182
+ saveFailed: "Save failed",
183
+ test: "Test connection",
184
+ testing: "Testing…",
185
+ testOk: "Connected · ComfyUI {version} · {ms} ms",
186
+ testFail: "Connection failed: {message}",
187
+ configNotWritable: "Settings service unavailable — edit cordis.yml instead.",
188
+ mediaHost: "Media base URL",
189
+ 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.",
190
+ language: "Language",
191
+ languageHint: "Switch the plugin UI language (中文 / English). The page reloads after switching.",
192
+ cardGenerating: "ComfyUI generating…",
193
+ cardBackground: "Background job",
194
+ cardBackgroundDone: "Completed",
195
+ cardPrompt: "Prompt ID",
196
+ cardElapsed: "Elapsed",
197
+ cardMedia: "Results",
198
+ cardEmpty: "No media outputs",
199
+ cardFailed: "Execution failed",
200
+ cardInterrupted: "Interrupted",
201
+ cardCollect: "Collect the background job result with job_output",
202
+ cardLoadFailed: "failed to load, use the download link below",
203
+ cardDownload: "Download",
204
+ cardWorkflow: "Workflow",
205
+ cardMode: "Mode",
206
+ cardListed: "Listed {runs} runnable workflows, {graphs} graph workflows",
207
+ panelTitle: "ComfyUI Panel",
208
+ close: "Close",
209
+ lbPrev: "Previous image",
210
+ lbNext: "Next image",
211
+ tabWorkflows: "Workflows",
212
+ tabAssets: "Assets",
213
+ tabQueue: "Queue",
214
+ error: "Error",
215
+ refresh: "Refresh",
216
+ ours: "ours",
217
+ wfAdd: "New workflow",
218
+ wfName: "Name",
219
+ wfDesc: "Overview",
220
+ wfDescPlaceholder: "Tells the agent what this workflow does",
221
+ wfNameCopyHint: "Click to copy the workflow name",
222
+ wfNameCopied: "Workflow name copied",
223
+ wfParams: "Parameters",
224
+ wfTags: "Tags",
225
+ wfTagAll: "All",
226
+ wfTagAdd: "Custom tag, press Enter to add",
227
+ wfTagRemove: "Click to remove tag",
228
+ wfParamsEmpty: "No parameters yet. Click \"Detect\" to auto-extract prompt / resolution / steps / seed, or \"Advanced\" to expose any node input manually.",
229
+ wfParamsDrop: "Drop / Click / Paste to upload",
230
+ wfPasteHint: "Hover here and press Ctrl+V to paste an image/file",
231
+ wfUploadSection: "Load area",
232
+ wfUploadHint: "Click the preview to pick an image, or paste/upload a new one; the selection becomes the default source for image-to-image",
233
+ wfUploadZone: "Drag / Click / Paste to upload into the ComfyUI input dir",
234
+ wfUploaded: "Uploaded",
235
+ wfCopyName: "Copy file name",
236
+ wfLoadArea: "Load area",
237
+ wfLoadHint: "Click to pick an image (the selection becomes the default source for image-to-image)",
238
+ wfLoadEmpty: "No image selected — click to pick",
239
+ wfLoadPicked: "Loaded",
240
+ wfTabAll: "All",
241
+ wfTabImported: "Imported",
242
+ wfTabGenerated: "Generated",
243
+ wfTypeAll: "All types",
244
+ wfUploadedToast: "File uploaded",
245
+ wfPickerUpload: "Paste / Click to upload",
246
+ wfLoadNoFiles: "No images available",
247
+ wfParamsUploading: "Uploading…",
248
+ uploadKind_image: "image",
249
+ uploadKind_video: "video",
250
+ uploadKind_audio: "audio",
251
+ wfMediaClear: "Clear",
252
+ wfMediaEmpty: "filename (empty = drop this slot)",
253
+ wfRefSlot: "Ref ",
254
+ wfParamsRecognize: "Detect",
255
+ wfParamsRecognizing: "Detecting…",
256
+ wfParamsRecognizeFailed: "Detection failed: ComfyUI unreachable or invalid workflow",
257
+ wfParamsAdd: "Advanced",
258
+ wfParamAdd: "Add",
259
+ wfParamLabel: "Name",
260
+ wfParamCustomLabel: "Custom name",
261
+ wfParamCustomHint: "Shown to the user and the agent only; the parameter key is generated automatically. Leave empty for the default",
262
+ wfParamDefault: "Default",
263
+ wfParamRandom: "Random",
264
+ wfParamDelete: "Delete",
265
+ wfParamNode: "Node",
266
+ wfParamInput: "Input",
267
+ wfJson: "Workflow JSON (API format exported from the ComfyUI UI)",
268
+ wfSave: "Save",
269
+ wfSaveRun: "Save & Run",
270
+ wfCancel: "Cancel",
271
+ wfEdit: "Edit",
272
+ wfRun: "Run",
273
+ wfDelete: "Delete",
274
+ wfQueued: "Queued: ",
275
+ wfJsonError: "Workflow JSON could not be parsed",
276
+ wfUpdated: "updated",
277
+ wfEmpty: "No workflows detected — create one via New, Import, or Extract.",
278
+ wfLibrarySection: "API workflows",
279
+ wfRefresh: "Refresh",
280
+ wfImportFile: "Import file",
281
+ wfComfyuiSection: "ComfyUI workflows",
282
+ 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.",
283
+ wfComfyuiEmpty: "No workflows detected on the ComfyUI server",
284
+ wfExtracted: "extracted",
285
+ wfNotExtracted: "not extracted",
286
+ wfView: "View",
287
+ wfExtract: "Extract",
288
+ wfExtractedNotice: "extracted into the library",
289
+ wfExtractTitle: "Extract runnable workflows",
290
+ wfExtractModeAll: "All as one",
291
+ wfExtractModeAllHint: "Merge every component into one runnable workflow (everything executes)",
292
+ wfExtractModeSplit: "Per component",
293
+ wfExtractModeSplitHint: "One runnable workflow per independent flow (recommended)",
294
+ wfExtractModeMain: "Main flow only",
295
+ wfExtractModeMainHint: "Only the largest component (usually the flow under test)",
296
+ wfExtractRun: "Extract",
297
+ wfExtractEmpty: "No extractable components in this graph",
298
+ wfComponents: "components",
299
+ wfComponentLabel: "Component",
300
+ wfNodes: "nodes",
301
+ wfBypassed: "bypassed nodes",
302
+ wfIsolated: "dangling nodes",
303
+ wfViewBack: "Back",
304
+ wfViewNodes: "Nodes",
305
+ wfViewJson: "JSON preview",
306
+ wfViewEmpty: "No executable nodes in this graph",
307
+ assetAll: "All workflows",
308
+ assetBack: "Back",
309
+ assetEmpty: "Nothing generated yet. Results appear here after workflows run.",
310
+ assetGone: "Source file evicted by ComfyUI",
311
+ queueEmpty: "No tasks right now",
312
+ queueError: "Cannot read the queue (ComfyUI not running?)",
313
+ jobFilterLabel: "History",
314
+ jobFilterAll: "All",
315
+ jobFilterPending: "Pending",
316
+ jobFilterInProgress: "Running",
317
+ jobFilterCompleted: "Completed",
318
+ jobFilterFailed: "Failed",
319
+ jobFilterCancelled: "Cancelled",
320
+ jobActive: "Active",
321
+ jobDelete: "Delete",
322
+ jobInterrupt: "Interrupt",
323
+ jobRerun: "Rerun",
324
+ jobViewAssets: "View assets",
325
+ jobMore: "More actions",
326
+ jobClearQueue: "Clear queue",
327
+ jobClearHistory: "Clear history",
328
+ jobFree: "Free memory",
329
+ jobDuration: "Took",
330
+ jobOurs: "ours"
331
+ };
332
+ const LANG_KEY = "dsh-comfyui.lang";
333
+ /** Current plugin UI language, read from localStorage (default zh). */
334
+ function getLang() {
335
+ try {
336
+ return localStorage.getItem(LANG_KEY) === "en" ? "en" : "zh";
337
+ } catch {
338
+ return "zh";
339
+ }
340
+ }
341
+ /** Persist the plugin UI language choice. */
342
+ function setLang(lang) {
343
+ try {
344
+ localStorage.setItem(LANG_KEY, lang);
345
+ } catch {}
346
+ }
347
+ const DICTS = {
348
+ zh,
349
+ en
350
+ };
351
+ /** Build a plugin-local translator for one language. Resolves {name}
352
+ * placeholders from the first rest argument (same contract as the DSH
353
+ * locale bind), falling back to the key itself when untranslated. */
354
+ function makeT(lang) {
355
+ const dict = DICTS[lang];
356
+ return (key, ...rest) => {
357
+ const template = dict[key] ?? key;
358
+ const params = rest[0] ?? {};
359
+ if (params !== null && typeof params === "object") return template.replace(/\{(\w+)\}/g, (match, name) => {
360
+ const value = params[name];
361
+ return value === void 0 ? match : String(value);
362
+ });
363
+ return template;
364
+ };
365
+ }
366
+ //#endregion
367
+ //#region src/client/lightbox.tsx
368
+ /**
369
+ * Shared lightbox: opens a generated image full-screen with prev/next
370
+ * navigation, keyboard support, and a download pill. Used by both the panel
371
+ * (assets/queue previews) and the tool card (result images).
372
+ */
373
+ /** Full-screen media overlay with prev/next navigation. */
374
+ function Lightbox({ t, images, kinds, index, onClose, onIndex }) {
375
+ const count = images.length;
376
+ (0, react.useEffect)(() => {
377
+ const onKey = (event) => {
378
+ if (event.key === "Escape") onClose();
379
+ else if (event.key === "ArrowLeft") onIndex((index - 1 + count) % count);
380
+ else if (event.key === "ArrowRight") onIndex((index + 1) % count);
381
+ };
382
+ document.addEventListener("keydown", onKey);
383
+ return () => document.removeEventListener("keydown", onKey);
384
+ }, [
385
+ index,
386
+ count,
387
+ onClose,
388
+ onIndex
389
+ ]);
390
+ const src = images[index];
391
+ if (src === void 0) return null;
392
+ const kind = kinds?.[index] ?? "image";
393
+ const media = kind === "video" ? (0, react.createElement)("video", {
394
+ className: "dsc-lightbox-media",
395
+ src,
396
+ controls: true,
397
+ autoPlay: true
398
+ }) : kind === "audio" ? (0, react.createElement)("audio", {
399
+ className: "dsc-lightbox-media",
400
+ src,
401
+ controls: true,
402
+ autoPlay: true
403
+ }) : kind === "image" ? (0, react.createElement)("img", {
404
+ className: "dsc-lightbox-img",
405
+ src,
406
+ alt: ""
407
+ }) : (0, react.createElement)("div", { className: "dsc-lightbox-media" }, src);
408
+ return (0, react.createElement)("div", {
409
+ className: "dsc-lightbox",
410
+ onClick: onClose
411
+ }, (0, react.createElement)("div", {
412
+ className: "dsc-lightbox-body",
413
+ onClick: (event) => event.stopPropagation()
414
+ }, (0, react.createElement)("button", {
415
+ className: "dsc-lightbox-close",
416
+ "aria-label": t("close"),
417
+ onClick: (event) => {
418
+ event.stopPropagation();
419
+ onClose();
420
+ }
421
+ }, "✕"), count > 1 ? (0, react.createElement)("button", {
422
+ className: "dsc-lightbox-nav dsc-lightbox-nav--prev",
423
+ "aria-label": t("lbPrev"),
424
+ onClick: (event) => {
425
+ event.stopPropagation();
426
+ onIndex((index - 1 + count) % count);
427
+ }
428
+ }, "‹") : null, media, (0, react.createElement)("div", { className: "dsc-lightbox-meta" }, (0, react.createElement)("span", null, `${index + 1} / ${count}`), (0, react.createElement)("a", {
429
+ className: "dsc-lightbox-download",
430
+ href: src,
431
+ download: "",
432
+ target: "_blank",
433
+ rel: "noreferrer"
434
+ }, t("cardDownload"))), count > 1 ? (0, react.createElement)("button", {
435
+ className: "dsc-lightbox-nav dsc-lightbox-nav--next",
436
+ "aria-label": t("lbNext"),
437
+ onClick: (event) => {
438
+ event.stopPropagation();
439
+ onIndex((index + 1) % count);
440
+ }
441
+ }, "›") : null));
442
+ }
443
+ //#endregion
444
+ //#region src/client/card.tsx
445
+ /**
446
+ * Tool cards for comfyui_run and comfyui_workflow (tool.call.toolview, keys
447
+ * 'comfyui_run' / 'comfyui_workflow'). A pure function of the frozen tool-call
448
+ * block: running calls show a generating state, settled calls render the
449
+ * presentationMeta payload (media wall with size labels, click-to-zoom
450
+ * lightbox, status) that the host threaded into the session log.
451
+ */
452
+ /** Discriminate the settled result node from a running call. */
453
+ function isResultNode(block) {
454
+ return block.kind === "tool-result";
455
+ }
456
+ function parseArgs(raw) {
457
+ try {
458
+ const parsed = JSON.parse(raw);
459
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
460
+ } catch {
461
+ return {};
462
+ }
463
+ }
464
+ function argsSummary(args) {
465
+ const parts = [];
466
+ if (typeof args.template === "string") parts.push(`template=${args.template}`);
467
+ if (args.mode !== void 0 && args.mode !== "sync") parts.push(`mode=${String(args.mode)}`);
468
+ if (parts.length === 0) parts.push("custom workflow");
469
+ return parts.join(" · ");
470
+ }
471
+ function aspectLabel(width, height) {
472
+ const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
473
+ const g = gcd(width, height);
474
+ const rw = width / g;
475
+ const rh = height / g;
476
+ return rw <= 32 && rh <= 32 ? `${rw}:${rh}` : "";
477
+ }
478
+ function MediaItem({ item, t, onOpen }) {
479
+ const [size, setSize] = (0, react.useState)(null);
480
+ const [failed, setFailed] = (0, react.useState)(false);
481
+ const media = item.kind === "video" ? (0, react.createElement)("video", {
482
+ src: item.url,
483
+ controls: true,
484
+ preload: "metadata"
485
+ }) : item.kind === "audio" ? (0, react.createElement)("audio", {
486
+ src: item.url,
487
+ controls: true,
488
+ preload: "metadata"
489
+ }) : item.kind === "image" ? failed ? (0, react.createElement)("span", { className: "dsc-media-other" }, `${item.filename}(${t("cardLoadFailed")})`) : (0, react.createElement)("img", {
490
+ src: item.url,
491
+ alt: item.filename,
492
+ loading: "lazy",
493
+ className: "dsc-media-img dsc-media-img--clickable",
494
+ onClick: onOpen,
495
+ onLoad: (event) => {
496
+ const width = event.target.naturalWidth;
497
+ const height = event.target.naturalHeight;
498
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) setSize({
499
+ width,
500
+ height
501
+ });
502
+ },
503
+ onError: () => setFailed(true)
504
+ }) : (0, react.createElement)("span", { className: "dsc-media-other" }, item.filename);
505
+ const ratio = size === null ? null : aspectLabel(size.width, size.height);
506
+ return (0, react.createElement)("div", { className: "dsc-media" }, media, (0, react.createElement)("div", { className: "dsc-media-meta" }, size !== null ? (0, react.createElement)("span", { className: "dsc-media-size" }, `${size.width}×${size.height}${ratio !== "" ? ` · ${ratio}` : ""}`) : null, (0, react.createElement)("a", {
507
+ href: item.url,
508
+ download: item.filename,
509
+ target: "_blank",
510
+ rel: "noreferrer"
511
+ }, t("cardDownload"))));
512
+ }
513
+ function ResultCard({ title, result, t }) {
514
+ const failed = result.status === "interrupted";
515
+ const [lightbox, setLightbox] = (0, react.useState)(null);
516
+ const urls = result.media.map((item) => item.url);
517
+ const kinds = result.media.map((item) => item.kind);
518
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, title !== "" ? (0, react.createElement)("span", { className: "dsc-badge" }, title) : null, (0, react.createElement)("span", { className: failed ? "dsc-badge dsc-badge--err" : "dsc-badge dsc-badge--ok" }, failed ? t("cardInterrupted") : result.status), (0, react.createElement)("span", { className: "dsc-meta" }, `${t("cardPrompt")} ${result.promptId}`), (0, react.createElement)("span", { className: "dsc-meta" }, `${t("cardElapsed")} ${result.elapsedMs} ms`)), (0, react.createElement)("div", { className: "dsc-meta" }, result.summary), result.media.length > 0 ? (0, react.createElement)("div", { className: "dsc-grid" }, result.media.map((item, index) => (0, react.createElement)(MediaItem, {
519
+ key: `${item.node}-${item.index}`,
520
+ item,
521
+ t,
522
+ onOpen: () => setLightbox(index)
523
+ }))) : (0, react.createElement)("div", { className: "dsc-meta" }, t("cardEmpty")), lightbox !== null ? (0, react.createElement)(Lightbox, {
524
+ t,
525
+ images: urls,
526
+ kinds,
527
+ index: lightbox,
528
+ onClose: () => setLightbox(null),
529
+ onIndex: setLightbox
530
+ }) : null);
531
+ }
532
+ function BackgroundCard({ label, promptId, t }) {
533
+ const [result, setResult] = (0, react.useState)(null);
534
+ const [lightbox, setLightbox] = (0, react.useState)(null);
535
+ (0, react.useEffect)(() => {
536
+ let stopped = false;
537
+ let timer;
538
+ const poll = async () => {
539
+ try {
540
+ const data = await (await fetch(`/comfyui/jobs/media?promptId=${encodeURIComponent(promptId)}`, { headers: { accept: "application/json" } })).json();
541
+ if (stopped) return;
542
+ if (data.ok !== true || data.status === void 0) return;
543
+ if (data.status === "completed" || data.status === "failed") {
544
+ setResult({
545
+ status: data.status,
546
+ media: data.media,
547
+ error: data.error
548
+ });
549
+ return;
550
+ }
551
+ timer = window.setTimeout(() => {
552
+ poll();
553
+ }, 3e3);
554
+ } catch {
555
+ timer = window.setTimeout(() => {
556
+ poll();
557
+ }, 5e3);
558
+ }
559
+ };
560
+ poll();
561
+ return () => {
562
+ stopped = true;
563
+ if (timer !== void 0) window.clearTimeout(timer);
564
+ };
565
+ }, [promptId]);
566
+ if (result !== null && (result.status === "completed" || result.status === "failed")) {
567
+ const media = result.media ?? [];
568
+ const urls = media.map((item) => item.url);
569
+ const kinds = media.map((item) => item.kind);
570
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: result.status === "failed" ? "dsc-badge dsc-badge--err" : "dsc-badge dsc-badge--ok" }, result.status === "failed" ? t("cardFailed") : t("cardBackgroundDone")), (0, react.createElement)("span", { className: "dsc-meta" }, `${label} · ${promptId}`)), result.status === "failed" ? (0, react.createElement)("div", { className: "dsc-meta dsc-job-error" }, result.error ?? t("cardFailed")) : media.length > 0 ? (0, react.createElement)("div", { className: "dsc-grid" }, media.map((item, index) => (0, react.createElement)(MediaItem, {
571
+ key: `${item.node}-${item.index}`,
572
+ item,
573
+ t,
574
+ onOpen: () => setLightbox(index)
575
+ }))) : (0, react.createElement)("div", { className: "dsc-meta" }, t("cardEmpty")), lightbox !== null ? (0, react.createElement)(Lightbox, {
576
+ t,
577
+ images: urls,
578
+ kinds,
579
+ index: lightbox,
580
+ onClose: () => setLightbox(null),
581
+ onIndex: setLightbox
582
+ }) : null);
583
+ }
584
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: "dsc-badge" }, t("cardBackground")), (0, react.createElement)("span", { className: "dsc-meta" }, `${label} · ${promptId}`)), (0, react.createElement)("div", { className: "dsc-meta" }, t("cardCollect")));
585
+ }
586
+ function SettledCard({ block, t }) {
587
+ if (block.isError === true) return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: "dsc-badge dsc-badge--err" }, t("cardFailed"))), (0, react.createElement)("div", { className: "dsc-meta" }, block.error?.name ?? "error"));
588
+ const meta = block.meta;
589
+ if (meta === void 0 || typeof meta !== "object") return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("span", { className: "dsc-badge" }, block.call?.name ?? "comfyui"));
590
+ if ("action" in meta) {
591
+ if (meta.action === "run") {
592
+ if (meta.background !== void 0) return (0, react.createElement)(BackgroundCard, {
593
+ label: meta.workflowName ?? "",
594
+ promptId: meta.background.promptId,
595
+ t
596
+ });
597
+ if (meta.result !== void 0) return (0, react.createElement)(ResultCard, {
598
+ title: meta.workflowName ?? "",
599
+ result: meta.result,
600
+ t
601
+ });
602
+ }
603
+ if (meta.action === "list") {
604
+ const runs = meta.workflows?.length ?? 0;
605
+ const graphs = meta.comfyuiWorkflows?.length ?? 0;
606
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: "dsc-badge" }, "comfyui_workflow")), (0, react.createElement)("div", { className: "dsc-meta" }, t("cardListed", {
607
+ runs,
608
+ graphs
609
+ })));
610
+ }
611
+ if (meta.action === "get") return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: "dsc-badge" }, "comfyui_workflow")), (0, react.createElement)("div", { className: "dsc-meta" }, meta.name ?? meta.id ?? "get"));
612
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("span", { className: "dsc-badge" }, "comfyui_workflow"));
613
+ }
614
+ if (meta.kind === "background") return (0, react.createElement)(BackgroundCard, {
615
+ label: meta.label,
616
+ promptId: meta.promptId,
617
+ t
618
+ });
619
+ return (0, react.createElement)(ResultCard, {
620
+ title: "",
621
+ result: meta,
622
+ t
623
+ });
624
+ }
625
+ /** The card component: picks running vs settled rendering from the block. */
626
+ function ComfyUICard({ t, block }) {
627
+ if (isResultNode(block)) return (0, react.createElement)(SettledCard, {
628
+ block,
629
+ t
630
+ });
631
+ const args = parseArgs(block.argsRaw);
632
+ return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-card-head" }, (0, react.createElement)("span", { className: "dsc-badge" }, t("cardGenerating")), (0, react.createElement)("span", { className: "dsc-meta" }, `${t("cardWorkflow")} ${argsSummary(args)} · ${t("cardMode")} ${String(args.mode ?? "sync")}`)));
633
+ }
634
+ //#endregion
635
+ //#region src/client/api.ts
636
+ /** Same-origin JSON helpers shared by the settings page and the panel. */
637
+ async function getJson(url) {
638
+ const response = await fetch(url, { headers: { accept: "application/json" } });
639
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
640
+ return await response.json();
641
+ }
642
+ async function postJson(url, body) {
643
+ const response = await fetch(url, {
644
+ method: "POST",
645
+ headers: { "content-type": "application/json" },
646
+ body: JSON.stringify(body)
647
+ });
648
+ if (!response.ok) {
649
+ const payload = await response.json().catch(() => ({}));
650
+ throw new Error(payload.error ?? `HTTP ${response.status}`);
651
+ }
652
+ return response.json();
653
+ }
654
+ //#endregion
655
+ //#region src/client/settings.tsx
656
+ /**
657
+ * The ComfyUI settings page (settings.section, id 'comfyui'). Reads the
658
+ * redacted host config over /comfyui/config, persists edits through the same
659
+ * route, and probes the server over /comfyui/test.
660
+ */
661
+ /** The ComfyUI settings page component. */
662
+ function ComfyUISettings({ t }) {
663
+ const [config, setConfig] = (0, react.useState)(null);
664
+ const [baseUrl, setBaseUrl] = (0, react.useState)("");
665
+ const [apiKeyEnv, setApiKeyEnv] = (0, react.useState)("");
666
+ const [mediaHost, setMediaHost] = (0, react.useState)("");
667
+ const [loadError, setLoadError] = (0, react.useState)(null);
668
+ const [saving, setSaving] = (0, react.useState)(false);
669
+ const [saveState, setSaveState] = (0, react.useState)("idle");
670
+ const [testing, setTesting] = (0, react.useState)(false);
671
+ const [testResult, setTestResult] = (0, react.useState)(null);
672
+ const [lang, setLangState] = (0, react.useState)(getLang());
673
+ (0, react.useEffect)(() => {
674
+ let cancelled = false;
675
+ getJson("/comfyui/config").then((view) => {
676
+ if (cancelled) return;
677
+ setConfig(view);
678
+ setBaseUrl(view.baseUrl);
679
+ setApiKeyEnv(view.apiKeyEnv);
680
+ setMediaHost(view.mediaHost ?? "");
681
+ }).catch((error) => {
682
+ if (cancelled) return;
683
+ setLoadError(error instanceof Error ? error.message : String(error));
684
+ });
685
+ return () => {
686
+ cancelled = true;
687
+ };
688
+ }, []);
689
+ const save = async () => {
690
+ setSaving(true);
691
+ setSaveState("idle");
692
+ try {
693
+ const payload = await postJson("/comfyui/config", { patch: {
694
+ baseUrl,
695
+ apiKeyEnv,
696
+ mediaHost
697
+ } });
698
+ if (payload.config !== void 0) {
699
+ setConfig(payload.config);
700
+ setBaseUrl(payload.config.baseUrl);
701
+ setApiKeyEnv(payload.config.apiKeyEnv);
702
+ setMediaHost(payload.config.mediaHost ?? "");
703
+ }
704
+ setSaveState("saved");
705
+ } catch (error) {
706
+ setSaveState("failed");
707
+ setTestResult({
708
+ ok: false,
709
+ text: error instanceof Error ? error.message : String(error)
710
+ });
711
+ } finally {
712
+ setSaving(false);
713
+ }
714
+ };
715
+ const test = async () => {
716
+ setTesting(true);
717
+ setTestResult(null);
718
+ try {
719
+ const payload = await postJson("/comfyui/test", {});
720
+ if (payload.ok === true) setTestResult({
721
+ ok: true,
722
+ text: t("testOk", {
723
+ version: payload.version ?? "unknown",
724
+ ms: payload.latencyMs ?? 0
725
+ })
726
+ });
727
+ else setTestResult({
728
+ ok: false,
729
+ text: t("testFail", { message: payload.error ?? "unknown error" })
730
+ });
731
+ } catch (error) {
732
+ setTestResult({
733
+ ok: false,
734
+ text: t("testFail", { message: error instanceof Error ? error.message : String(error) })
735
+ });
736
+ } finally {
737
+ setTesting(false);
738
+ }
739
+ };
740
+ if (loadError !== null) return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-meta" }, `${t("saveFailed")}: ${loadError}`));
741
+ if (config === null) return (0, react.createElement)("div", { className: "dsc-card" }, (0, react.createElement)("div", { className: "dsc-meta" }, "…"));
742
+ return (0, react.createElement)("div", { className: "dsc-form" }, (0, react.createElement)("div", { className: "dsc-meta" }, t("settingsDesc")), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("language")), (0, react.createElement)("select", {
743
+ className: "dsc-input",
744
+ value: lang,
745
+ onChange: (event) => {
746
+ const next = event.target.value === "en" ? "en" : "zh";
747
+ setLangState(next);
748
+ setLang(next);
749
+ window.location.reload();
750
+ }
751
+ }, (0, react.createElement)("option", { value: "zh" }, "中文"), (0, react.createElement)("option", { value: "en" }, "English")), (0, react.createElement)("div", { className: "dsc-hint" }, t("languageHint"))), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("baseUrl")), (0, react.createElement)("input", {
752
+ value: baseUrl,
753
+ placeholder: "http://127.0.0.1:8188",
754
+ onChange: (event) => {
755
+ setBaseUrl(event.target.value);
756
+ setSaveState("idle");
757
+ }
758
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("baseUrlHint"))), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("apiKeyEnv")), (0, react.createElement)("input", {
759
+ value: apiKeyEnv,
760
+ placeholder: "COMFYUI_API_KEY",
761
+ onChange: (event) => {
762
+ setApiKeyEnv(event.target.value);
763
+ setSaveState("idle");
764
+ }
765
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, `${t("apiKeyEnvHint")} — ${config.hasApiKey ? t("hasApiKey") : t("noApiKey")}`)), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("mediaHost")), (0, react.createElement)("input", {
766
+ value: mediaHost,
767
+ placeholder: "http://192.168.1.5:3080",
768
+ onChange: (event) => {
769
+ setMediaHost(event.target.value);
770
+ setSaveState("idle");
771
+ }
772
+ }), (0, react.createElement)("div", { className: "dsc-hint" }, t("mediaHostHint"))), (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
773
+ className: "dsc-btn",
774
+ disabled: saving || !config.writable,
775
+ onClick: () => void save()
776
+ }, t("save")), (0, react.createElement)("button", {
777
+ className: "dsc-btn",
778
+ disabled: testing,
779
+ onClick: () => void test()
780
+ }, testing ? t("testing") : t("test")), saveState === "saved" ? (0, react.createElement)("span", { className: "dsc-note" }, t("saved")) : null, saveState === "failed" ? (0, react.createElement)("span", { className: "dsc-note" }, t("saveFailed")) : null), testResult !== null ? (0, react.createElement)("div", {
781
+ className: testResult.ok ? "dsc-note" : "dsc-note",
782
+ style: testResult.ok ? { color: "#22c55e" } : { color: "#ef4444" }
783
+ }, testResult.text) : null, config.writable ? null : (0, react.createElement)("div", { className: "dsc-hint" }, t("configNotWritable")));
784
+ }
785
+ //#endregion
786
+ //#region src/client/panel-store.ts
787
+ /**
788
+ * Shared open/close/tab state for the ComfyUI panel. The overlay occupant
789
+ * and the session-header trigger are separate registrations in one bundle,
790
+ * so they coordinate through this module-level store plus useSyncExternalStore.
791
+ */
792
+ const listeners = /* @__PURE__ */ new Set();
793
+ let open = false;
794
+ let tab = "workflows";
795
+ let assetFilter = "";
796
+ function emit() {
797
+ for (const listener of listeners) listener();
798
+ }
799
+ const panelStore = {
800
+ isOpen: () => open,
801
+ toggle: () => {
802
+ open = !open;
803
+ emit();
804
+ },
805
+ open: () => {
806
+ if (!open) {
807
+ open = true;
808
+ emit();
809
+ }
810
+ },
811
+ close: () => {
812
+ if (open) {
813
+ open = false;
814
+ emit();
815
+ }
816
+ },
817
+ getTab: () => tab,
818
+ setTab: (next) => {
819
+ if (tab !== next) {
820
+ tab = next;
821
+ emit();
822
+ }
823
+ },
824
+ getAssetFilter: () => assetFilter,
825
+ setAssetFilter: (name) => {
826
+ if (assetFilter !== name) {
827
+ assetFilter = name;
828
+ emit();
829
+ }
830
+ },
831
+ subscribe(listener) {
832
+ listeners.add(listener);
833
+ return () => {
834
+ listeners.delete(listener);
835
+ };
836
+ }
837
+ };
838
+ /** React hook reading the panel open state. */
839
+ function usePanelOpen() {
840
+ return (0, react.useSyncExternalStore)(panelStore.subscribe, panelStore.isOpen, panelStore.isOpen);
841
+ }
842
+ /** React hook reading the active panel tab. */
843
+ function usePanelTab() {
844
+ return (0, react.useSyncExternalStore)(panelStore.subscribe, panelStore.getTab, panelStore.getTab);
845
+ }
846
+ //#endregion
847
+ //#region src/client/trigger.tsx
848
+ /**
849
+ * The session-header trigger (conversation.session.header.actions, id
850
+ * 'comfyui'): a labelled button that toggles the ComfyUI panel.
851
+ */
852
+ /**
853
+ * ComfyUI brand glyph (mono), inlined from @lobehub/icons-static-svg
854
+ * (lobehub.com/icons). currentColor fill keeps it theme-aware.
855
+ */
856
+ function ComfyUIIcon() {
857
+ return (0, react.createElement)("svg", {
858
+ viewBox: "0 0 24 24",
859
+ width: "1em",
860
+ height: "1em",
861
+ fill: "currentColor",
862
+ fillRule: "evenodd",
863
+ style: {
864
+ flex: "none",
865
+ lineHeight: 1
866
+ },
867
+ "aria-hidden": true
868
+ }, (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" }));
869
+ }
870
+ /** Header-action button toggling the ComfyUI panel. */
871
+ function ComfyUITrigger({ t }) {
872
+ const open = usePanelOpen();
873
+ return (0, react.createElement)("button", {
874
+ className: "dsc-trigger",
875
+ title: t("panelTitle"),
876
+ "aria-label": t("panelTitle"),
877
+ "aria-pressed": open,
878
+ onClick: () => panelStore.toggle()
879
+ }, (0, react.createElement)("span", { className: "dsc-trigger-glyph" }, (0, react.createElement)(ComfyUIIcon)), (0, react.createElement)("span", null, t("panelTitle")));
880
+ }
881
+ //#endregion
882
+ //#region src/client/panel.tsx
883
+ /**
884
+ * The ComfyUI panel (shell.overlay, id 'comfyui.panel'): a floating panel
885
+ * with three tabs — workflow library management, generated asset preview,
886
+ * and the live ComfyUI queue. Draggable by its header and resizable via the
887
+ * corner handle; geometry persists in localStorage. Renders null while closed
888
+ * (the overlay layer is click-through, so nothing blocks the app underneath).
889
+ */
890
+ const GEOM_KEY = "dsh-comfyui-panel-geom";
891
+ function loadGeom() {
892
+ try {
893
+ const raw = localStorage.getItem(GEOM_KEY);
894
+ if (raw === null) return {};
895
+ const parsed = JSON.parse(raw);
896
+ const geom = {};
897
+ if (typeof parsed.x === "number") geom.x = parsed.x;
898
+ if (typeof parsed.y === "number") geom.y = parsed.y;
899
+ if (typeof parsed.width === "number" && parsed.width >= 300) geom.width = parsed.width;
900
+ if (typeof parsed.height === "number" && parsed.height >= 200) geom.height = parsed.height;
901
+ return geom;
902
+ } catch {
903
+ return {};
904
+ }
905
+ }
906
+ function saveGeom(geom) {
907
+ try {
908
+ localStorage.setItem(GEOM_KEY, JSON.stringify(geom));
909
+ } catch {}
910
+ }
911
+ /** Preset workflow classification tags (users can add custom ones). Stored as
912
+ * plain text, so the tag identity is the label itself. */
913
+ const PRESET_TAGS = [
914
+ "图生图",
915
+ "文生图",
916
+ "文生视频",
917
+ "图生视频",
918
+ "参考生视频",
919
+ "文生音频",
920
+ "参考生音频"
921
+ ];
922
+ const JOB_FILTERS = [
923
+ {
924
+ key: "all",
925
+ label: "jobFilterAll"
926
+ },
927
+ {
928
+ key: "completed",
929
+ label: "jobFilterCompleted"
930
+ },
931
+ {
932
+ key: "failed",
933
+ label: "jobFilterFailed"
934
+ },
935
+ {
936
+ key: "cancelled",
937
+ label: "jobFilterCancelled"
938
+ }
939
+ ];
940
+ function shortId(promptId) {
941
+ return promptId.length > 10 ? `${promptId.slice(0, 10)}…` : promptId;
942
+ }
943
+ /** Open a job's preview image URL (via the media proxy) when it has one. */
944
+ function previewUrlOf(job) {
945
+ const preview = job.previewOutput;
946
+ if (preview === void 0 || preview === null || preview.filename === void 0 || preview.filename === null) return null;
947
+ return `/comfyui/media?file=${encodeURIComponent(preview.filename)}&subfolder=${encodeURIComponent(preview.subfolder ?? "")}&type=${encodeURIComponent(preview.type ?? "output")}`;
948
+ }
949
+ /** Opens a generated image in a large overlay with prev/next navigation. */
950
+ function newParamId() {
951
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
952
+ return `p${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
953
+ }
954
+ /** Parse the draft workflow into a plain object (empty on invalid JSON). */
955
+ function parseDraftWorkflow(workflow) {
956
+ if (typeof workflow !== "string") return workflow;
957
+ try {
958
+ const parsed = JSON.parse(workflow);
959
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
960
+ } catch {
961
+ return {};
962
+ }
963
+ }
964
+ /**
965
+ * Custom dropdown for loader-node file lists: each option shows a small
966
+ * server-side thumbnail (images only; <select> cannot render images).
967
+ */
968
+ function UploadSelect(props) {
969
+ const [open, setOpen] = (0, react.useState)(false);
970
+ const [live, setLive] = (0, react.useState)(null);
971
+ const ref = (0, react.useRef)(null);
972
+ const refreshRef = (0, react.useRef)(props.onRefresh);
973
+ refreshRef.current = props.onRefresh;
974
+ (0, react.useEffect)(() => {
975
+ if (!open) return;
976
+ const onDoc = (event) => {
977
+ if (ref.current !== null && !ref.current.contains(event.target)) setOpen(false);
978
+ };
979
+ document.addEventListener("mousedown", onDoc);
980
+ return () => document.removeEventListener("mousedown", onDoc);
981
+ }, [open]);
982
+ (0, react.useEffect)(() => {
983
+ if (!open) return;
984
+ const fn = refreshRef.current;
985
+ if (fn === void 0) return;
986
+ let cancelled = false;
987
+ fn().then((latest) => {
988
+ if (!cancelled && latest !== void 0) setLive(latest);
989
+ });
990
+ return () => {
991
+ cancelled = true;
992
+ };
993
+ }, [open]);
994
+ const options = live ?? props.options;
995
+ const thumbUrl = (name) => props.kind === "image" ? `/comfyui/media?file=${encodeURIComponent(String(name))}&type=input` : null;
996
+ const current = options.some((option) => String(option) === props.value) ? props.value : String(props.value);
997
+ return (0, react.createElement)("div", {
998
+ ref,
999
+ className: "dsc-upload-select"
1000
+ }, (0, react.createElement)("button", {
1001
+ className: "dsc-upload-select-btn",
1002
+ onClick: () => setOpen(!open),
1003
+ title: String(current)
1004
+ }, thumbUrl(props.value) !== null ? (0, react.createElement)("img", {
1005
+ className: "dsc-upload-select-thumb",
1006
+ src: thumbUrl(props.value) ?? "",
1007
+ alt: "",
1008
+ loading: "lazy"
1009
+ }) : null, (0, react.createElement)("span", { className: "dsc-upload-select-name" }, String(current))), open ? (0, react.createElement)("div", { className: "dsc-upload-select-pop" }, options.map((option) => {
1010
+ const name = String(option);
1011
+ return (0, react.createElement)("div", {
1012
+ key: name,
1013
+ className: `dsc-upload-select-item${name === props.value ? " dsc-upload-select-item--active" : ""}`,
1014
+ onClick: () => {
1015
+ props.onChange(name);
1016
+ setOpen(false);
1017
+ }
1018
+ }, thumbUrl(option) !== null ? (0, react.createElement)("img", {
1019
+ className: "dsc-upload-select-thumb",
1020
+ src: thumbUrl(option) ?? "",
1021
+ alt: "",
1022
+ loading: "lazy"
1023
+ }) : null, (0, react.createElement)("span", { className: "dsc-upload-select-name" }, name));
1024
+ })) : null);
1025
+ }
1026
+ /** Merge a freshly fetched option list with the previous one (latest first,
1027
+ * deduped) so the dropdown never drops options mid-session. */
1028
+ function mergeOptions(prev, latest) {
1029
+ const seen = /* @__PURE__ */ new Set();
1030
+ const out = [];
1031
+ for (const item of [...latest, ...prev]) {
1032
+ const key = String(item);
1033
+ if (!seen.has(key)) {
1034
+ seen.add(key);
1035
+ out.push(item);
1036
+ }
1037
+ }
1038
+ return out;
1039
+ }
1040
+ /** Copy text to the clipboard; falls back to a hidden textarea when the
1041
+ * clipboard API is unavailable (non-secure LAN http). */
1042
+ function copyText(text) {
1043
+ if (navigator.clipboard !== void 0) return navigator.clipboard.writeText(text).then(() => true).catch(() => copyViaTextarea(text));
1044
+ return Promise.resolve(copyViaTextarea(text));
1045
+ }
1046
+ function copyViaTextarea(text) {
1047
+ const textarea = document.createElement("textarea");
1048
+ textarea.value = text;
1049
+ textarea.style.position = "fixed";
1050
+ textarea.style.opacity = "0";
1051
+ document.body.appendChild(textarea);
1052
+ textarea.select();
1053
+ let ok = false;
1054
+ try {
1055
+ ok = document.execCommand("copy");
1056
+ } catch {
1057
+ ok = false;
1058
+ }
1059
+ document.body.removeChild(textarea);
1060
+ return ok;
1061
+ }
1062
+ /** Read a file's pixel size in the browser (image only) and report it to the
1063
+ * plugin so the workflow output size can default to the source image. Returns
1064
+ * the size text for display, or undefined when unreadable. */
1065
+ async function recordMediaSize(file, name) {
1066
+ try {
1067
+ const bitmap = await createImageBitmap(file);
1068
+ const width = bitmap.width;
1069
+ const height = bitmap.height;
1070
+ bitmap.close();
1071
+ await fetch("/comfyui/media-size", {
1072
+ method: "POST",
1073
+ headers: { "Content-Type": "application/json" },
1074
+ body: JSON.stringify({
1075
+ name,
1076
+ width,
1077
+ height
1078
+ })
1079
+ }).catch(() => void 0);
1080
+ return `${width}×${height}`;
1081
+ } catch {
1082
+ return;
1083
+ }
1084
+ }
1085
+ /** SHA-256 hex digest (pure JS — crypto.subtle needs a secure context, which a
1086
+ * LAN http origin is not). Uses the standard padding/compression; the input is
1087
+ * any byte array. */
1088
+ function sha256Hex(data) {
1089
+ const K = new Uint32Array([
1090
+ 1116352408,
1091
+ 1899447441,
1092
+ 3049323471,
1093
+ 3921009573,
1094
+ 961987163,
1095
+ 1508970993,
1096
+ 2453635748,
1097
+ 2870763221,
1098
+ 3624381080,
1099
+ 310598401,
1100
+ 607225278,
1101
+ 1426881987,
1102
+ 1925078388,
1103
+ 2162078206,
1104
+ 2614888103,
1105
+ 3248222580,
1106
+ 3835390401,
1107
+ 4022224774,
1108
+ 264347078,
1109
+ 604807628,
1110
+ 770255983,
1111
+ 1249150122,
1112
+ 1555081692,
1113
+ 1996064986,
1114
+ 2554220882,
1115
+ 2821834349,
1116
+ 2952996808,
1117
+ 3210313671,
1118
+ 3336571891,
1119
+ 3584528711,
1120
+ 113926993,
1121
+ 338241895,
1122
+ 666307205,
1123
+ 773529912,
1124
+ 1294757372,
1125
+ 1396182291,
1126
+ 1695183700,
1127
+ 1986661051,
1128
+ 2177026350,
1129
+ 2456956037,
1130
+ 2730485921,
1131
+ 2820302411,
1132
+ 3259730800,
1133
+ 3345764771,
1134
+ 3516065817,
1135
+ 3600352804,
1136
+ 4094571909,
1137
+ 275423344,
1138
+ 430227734,
1139
+ 506948616,
1140
+ 659060556,
1141
+ 883997877,
1142
+ 958139571,
1143
+ 1322822218,
1144
+ 1537002063,
1145
+ 1747873779,
1146
+ 1955562222,
1147
+ 2024104815,
1148
+ 2227730452,
1149
+ 2361852424,
1150
+ 2428436474,
1151
+ 2756734187,
1152
+ 3204031479,
1153
+ 3329325298
1154
+ ]);
1155
+ const H = new Uint32Array([
1156
+ 1779033703,
1157
+ 3144134277,
1158
+ 1013904242,
1159
+ 2773480762,
1160
+ 1359893119,
1161
+ 2600822924,
1162
+ 528734635,
1163
+ 1541459225
1164
+ ]);
1165
+ const len = data.length;
1166
+ const total = len + 1 + (56 - (len + 1) % 64 + 64) % 64 + 8;
1167
+ const msg = new Uint8Array(total);
1168
+ msg.set(data);
1169
+ msg[len] = 128;
1170
+ const bitLenLo = len * 8 >>> 0;
1171
+ const bitLenHi = Math.floor(len / 536870912);
1172
+ const dv = new DataView(msg.buffer);
1173
+ dv.setUint32(total - 8, bitLenHi);
1174
+ dv.setUint32(total - 4, bitLenLo);
1175
+ const w = /* @__PURE__ */ new Uint32Array(64);
1176
+ for (let offset = 0; offset < total; offset += 64) {
1177
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(offset + i * 4);
1178
+ for (let i = 16; i < 64; i++) {
1179
+ const s0 = (w[i - 15] >>> 7 | w[i - 15] << 25) ^ (w[i - 15] >>> 18 | w[i - 15] << 14) ^ w[i - 15] >>> 3;
1180
+ const s1 = (w[i - 2] >>> 17 | w[i - 2] << 15) ^ (w[i - 2] >>> 19 | w[i - 2] << 13) ^ w[i - 2] >>> 10;
1181
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
1182
+ }
1183
+ let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
1184
+ for (let i = 0; i < 64; i++) {
1185
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
1186
+ const ch = e & f ^ ~e & g;
1187
+ const temp1 = h + S1 + ch + K[i] + w[i] >>> 0;
1188
+ const temp2 = ((a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10)) + (a & b ^ a & c ^ b & c) >>> 0;
1189
+ h = g;
1190
+ g = f;
1191
+ f = e;
1192
+ e = d + temp1 >>> 0;
1193
+ d = c;
1194
+ c = b;
1195
+ b = a;
1196
+ a = temp1 + temp2 >>> 0;
1197
+ }
1198
+ H[0] = H[0] + a >>> 0;
1199
+ H[1] = H[1] + b >>> 0;
1200
+ H[2] = H[2] + c >>> 0;
1201
+ H[3] = H[3] + d >>> 0;
1202
+ H[4] = H[4] + e >>> 0;
1203
+ H[5] = H[5] + f >>> 0;
1204
+ H[6] = H[6] + g >>> 0;
1205
+ H[7] = H[7] + h >>> 0;
1206
+ }
1207
+ let out = "";
1208
+ for (let i = 0; i < 8; i++) out += H[i].toString(16).padStart(8, "0");
1209
+ return out;
1210
+ }
1211
+ /** `image.png` + hash → `image_3f9a2b1c0d.png`: keeps the name readable while
1212
+ * making it content-unique, so re-uploading the same file reuses the name. */
1213
+ function renameWithHash(original, hash) {
1214
+ const short = hash.slice(0, 10);
1215
+ const dot = original.lastIndexOf(".");
1216
+ return `${dot > 0 ? original.slice(0, dot) : original}_${short}${dot > 0 ? original.slice(dot) : ""}`;
1217
+ }
1218
+ /** Upload a file with content-hash naming and dedup: same bytes → same file
1219
+ * name, and (for root uploads) the existing file is reused instead of creating
1220
+ * a duplicate. Returns the file name on the ComfyUI server. */
1221
+ async function uploadDedup(file, subfolder) {
1222
+ const hash = sha256Hex(new Uint8Array(await file.arrayBuffer()));
1223
+ const dedup = subfolder === void 0 || subfolder === "";
1224
+ if (dedup) {
1225
+ const lookup = await fetch("/comfyui/media-lookup", {
1226
+ method: "POST",
1227
+ headers: { "Content-Type": "application/json" },
1228
+ body: JSON.stringify({ hash })
1229
+ }).then((response) => response.json()).catch(() => void 0);
1230
+ if (lookup !== void 0 && lookup.ok === true && lookup.found === true && typeof lookup.name === "string") return lookup.name;
1231
+ }
1232
+ const renamed = new File([file], renameWithHash(file.name, hash), { type: file.type });
1233
+ const form = new FormData();
1234
+ form.append("image", renamed);
1235
+ if (!dedup) form.append("subfolder", subfolder);
1236
+ const data = await (await fetch("/comfyui/upload", {
1237
+ method: "POST",
1238
+ body: form
1239
+ })).json();
1240
+ if (data.ok !== true || typeof data.name !== "string") throw new Error(data.error ?? "upload failed");
1241
+ if (dedup) await fetch("/comfyui/media-hash", {
1242
+ method: "POST",
1243
+ headers: { "Content-Type": "application/json" },
1244
+ body: JSON.stringify({
1245
+ hash,
1246
+ name: data.name
1247
+ })
1248
+ }).catch(() => void 0);
1249
+ return data.name;
1250
+ }
1251
+ /**
1252
+ * Parameter editor: lists the workflow's adjustable parameters (auto-detected
1253
+ * prompt/size/steps/seed) with editable name/label/default and a random
1254
+ * toggle for number parameters, plus an "advanced" picker that exposes any
1255
+ * node input as a custom parameter.
1256
+ */
1257
+ function ParameterEditor(props) {
1258
+ const { t, params, workflow, onChange } = props;
1259
+ const [advanced, setAdvanced] = (0, react.useState)(false);
1260
+ const [advNode, setAdvNode] = (0, react.useState)("");
1261
+ const [advInput, setAdvInput] = (0, react.useState)("");
1262
+ const [advLabel, setAdvLabel] = (0, react.useState)("");
1263
+ const [recognizing, setRecognizing] = (0, react.useState)(false);
1264
+ const [recognizeError, setRecognizeError] = (0, react.useState)(null);
1265
+ const [uploading, setUploading] = (0, react.useState)({});
1266
+ const [hovered, setHovered] = (0, react.useState)(null);
1267
+ const hoverRef = (0, react.useRef)(null);
1268
+ const latestRef = (0, react.useRef)({
1269
+ params,
1270
+ uploadFile: async () => void 0
1271
+ });
1272
+ /** Re-fetch a parameter's live option list (ComfyUI input dir files) and
1273
+ * merge it into the stored options, so uploads made anywhere show up in the
1274
+ * dropdown without reloading the workflow editor. */
1275
+ const refreshOptions = async (param) => {
1276
+ const rawNode = workflow[param.nodeId];
1277
+ const classType = typeof rawNode?.class_type === "string" ? rawNode.class_type : "";
1278
+ if (classType === "") return void 0;
1279
+ const result = await postJson("/comfyui/workflows/input-options", {
1280
+ classType,
1281
+ inputKey: param.inputKey
1282
+ });
1283
+ if (result.ok !== true || !Array.isArray(result.options)) return void 0;
1284
+ const latest = result.options;
1285
+ const index = params.findIndex((entry) => entry.id === param.id);
1286
+ if (index !== -1 && params[index]?.options !== void 0) update(index, { options: mergeOptions(params[index].options, latest) });
1287
+ return latest;
1288
+ };
1289
+ /** Upload a dropped file through the plugin proxy into ComfyUI's input dir. */
1290
+ const uploadFile = async (param, file) => {
1291
+ setUploading((prev) => ({
1292
+ ...prev,
1293
+ [param.id]: true
1294
+ }));
1295
+ try {
1296
+ const name = await uploadDedup(file, param.subfolder);
1297
+ recordMediaSize(file, name);
1298
+ const index = params.findIndex((entry) => entry.id === param.id);
1299
+ if (index !== -1) update(index, { default: name });
1300
+ refreshOptions(param);
1301
+ } finally {
1302
+ setUploading((prev) => ({
1303
+ ...prev,
1304
+ [param.id]: false
1305
+ }));
1306
+ }
1307
+ };
1308
+ latestRef.current = {
1309
+ params,
1310
+ uploadFile
1311
+ };
1312
+ /** Global paste: Ctrl+V with the mouse hovering an upload parameter routes
1313
+ * the clipboard file (e.g. a screenshot) to that parameter — same mental
1314
+ * model as pasting an image into a chat input. */
1315
+ (0, react.useEffect)(() => {
1316
+ const onPaste = (event) => {
1317
+ const targetId = hoverRef.current;
1318
+ if (targetId === null) return;
1319
+ const file = event.clipboardData?.files?.[0];
1320
+ if (file === void 0) return;
1321
+ const param = latestRef.current.params.find((entry) => entry.id === targetId);
1322
+ if (param === void 0 || param.upload === void 0) return;
1323
+ event.preventDefault();
1324
+ latestRef.current.uploadFile(param, file);
1325
+ };
1326
+ document.addEventListener("paste", onPaste);
1327
+ return () => document.removeEventListener("paste", onPaste);
1328
+ }, []);
1329
+ /** Auto-generate the internal parameter name from the input key, deduped against existing params. */
1330
+ const genParamName = (base) => {
1331
+ let name = base;
1332
+ let index = 2;
1333
+ while (params.some((param) => param.name === name)) name = `${base}_${index++}`;
1334
+ return name;
1335
+ };
1336
+ const nodeEntries = Object.entries(workflow).map(([id, raw]) => [id, raw]);
1337
+ const advNodeInfo = nodeEntries.find(([id]) => id === advNode)?.[1];
1338
+ const advInputs = advNodeInfo !== void 0 && advNodeInfo.inputs !== void 0 ? Object.entries(advNodeInfo.inputs).filter(([key]) => key !== "upload") : [];
1339
+ const selectedInput = advInputs.find(([key]) => key === advInput);
1340
+ /** Server-side auto-detection: prompt/size/steps/seed + dropdown options (object_info). */
1341
+ const recognize = async () => {
1342
+ if (recognizing) return;
1343
+ setRecognizing(true);
1344
+ setRecognizeError(null);
1345
+ try {
1346
+ const result = await postJson("/comfyui/workflows/recognize", { workflow });
1347
+ if (result.ok === true && Array.isArray(result.parameters)) {
1348
+ onChange(result.parameters);
1349
+ setAdvanced(false);
1350
+ } else setRecognizeError(result.error ?? t("wfParamsRecognizeFailed"));
1351
+ } catch {
1352
+ setRecognizeError(t("wfParamsRecognizeFailed"));
1353
+ } finally {
1354
+ setRecognizing(false);
1355
+ }
1356
+ };
1357
+ const update = (index, patch) => {
1358
+ onChange(params.map((param, i) => i === index ? {
1359
+ ...param,
1360
+ ...patch
1361
+ } : param));
1362
+ };
1363
+ const addAdvanced = async () => {
1364
+ if (advNode === "" || advInput === "" || selectedInput === void 0 || advNodeInfo === void 0) return;
1365
+ const [, value] = selectedInput;
1366
+ const type = typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string";
1367
+ if (!params.some((param) => param.nodeId === advNode && param.inputKey === advInput && param.upload === "media") && typeof value === "string") {
1368
+ let parsed;
1369
+ try {
1370
+ parsed = JSON.parse(value);
1371
+ } catch {
1372
+ parsed = void 0;
1373
+ }
1374
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "object" && item !== null && typeof item.file === "string")) {
1375
+ const items = parsed;
1376
+ const firstFile = items[0]?.file;
1377
+ const slash = typeof firstFile === "string" ? firstFile.indexOf("/") : -1;
1378
+ const subfolder = typeof firstFile === "string" && slash > 0 ? firstFile.slice(0, slash) : void 0;
1379
+ const added = items.map((item, i) => ({
1380
+ id: newParamId(),
1381
+ name: genParamName(`media_${i + 1}`),
1382
+ label: `${t("wfRefSlot")}${i + 1}`,
1383
+ type: "string",
1384
+ nodeId: advNode,
1385
+ inputKey: advInput,
1386
+ default: typeof item.name === "string" ? item.name : "",
1387
+ upload: "media",
1388
+ subfolder
1389
+ }));
1390
+ onChange([...params, ...added]);
1391
+ setAdvanced(false);
1392
+ setAdvNode("");
1393
+ setAdvInput("");
1394
+ setAdvLabel("");
1395
+ return;
1396
+ }
1397
+ }
1398
+ let options;
1399
+ let upload;
1400
+ if (typeof advNodeInfo.class_type === "string") try {
1401
+ const result = await postJson("/comfyui/workflows/input-options", {
1402
+ classType: advNodeInfo.class_type,
1403
+ inputKey: advInput
1404
+ });
1405
+ if (result.ok === true && Array.isArray(result.options) && result.options.length > 0) options = result.options;
1406
+ upload = result.upload;
1407
+ } catch {}
1408
+ onChange([...params, {
1409
+ id: newParamId(),
1410
+ name: genParamName(advInput),
1411
+ label: advLabel !== "" ? advLabel : `${advNode} · ${advInput}`,
1412
+ type,
1413
+ nodeId: advNode,
1414
+ inputKey: advInput,
1415
+ default: typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : "",
1416
+ options,
1417
+ upload
1418
+ }]);
1419
+ setAdvanced(false);
1420
+ setAdvNode("");
1421
+ setAdvInput("");
1422
+ setAdvLabel("");
1423
+ };
1424
+ /** Update a parameter's default; DynamicCombo parents refresh their child (size) parameter. */
1425
+ const updateDefault = (index, value) => {
1426
+ update(index, { default: value });
1427
+ const param = params[index];
1428
+ if (param === void 0 || typeof value !== "string") return;
1429
+ if (param.options === void 0 || param.options.length === 0) return;
1430
+ const childIndex = params.findIndex((entry) => entry.nodeId === param.nodeId && entry.inputKey.startsWith(`${param.inputKey}.`));
1431
+ if (childIndex === -1) return;
1432
+ const child = params[childIndex];
1433
+ if (child === void 0) return;
1434
+ const rawNode = workflow[child.nodeId];
1435
+ postJson("/comfyui/workflows/input-options", {
1436
+ classType: typeof rawNode?.class_type === "string" ? rawNode.class_type : "",
1437
+ inputKey: param.inputKey,
1438
+ parentValue: value
1439
+ }).then((result) => {
1440
+ const data = result;
1441
+ if (data.ok === true && data.child !== void 0 && childIndex < params.length) update(childIndex, {
1442
+ options: data.child.options,
1443
+ default: data.child.default
1444
+ });
1445
+ }).catch(() => void 0);
1446
+ };
1447
+ return (0, react.createElement)("div", { className: "dsc-params" }, params.length === 0 ? (0, react.createElement)("div", { className: "dsc-hint" }, t("wfParamsEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, params.map((param, index) => (0, react.createElement)("div", {
1448
+ key: param.id,
1449
+ className: "dsc-param-row"
1450
+ }, (0, react.createElement)("div", { className: "dsc-param-fields" }, (0, react.createElement)("input", {
1451
+ className: "dsc-input dsc-input--sm",
1452
+ value: param.label,
1453
+ placeholder: t("wfParamLabel"),
1454
+ onChange: (event) => update(index, { label: event.target.value })
1455
+ })), param.upload === "media" ? (0, react.createElement)("div", {
1456
+ className: "dsc-param-upload-wrap",
1457
+ onMouseEnter: () => {
1458
+ hoverRef.current = param.id;
1459
+ setHovered(param.id);
1460
+ },
1461
+ onMouseLeave: () => {
1462
+ hoverRef.current = null;
1463
+ setHovered(null);
1464
+ }
1465
+ }, (0, react.createElement)("input", {
1466
+ className: "dsc-input dsc-input--sm dsc-param-default",
1467
+ value: String(param.default),
1468
+ placeholder: t("wfMediaEmpty"),
1469
+ title: `${param.subfolder ?? ""}/`,
1470
+ onChange: (event) => updateDefault(index, event.target.value)
1471
+ }), (0, react.createElement)("label", {
1472
+ className: hovered === param.id ? "dsc-dropzone dsc-dropzone--over" : "dsc-dropzone",
1473
+ title: t("wfPasteHint"),
1474
+ tabIndex: 0,
1475
+ onDragOver: (event) => event.preventDefault(),
1476
+ onDrop: (event) => {
1477
+ event.preventDefault();
1478
+ const file = event.dataTransfer?.files?.[0];
1479
+ if (file !== void 0) uploadFile(param, file);
1480
+ },
1481
+ onPaste: (event) => {
1482
+ const file = event.clipboardData?.files?.[0];
1483
+ if (file !== void 0) uploadFile(param, file);
1484
+ }
1485
+ }, (0, react.createElement)("input", {
1486
+ type: "file",
1487
+ style: { display: "none" },
1488
+ onChange: (event) => {
1489
+ const file = event.target.files?.[0];
1490
+ if (file !== void 0) uploadFile(param, file);
1491
+ event.target.value = "";
1492
+ }
1493
+ }), uploading[param.id] === true ? t("wfParamsUploading") : t("wfParamsDrop")), (0, react.createElement)("button", {
1494
+ className: "dsc-btn",
1495
+ onClick: () => updateDefault(index, "")
1496
+ }, t("wfMediaClear"))) : param.upload !== void 0 ? (0, react.createElement)("div", {
1497
+ className: "dsc-param-upload-wrap",
1498
+ onMouseEnter: () => {
1499
+ hoverRef.current = param.id;
1500
+ setHovered(param.id);
1501
+ },
1502
+ onMouseLeave: () => {
1503
+ hoverRef.current = null;
1504
+ setHovered(null);
1505
+ }
1506
+ }, param.options !== void 0 && param.options.length > 0 ? (0, react.createElement)(UploadSelect, {
1507
+ value: String(param.default),
1508
+ options: param.options,
1509
+ kind: param.upload,
1510
+ onChange: (value) => updateDefault(index, value),
1511
+ onRefresh: () => refreshOptions(param)
1512
+ }) : null, (0, react.createElement)("label", {
1513
+ className: hovered === param.id ? "dsc-dropzone dsc-dropzone--over" : "dsc-dropzone",
1514
+ title: t("wfPasteHint"),
1515
+ tabIndex: 0,
1516
+ onDragOver: (event) => event.preventDefault(),
1517
+ onDrop: (event) => {
1518
+ event.preventDefault();
1519
+ const file = event.dataTransfer?.files?.[0];
1520
+ if (file !== void 0) uploadFile(param, file);
1521
+ },
1522
+ onPaste: (event) => {
1523
+ const file = event.clipboardData?.files?.[0];
1524
+ if (file !== void 0) uploadFile(param, file);
1525
+ }
1526
+ }, (0, react.createElement)("input", {
1527
+ type: "file",
1528
+ style: { display: "none" },
1529
+ onChange: (event) => {
1530
+ const file = event.target.files?.[0];
1531
+ if (file !== void 0) uploadFile(param, file);
1532
+ event.target.value = "";
1533
+ }
1534
+ }), uploading[param.id] === true ? t("wfParamsUploading") : `${t("wfParamsDrop")} ${t(`uploadKind_${param.upload}`)}`)) : param.options !== void 0 && param.options.length > 0 ? (0, react.createElement)("select", {
1535
+ className: "dsc-input dsc-input--sm dsc-param-default",
1536
+ value: String(param.default),
1537
+ onChange: (event) => updateDefault(index, event.target.value)
1538
+ }, param.options.map((option) => (0, react.createElement)("option", {
1539
+ key: String(option),
1540
+ value: String(option)
1541
+ }, String(option)))) : param.type === "string" ? (0, react.createElement)("textarea", {
1542
+ className: "dsc-input dsc-param-default",
1543
+ rows: 2,
1544
+ value: String(param.default),
1545
+ placeholder: t("wfParamDefault"),
1546
+ onChange: (event) => updateDefault(index, event.target.value)
1547
+ }) : (0, react.createElement)("input", {
1548
+ className: "dsc-input dsc-input--sm dsc-param-default",
1549
+ value: String(param.default),
1550
+ placeholder: t("wfParamDefault"),
1551
+ onChange: (event) => {
1552
+ const raw = event.target.value;
1553
+ updateDefault(index, param.type === "number" ? Number(raw) || 0 : raw);
1554
+ }
1555
+ }), (0, react.createElement)("div", { className: "dsc-param-meta" }, (0, react.createElement)("span", { className: "dsc-meta" }, `${param.type} · #${param.nodeId}.${param.inputKey}`), param.type === "number" ? (0, react.createElement)("label", { className: "dsc-param-random" }, (0, react.createElement)("input", {
1556
+ type: "checkbox",
1557
+ checked: param.random === true,
1558
+ onChange: (event) => update(index, { random: event.target.checked })
1559
+ }), (0, react.createElement)("span", null, t("wfParamRandom"))) : null, (0, react.createElement)("button", {
1560
+ className: "dsc-btn",
1561
+ onClick: () => onChange(params.filter((_, i) => i !== index))
1562
+ }, t("wfParamDelete")))))), (0, react.createElement)("div", { className: "dsc-toolbar" }, (0, react.createElement)("button", {
1563
+ className: "dsc-btn",
1564
+ disabled: recognizing,
1565
+ onClick: () => {
1566
+ recognize();
1567
+ }
1568
+ }, recognizing ? t("wfParamsRecognizing") : t("wfParamsRecognize")), (0, react.createElement)("button", {
1569
+ className: "dsc-btn",
1570
+ onClick: () => setAdvanced(!advanced)
1571
+ }, t("wfParamsAdd"))), recognizeError !== null ? (0, react.createElement)("div", { className: "dsc-hint dsc-hint--error" }, recognizeError) : null, advanced ? (0, react.createElement)("div", { className: "dsc-param-advanced" }, (0, react.createElement)("div", { className: "dsc-param-fields" }, (0, react.createElement)("select", {
1572
+ className: "dsc-input dsc-input--sm",
1573
+ value: advNode,
1574
+ onChange: (event) => {
1575
+ setAdvNode(event.target.value);
1576
+ setAdvInput("");
1577
+ }
1578
+ }, (0, react.createElement)("option", { value: "" }, "—"), nodeEntries.map(([id, node]) => (0, react.createElement)("option", {
1579
+ key: id,
1580
+ value: id
1581
+ }, `#${id} ${node.class_type ?? ""}`))), advNodeInfo !== void 0 ? (0, react.createElement)("select", {
1582
+ className: "dsc-input dsc-input--sm",
1583
+ value: advInput,
1584
+ onChange: (event) => setAdvInput(event.target.value)
1585
+ }, (0, react.createElement)("option", { value: "" }, "—"), advInputs.filter(([, value]) => value === null || typeof value !== "object").map(([key, value]) => (0, react.createElement)("option", {
1586
+ key,
1587
+ value: key
1588
+ }, `${key} (${value === null ? "null" : typeof value})`))) : null, (0, react.createElement)("input", {
1589
+ className: "dsc-input dsc-input--sm",
1590
+ value: advLabel,
1591
+ placeholder: t("wfParamCustomLabel"),
1592
+ onChange: (event) => setAdvLabel(event.target.value)
1593
+ }), (0, react.createElement)("button", {
1594
+ className: "dsc-btn",
1595
+ disabled: advNode === "" || advInput === "",
1596
+ onClick: () => {
1597
+ addAdvanced();
1598
+ }
1599
+ }, t("wfParamAdd"))), (0, react.createElement)("div", { className: "dsc-hint" }, t("wfParamCustomHint"))) : null);
1600
+ }
1601
+ function formatTs(ts) {
1602
+ const date = new Date(ts);
1603
+ return Number.isNaN(date.getTime()) ? ts : date.toLocaleString();
1604
+ }
1605
+ function formatBytes(bytes) {
1606
+ if (bytes === void 0) return "";
1607
+ if (bytes < 1024) return `${bytes} B`;
1608
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
1609
+ return `${(bytes / 1048576).toFixed(2)} MB`;
1610
+ }
1611
+ /** The panel shell: header (drag handle), tabs, and the active tab body. */
1612
+ function ComfyUIPanel({ t }) {
1613
+ const open = usePanelOpen();
1614
+ const tab = usePanelTab();
1615
+ const [geom, setGeom] = (0, react.useState)(loadGeom);
1616
+ const [dragging, setDragging] = (0, react.useState)(null);
1617
+ const [lightbox, setLightbox] = (0, react.useState)(null);
1618
+ const panelRef = (0, react.useRef)(null);
1619
+ const dragRef = (0, react.useRef)(null);
1620
+ const openPreview = (images, kinds, index) => setLightbox({
1621
+ images,
1622
+ kinds,
1623
+ index
1624
+ });
1625
+ (0, react.useEffect)(() => {
1626
+ if (open) return;
1627
+ saveGeom(geom);
1628
+ }, [open]);
1629
+ const startDrag = (event, mode) => {
1630
+ if (event.button !== 0) return;
1631
+ if (mode === "move") {
1632
+ const target = event.target;
1633
+ if (target !== null && target.closest("button, input, textarea, select, a, label")) return;
1634
+ }
1635
+ const rect = panelRef.current?.getBoundingClientRect();
1636
+ if (rect === void 0) return;
1637
+ dragRef.current = {
1638
+ startX: event.clientX,
1639
+ startY: event.clientY,
1640
+ origX: rect.left,
1641
+ origY: rect.top,
1642
+ width: rect.width,
1643
+ height: rect.height
1644
+ };
1645
+ setDragging(mode);
1646
+ const el = event.currentTarget;
1647
+ if (el !== null && typeof el.setPointerCapture === "function") el.setPointerCapture(event.pointerId);
1648
+ };
1649
+ const moveDrag = (event) => {
1650
+ const d = dragRef.current;
1651
+ if (d === null) return;
1652
+ if (dragging === "move") setGeom((prev) => ({
1653
+ ...prev,
1654
+ x: Math.round(d.origX + event.clientX - d.startX),
1655
+ y: Math.round(d.origY + event.clientY - d.startY)
1656
+ }));
1657
+ else if (dragging === "resize") {
1658
+ const width = Math.max(300, Math.min(720, d.width + event.clientX - d.startX));
1659
+ const height = Math.max(200, Math.min(Math.max(300, window.innerHeight - 40), d.height + event.clientY - d.startY));
1660
+ setGeom((prev) => ({
1661
+ ...prev,
1662
+ width,
1663
+ height
1664
+ }));
1665
+ }
1666
+ };
1667
+ const endDrag = () => {
1668
+ if (dragRef.current === null) return;
1669
+ dragRef.current = null;
1670
+ setDragging(null);
1671
+ setGeom((prev) => {
1672
+ saveGeom(prev);
1673
+ return prev;
1674
+ });
1675
+ };
1676
+ if (!open) return null;
1677
+ const panelStyle = {};
1678
+ if (geom.x !== void 0 && geom.y !== void 0) {
1679
+ panelStyle.left = `${geom.x}px`;
1680
+ panelStyle.top = `${geom.y}px`;
1681
+ panelStyle.right = "auto";
1682
+ panelStyle.bottom = "auto";
1683
+ }
1684
+ if (geom.width !== void 0) panelStyle.width = `${geom.width}px`;
1685
+ if (geom.height !== void 0) panelStyle.height = `${geom.height}px`;
1686
+ return (0, react.createElement)("div", {
1687
+ ref: panelRef,
1688
+ className: `dsc-panel${dragging !== null ? " dsc-panel--dragging" : ""}`,
1689
+ style: panelStyle
1690
+ }, (0, react.createElement)("div", {
1691
+ className: "dsc-panel-head",
1692
+ onPointerDown: (event) => startDrag(event, "move"),
1693
+ onPointerMove: (event) => moveDrag(event),
1694
+ onPointerUp: () => endDrag(),
1695
+ onPointerCancel: () => endDrag()
1696
+ }, (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", {
1697
+ className: "dsc-panel-close",
1698
+ "aria-label": t("close"),
1699
+ onClick: () => panelStore.close()
1700
+ }, "✕")), (0, react.createElement)("div", { className: "dsc-tabs" }, (0, react.createElement)(TabButton, {
1701
+ t,
1702
+ active: tab === "workflows",
1703
+ label: t("tabWorkflows"),
1704
+ onClick: () => panelStore.setTab("workflows")
1705
+ }), (0, react.createElement)(TabButton, {
1706
+ t,
1707
+ active: tab === "assets",
1708
+ label: t("tabAssets"),
1709
+ onClick: () => panelStore.setTab("assets")
1710
+ }), (0, react.createElement)(TabButton, {
1711
+ t,
1712
+ active: tab === "queue",
1713
+ label: t("tabQueue"),
1714
+ onClick: () => panelStore.setTab("queue")
1715
+ })), (0, react.createElement)("div", { className: "dsc-panel-body" }, tab === "workflows" ? (0, react.createElement)(WorkflowsTab, { t }) : tab === "assets" ? (0, react.createElement)(AssetsTab, {
1716
+ t,
1717
+ onPreview: openPreview
1718
+ }) : (0, react.createElement)(QueueTab, {
1719
+ t,
1720
+ onPreview: openPreview
1721
+ })), lightbox !== null ? (0, react.createElement)(Lightbox, {
1722
+ t,
1723
+ images: lightbox.images,
1724
+ kinds: lightbox.kinds,
1725
+ index: lightbox.index,
1726
+ onClose: () => setLightbox(null),
1727
+ onIndex: (index) => setLightbox({
1728
+ images: lightbox.images,
1729
+ kinds: lightbox.kinds,
1730
+ index
1731
+ })
1732
+ }) : null, (0, react.createElement)("div", {
1733
+ className: "dsc-panel-resize",
1734
+ onPointerDown: (event) => startDrag(event, "resize"),
1735
+ onPointerMove: (event) => moveDrag(event),
1736
+ onPointerUp: () => endDrag(),
1737
+ onPointerCancel: () => endDrag()
1738
+ }));
1739
+ }
1740
+ function TabButton(props) {
1741
+ return (0, react.createElement)("button", {
1742
+ className: props.active ? "dsc-tab dsc-tab--active" : "dsc-tab",
1743
+ onClick: props.onClick
1744
+ }, props.label);
1745
+ }
1746
+ function ErrorNote({ t, message }) {
1747
+ return (0, react.createElement)("div", { className: "dsc-err" }, `${t("error")}: ${message}`);
1748
+ }
1749
+ /** Load area at the bottom of the workflow library, modeled on the ComfyUI
1750
+ * LoadImage node: a big preview of the currently selected image; clicking it
1751
+ * opens a picker (全部/已导入/已生成 tabs + paste-to-upload + masonry grid).
1752
+ * Selecting a file makes it the default source image for image-to-image runs
1753
+ * (the workflow's image parameter defaults to this file name). */
1754
+ function LoadArea(props) {
1755
+ const [current, setCurrent] = (0, react.useState)(null);
1756
+ const [files, setFiles] = (0, react.useState)([]);
1757
+ const [pickerOpen, setPickerOpen] = (0, react.useState)(false);
1758
+ const [busy, setBusy] = (0, react.useState)(false);
1759
+ const [error, setError] = (0, react.useState)(null);
1760
+ const refresh = (0, react.useCallback)(async () => {
1761
+ const data = await fetch("/comfyui/loadarea").then((response) => response.json()).catch(() => void 0);
1762
+ if (data?.ok === true) {
1763
+ setCurrent(data.current ?? null);
1764
+ setFiles(data.files ?? []);
1765
+ }
1766
+ }, []);
1767
+ (0, react.useEffect)(() => {
1768
+ refresh();
1769
+ }, [refresh]);
1770
+ const upload = async (file) => {
1771
+ setBusy(true);
1772
+ setError(null);
1773
+ try {
1774
+ const name = await uploadDedup(file);
1775
+ const sizeText = await recordMediaSize(file, name);
1776
+ await refresh();
1777
+ props.onNotice(`${props.t("wfUploaded")}:${name}${sizeText !== void 0 ? `(${sizeText})` : ""}`);
1778
+ return name;
1779
+ } catch (cause) {
1780
+ setError(cause instanceof Error ? cause.message : String(cause));
1781
+ throw cause;
1782
+ } finally {
1783
+ setBusy(false);
1784
+ }
1785
+ };
1786
+ const pick = async (file) => {
1787
+ await fetch("/comfyui/current-image", {
1788
+ method: "POST",
1789
+ headers: { "Content-Type": "application/json" },
1790
+ body: JSON.stringify({
1791
+ name: file.name,
1792
+ kind: file.kind,
1793
+ source: file.source
1794
+ })
1795
+ }).catch(() => void 0);
1796
+ setCurrent(file);
1797
+ setPickerOpen(false);
1798
+ props.onNotice(`${props.t("wfLoadPicked")}:${file.name}`);
1799
+ };
1800
+ return (0, react.createElement)("div", { className: "dsc-loadarea" }, (0, react.createElement)("div", { className: "dsc-loadarea-head" }, (0, react.createElement)("span", { className: "dsc-loadarea-title" }, props.t("wfLoadArea")), (0, react.createElement)("span", { className: "dsc-meta" }, props.t("wfLoadHint"))), (0, react.createElement)("button", {
1801
+ className: "dsc-loadarea-main",
1802
+ onClick: () => setPickerOpen(true),
1803
+ title: props.t("wfLoadHint"),
1804
+ disabled: busy
1805
+ }, current !== null ? (0, react.createElement)("img", {
1806
+ className: "dsc-loadarea-img",
1807
+ src: current.url,
1808
+ alt: "",
1809
+ loading: "lazy"
1810
+ }) : (0, react.createElement)("span", { className: "dsc-loadarea-empty" }, props.t("wfLoadEmpty")), (0, react.createElement)("span", { className: "dsc-loadarea-name" }, current !== null ? `${current.name}${current.width !== void 0 && current.height !== void 0 ? `(${current.width}×${current.height})` : ""}` : "")), pickerOpen ? (0, react.createElement)(LoadPicker, {
1811
+ t: props.t,
1812
+ files,
1813
+ current: current?.name ?? null,
1814
+ onUpload: upload,
1815
+ onPick: pick,
1816
+ onClose: () => setPickerOpen(false)
1817
+ }) : null, error !== null ? (0, react.createElement)("div", { className: "dsc-hint dsc-hint--error" }, error) : null);
1818
+ }
1819
+ /** The load-area picker dialog: tab bar (全部/已导入/已生成) with a
1820
+ * paste/click upload zone on the right, and a masonry image grid below. */
1821
+ function LoadPicker(props) {
1822
+ const [tab, setTab] = (0, react.useState)("all");
1823
+ const [type, setType] = (0, react.useState)("all");
1824
+ const hoverRef = (0, react.useRef)(false);
1825
+ const [uploadFlash, setUploadFlash] = (0, react.useState)(null);
1826
+ const [scrollTo, setScrollTo] = (0, react.useState)(null);
1827
+ const cardRefs = (0, react.useRef)(/* @__PURE__ */ new Map());
1828
+ const flashTimer = (0, react.useRef)(void 0);
1829
+ (0, react.useEffect)(() => () => {
1830
+ if (flashTimer.current !== void 0) window.clearTimeout(flashTimer.current);
1831
+ }, []);
1832
+ (0, react.useEffect)(() => {
1833
+ const onPaste = (event) => {
1834
+ if (!hoverRef.current) return;
1835
+ const file = event.clipboardData?.files?.[0];
1836
+ if (file === void 0) return;
1837
+ event.preventDefault();
1838
+ handleUpload(file);
1839
+ };
1840
+ document.addEventListener("paste", onPaste);
1841
+ return () => document.removeEventListener("paste", onPaste);
1842
+ });
1843
+ /** Upload, then flash a confirmation and scroll the grid to the new file. */
1844
+ const handleUpload = async (file) => {
1845
+ try {
1846
+ const name = await props.onUpload(file);
1847
+ setScrollTo(name);
1848
+ setUploadFlash(`${props.t("wfUploadedToast")}:${name}`);
1849
+ if (flashTimer.current !== void 0) window.clearTimeout(flashTimer.current);
1850
+ flashTimer.current = window.setTimeout(() => setUploadFlash(null), 2500);
1851
+ } catch {}
1852
+ };
1853
+ const bySource = tab === "all" ? props.files : props.files.filter((file) => file.source === tab);
1854
+ const filtered = type === "all" ? bySource : bySource.filter((file) => file.kind === type);
1855
+ (0, react.useEffect)(() => {
1856
+ if (scrollTo === null) return;
1857
+ let found;
1858
+ for (const [key, el] of cardRefs.current) if (key.endsWith(`:${scrollTo}`)) {
1859
+ found = el;
1860
+ break;
1861
+ }
1862
+ if (found !== void 0) {
1863
+ found.scrollIntoView({
1864
+ block: "center",
1865
+ behavior: "smooth"
1866
+ });
1867
+ setScrollTo(null);
1868
+ }
1869
+ }, [scrollTo, filtered]);
1870
+ const columns = [
1871
+ [],
1872
+ [],
1873
+ []
1874
+ ];
1875
+ filtered.forEach((file, index) => {
1876
+ columns[index % 3].push(file);
1877
+ });
1878
+ const tabs = [
1879
+ {
1880
+ id: "all",
1881
+ label: props.t("wfTabAll")
1882
+ },
1883
+ {
1884
+ id: "imported",
1885
+ label: props.t("wfTabImported")
1886
+ },
1887
+ {
1888
+ id: "generated",
1889
+ label: props.t("wfTabGenerated")
1890
+ }
1891
+ ];
1892
+ const typeOptions = [
1893
+ {
1894
+ id: "all",
1895
+ label: props.t("wfTypeAll")
1896
+ },
1897
+ {
1898
+ id: "image",
1899
+ label: props.t("uploadKind_image")
1900
+ },
1901
+ {
1902
+ id: "video",
1903
+ label: props.t("uploadKind_video")
1904
+ },
1905
+ {
1906
+ id: "audio",
1907
+ label: props.t("uploadKind_audio")
1908
+ }
1909
+ ];
1910
+ return (0, react.createElement)("div", {
1911
+ className: "dsc-picker-overlay",
1912
+ onClick: props.onClose
1913
+ }, (0, react.createElement)("div", {
1914
+ className: "dsc-picker",
1915
+ onClick: (event) => event.stopPropagation()
1916
+ }, (0, react.createElement)("div", { className: "dsc-picker-bar" }, (0, react.createElement)("div", { className: "dsc-picker-tabs" }, tabs.map((entry) => (0, react.createElement)("button", {
1917
+ key: entry.id,
1918
+ className: `dsc-picker-tab${tab === entry.id ? " dsc-picker-tab--active" : ""}`,
1919
+ onClick: () => setTab(entry.id)
1920
+ }, entry.label))), (0, react.createElement)("label", {
1921
+ className: "dsc-dropzone dsc-picker-upload",
1922
+ title: props.t("wfPasteHint"),
1923
+ onMouseEnter: () => {
1924
+ hoverRef.current = true;
1925
+ },
1926
+ onMouseLeave: () => {
1927
+ hoverRef.current = false;
1928
+ },
1929
+ onDragOver: (event) => event.preventDefault(),
1930
+ onDrop: (event) => {
1931
+ event.preventDefault();
1932
+ const file = event.dataTransfer?.files?.[0];
1933
+ if (file !== void 0) handleUpload(file);
1934
+ }
1935
+ }, (0, react.createElement)("input", {
1936
+ type: "file",
1937
+ style: { display: "none" },
1938
+ onChange: (event) => {
1939
+ const file = event.target.files?.[0];
1940
+ if (file !== void 0) handleUpload(file);
1941
+ event.target.value = "";
1942
+ }
1943
+ }), props.t("wfPickerUpload")), (0, react.createElement)("select", {
1944
+ className: "dsc-picker-type",
1945
+ value: type,
1946
+ onChange: (event) => setType(event.target.value)
1947
+ }, typeOptions.map((option) => (0, react.createElement)("option", {
1948
+ key: option.id,
1949
+ value: option.id
1950
+ }, option.label)))), filtered.length === 0 ? (0, react.createElement)("div", { className: "dsc-picker-empty" }, props.t("wfLoadNoFiles")) : (0, react.createElement)("div", { className: "dsc-picker-grid" }, columns.map((column, columnIndex) => (0, react.createElement)("div", {
1951
+ key: columnIndex,
1952
+ className: "dsc-picker-col"
1953
+ }, column.map((file) => (0, react.createElement)("button", {
1954
+ key: `${file.source}:${file.name}`,
1955
+ className: `dsc-picker-card${file.name === props.current ? " dsc-picker-card--active" : ""}`,
1956
+ onClick: () => {
1957
+ props.onPick(file);
1958
+ },
1959
+ title: file.name,
1960
+ ref: (el) => {
1961
+ if (el !== null) cardRefs.current.set(`${file.source}:${file.name}`, el);
1962
+ else cardRefs.current.delete(`${file.source}:${file.name}`);
1963
+ }
1964
+ }, file.kind === "image" ? (0, react.createElement)("img", {
1965
+ className: "dsc-picker-thumb",
1966
+ src: file.url,
1967
+ alt: "",
1968
+ loading: "lazy"
1969
+ }) : (0, react.createElement)("span", { className: "dsc-picker-thumb dsc-picker-thumb--media" }, file.kind === "video" ? "🎞" : "🎵"), (0, react.createElement)("span", { className: "dsc-picker-name" }, file.name), file.width !== void 0 && file.height !== void 0 ? (0, react.createElement)("span", { className: "dsc-meta" }, `${file.width}×${file.height}`) : null))))), uploadFlash !== null ? (0, react.createElement)("div", { className: "dsc-picker-toast" }, uploadFlash) : null));
1970
+ }
1971
+ /** Tag editor: preset classification chips (图生图 etc.) toggle on/off, plus
1972
+ * free-form custom tags added with Enter and removed by clicking. */
1973
+ function TagEditor(props) {
1974
+ const [custom, setCustom] = (0, react.useState)("");
1975
+ const toggle = (tag) => {
1976
+ props.onChange(props.tags.includes(tag) ? props.tags.filter((entry) => entry !== tag) : [...props.tags, tag]);
1977
+ };
1978
+ const addCustom = () => {
1979
+ const tag = custom.trim();
1980
+ if (tag === "" || props.tags.includes(tag)) return;
1981
+ props.onChange([...props.tags, tag]);
1982
+ setCustom("");
1983
+ };
1984
+ const customTags = props.tags.filter((tag) => !PRESET_TAGS.includes(tag));
1985
+ return (0, react.createElement)("div", { className: "dsc-tag-editor" }, (0, react.createElement)("div", { className: "dsc-tag-row" }, PRESET_TAGS.map((tag) => (0, react.createElement)("button", {
1986
+ key: tag,
1987
+ className: `dsc-tag-chip${props.tags.includes(tag) ? " dsc-tag-chip--active" : ""}`,
1988
+ onClick: () => toggle(tag)
1989
+ }, tag))), customTags.length > 0 ? (0, react.createElement)("div", { className: "dsc-tag-row" }, customTags.map((tag) => (0, react.createElement)("button", {
1990
+ key: tag,
1991
+ className: "dsc-tag-chip dsc-tag-chip--active",
1992
+ title: props.t("wfTagRemove"),
1993
+ onClick: () => toggle(tag)
1994
+ }, `✕ ${tag}`))) : null, (0, react.createElement)("input", {
1995
+ className: "dsc-input dsc-input--sm dsc-tag-input",
1996
+ value: custom,
1997
+ placeholder: props.t("wfTagAdd"),
1998
+ onChange: (event) => setCustom(event.target.value),
1999
+ onKeyDown: (event) => {
2000
+ if (event.key === "Enter") {
2001
+ event.preventDefault();
2002
+ addCustom();
2003
+ }
2004
+ }
2005
+ }));
2006
+ }
2007
+ function WorkflowsTab({ t }) {
2008
+ const [list, setList] = (0, react.useState)(null);
2009
+ const [comfyui, setComfyui] = (0, react.useState)(null);
2010
+ const [error, setError] = (0, react.useState)(null);
2011
+ const [comfyuiError, setComfyuiError] = (0, react.useState)(null);
2012
+ const [notice, setNotice] = (0, react.useState)(null);
2013
+ const [editing, setEditing] = (0, react.useState)(null);
2014
+ const [busy, setBusy] = (0, react.useState)(false);
2015
+ const [viewing, setViewing] = (0, react.useState)(null);
2016
+ const [extract, setExtract] = (0, react.useState)(null);
2017
+ const [tagFilter, setTagFilter] = (0, react.useState)(null);
2018
+ const load = async () => {
2019
+ try {
2020
+ const data = await getJson("/comfyui/workflows");
2021
+ setList(data.workflows);
2022
+ setError(null);
2023
+ } catch (cause) {
2024
+ setError(cause instanceof Error ? cause.message : String(cause));
2025
+ }
2026
+ try {
2027
+ const data = await getJson("/comfyui/comfy-workflows");
2028
+ setComfyui(data.workflows);
2029
+ setComfyuiError(null);
2030
+ } catch (cause) {
2031
+ setComfyuiError(cause instanceof Error ? cause.message : String(cause));
2032
+ }
2033
+ };
2034
+ (0, react.useEffect)(() => {
2035
+ let cancelled = false;
2036
+ getJson("/comfyui/workflows").then((data) => {
2037
+ if (!cancelled) setList(data.workflows);
2038
+ }).catch((cause) => {
2039
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
2040
+ });
2041
+ getJson("/comfyui/comfy-workflows").then((data) => {
2042
+ if (!cancelled) setComfyui(data.workflows);
2043
+ }).catch((cause) => {
2044
+ if (!cancelled) setComfyuiError(cause instanceof Error ? cause.message : String(cause));
2045
+ });
2046
+ return () => {
2047
+ cancelled = true;
2048
+ };
2049
+ }, []);
2050
+ /** Persist the current draft; returns the saved workflow id, or null on failure (error already set). */
2051
+ const persist = async () => {
2052
+ if (editing === null) return null;
2053
+ setBusy(true);
2054
+ setError(null);
2055
+ setNotice(null);
2056
+ let workflow;
2057
+ if (typeof editing.workflow === "string") try {
2058
+ workflow = JSON.parse(editing.workflow);
2059
+ } catch {
2060
+ setError(t("wfJsonError"));
2061
+ setBusy(false);
2062
+ return null;
2063
+ }
2064
+ else workflow = editing.workflow;
2065
+ try {
2066
+ const body = {
2067
+ name: String(editing.name ?? ""),
2068
+ description: String(editing.description ?? ""),
2069
+ workflow,
2070
+ parameters: editing.parameters ?? [],
2071
+ tags: editing.tags ?? []
2072
+ };
2073
+ if (editing.id !== void 0) body.id = editing.id;
2074
+ return (await postJson("/comfyui/workflows", body)).workflow?.id ?? (typeof editing.id === "string" ? editing.id : null);
2075
+ } catch (cause) {
2076
+ setError(cause instanceof Error ? cause.message : String(cause));
2077
+ return null;
2078
+ } finally {
2079
+ setBusy(false);
2080
+ }
2081
+ };
2082
+ const save = async () => {
2083
+ if (await persist() === null) return;
2084
+ setEditing(null);
2085
+ await load();
2086
+ };
2087
+ /** Save the draft, then immediately queue it with the saved parameters. */
2088
+ const saveAndRun = async () => {
2089
+ const id = await persist();
2090
+ if (id === null) return;
2091
+ setEditing(null);
2092
+ await load();
2093
+ await run(id);
2094
+ };
2095
+ const run = async (id) => {
2096
+ setBusy(true);
2097
+ setError(null);
2098
+ setNotice(null);
2099
+ try {
2100
+ const result = await postJson("/comfyui/workflows/run", { id });
2101
+ setNotice(`${t("wfQueued")} ${shortId(result.promptId)}`);
2102
+ } catch (cause) {
2103
+ setError(cause instanceof Error ? cause.message : String(cause));
2104
+ } finally {
2105
+ setBusy(false);
2106
+ }
2107
+ };
2108
+ const remove = async (id) => {
2109
+ setBusy(true);
2110
+ setError(null);
2111
+ try {
2112
+ await postJson("/comfyui/workflows/delete", { id });
2113
+ await load();
2114
+ } catch (cause) {
2115
+ setError(cause instanceof Error ? cause.message : String(cause));
2116
+ } finally {
2117
+ setBusy(false);
2118
+ }
2119
+ };
2120
+ const openExtract = (file) => {
2121
+ setError(null);
2122
+ setExtract({
2123
+ file,
2124
+ analysis: null,
2125
+ error: null,
2126
+ mode: "split"
2127
+ });
2128
+ };
2129
+ const runExtract = async (state) => {
2130
+ setBusy(true);
2131
+ try {
2132
+ const data = await postJson("/comfyui/comfy-workflows/extract", {
2133
+ file: state.file,
2134
+ mode: state.mode
2135
+ });
2136
+ if (data.ok !== true) throw new Error(data.error ?? "failed to extract");
2137
+ setNotice(`${state.file} → ${t("wfExtractedNotice")}${data.warnings.length > 0 ? `(${t("error")}: ${data.warnings.join(";")})` : ""}`);
2138
+ setExtract(null);
2139
+ await load();
2140
+ } catch (cause) {
2141
+ setExtract({
2142
+ ...state,
2143
+ error: cause instanceof Error ? cause.message : String(cause)
2144
+ });
2145
+ } finally {
2146
+ setBusy(false);
2147
+ }
2148
+ };
2149
+ const importFile = (file) => {
2150
+ const reader = new FileReader();
2151
+ reader.onload = () => {
2152
+ try {
2153
+ const parsed = JSON.parse(String(reader.result));
2154
+ setEditing({
2155
+ name: file.name.replace(/\.json$/i, ""),
2156
+ workflow: parsed
2157
+ });
2158
+ setError(null);
2159
+ } catch {
2160
+ setError(t("wfJsonError"));
2161
+ }
2162
+ };
2163
+ reader.readAsText(file);
2164
+ };
2165
+ const view = async (file) => {
2166
+ setBusy(true);
2167
+ setError(null);
2168
+ try {
2169
+ const data = await getJson(`/comfyui/comfy-workflows?file=${encodeURIComponent(file)}`);
2170
+ if (data.ok !== true || data.workflow === void 0) throw new Error(data.error ?? "failed to read workflow");
2171
+ setViewing({
2172
+ file,
2173
+ graph: data.workflow
2174
+ });
2175
+ } catch (cause) {
2176
+ setError(cause instanceof Error ? cause.message : String(cause));
2177
+ } finally {
2178
+ setBusy(false);
2179
+ }
2180
+ };
2181
+ if (viewing !== null) return (0, react.createElement)(WorkflowView, {
2182
+ t,
2183
+ file: viewing.file,
2184
+ graph: viewing.graph,
2185
+ onBack: () => setViewing(null)
2186
+ });
2187
+ if (extract !== null) return (0, react.createElement)(ExtractDialog, {
2188
+ t,
2189
+ state: extract,
2190
+ onUpdate: setExtract,
2191
+ onRun: (state) => runExtract(state),
2192
+ onClose: () => setExtract(null)
2193
+ });
2194
+ if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
2195
+ t,
2196
+ message: error
2197
+ }));
2198
+ if (list === null) return (0, react.createElement)("div", { className: "dsc-meta" }, "…");
2199
+ if (editing !== null) return (0, react.createElement)("div", { className: "dsc-form" }, (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("wfName")), (0, react.createElement)("input", {
2200
+ className: "dsc-input",
2201
+ value: editing.name ?? "",
2202
+ onChange: (event) => setEditing({
2203
+ ...editing,
2204
+ name: event.target.value
2205
+ })
2206
+ })), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("wfDesc")), (0, react.createElement)("textarea", {
2207
+ className: "dsc-textarea",
2208
+ placeholder: t("wfDescPlaceholder"),
2209
+ value: editing.description ?? "",
2210
+ onChange: (event) => setEditing({
2211
+ ...editing,
2212
+ description: event.target.value
2213
+ })
2214
+ })), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("wfTags")), (0, react.createElement)(TagEditor, {
2215
+ t,
2216
+ tags: editing.tags ?? [],
2217
+ onChange: (tags) => setEditing({
2218
+ ...editing,
2219
+ tags
2220
+ })
2221
+ })), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("wfParams")), (0, react.createElement)(ParameterEditor, {
2222
+ t,
2223
+ params: editing.parameters ?? [],
2224
+ workflow: parseDraftWorkflow(editing.workflow),
2225
+ onChange: (parameters) => setEditing({
2226
+ ...editing,
2227
+ parameters
2228
+ })
2229
+ })), (0, react.createElement)("div", { className: "dsc-field" }, (0, react.createElement)("label", null, t("wfJson")), (0, react.createElement)("textarea", {
2230
+ className: "dsc-textarea dsc-textarea--json",
2231
+ value: typeof editing.workflow === "string" ? editing.workflow : JSON.stringify(editing.workflow ?? {}, null, 2),
2232
+ onChange: (event) => setEditing({
2233
+ ...editing,
2234
+ workflow: event.target.value
2235
+ })
2236
+ })), notice !== null ? (0, react.createElement)("div", { className: "dsc-ok" }, notice) : null, error !== null ? (0, react.createElement)(ErrorNote, {
2237
+ t,
2238
+ message: error
2239
+ }) : null, (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2240
+ className: "dsc-btn",
2241
+ disabled: busy,
2242
+ onClick: () => void save()
2243
+ }, t("wfSave")), (0, react.createElement)("button", {
2244
+ className: "dsc-btn",
2245
+ disabled: busy,
2246
+ onClick: () => void saveAndRun()
2247
+ }, t("wfSaveRun")), (0, react.createElement)("button", {
2248
+ className: "dsc-btn",
2249
+ disabled: busy,
2250
+ onClick: () => {
2251
+ setEditing(null);
2252
+ setError(null);
2253
+ }
2254
+ }, t("wfCancel"))));
2255
+ const comfyuiFold = (0, react.createElement)("details", { className: "dsc-fold" }, (0, react.createElement)("summary", null, `${t("wfComfyuiSection")} (${comfyui?.length ?? 0})`), (0, react.createElement)("div", { className: "dsc-fold-body" }, (0, react.createElement)("div", { className: "dsc-hint" }, t("wfComfyuiHint")), comfyuiError !== null ? (0, react.createElement)(ErrorNote, {
2256
+ t,
2257
+ message: comfyuiError
2258
+ }) : null, comfyui === null ? (0, react.createElement)("div", { className: "dsc-meta" }, "…") : comfyui.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("wfComfyuiEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, comfyui.map((workflow) => (0, react.createElement)("div", {
2259
+ key: workflow.name,
2260
+ className: "dsc-wf dsc-wf--comfyui",
2261
+ title: t("wfNameCopyHint"),
2262
+ onClick: () => {
2263
+ copyText(workflow.name).then((ok) => {
2264
+ if (ok) setNotice(`${t("wfNameCopied")}:${workflow.name}`);
2265
+ });
2266
+ }
2267
+ }, (0, react.createElement)("div", { className: "dsc-wf-col" }, (0, react.createElement)("div", {
2268
+ className: "dsc-wf-name",
2269
+ title: workflow.name
2270
+ }, workflow.name), (0, react.createElement)("div", { className: "dsc-wf-actions" }, (0, react.createElement)("button", {
2271
+ className: "dsc-btn",
2272
+ disabled: busy,
2273
+ onClick: (event) => {
2274
+ event.stopPropagation();
2275
+ view(workflow.name);
2276
+ }
2277
+ }, t("wfView")), (0, react.createElement)("button", {
2278
+ className: "dsc-btn",
2279
+ disabled: busy,
2280
+ onClick: (event) => {
2281
+ event.stopPropagation();
2282
+ openExtract(workflow.name);
2283
+ }
2284
+ }, t("wfExtract"))), (0, react.createElement)("div", { className: "dsc-meta" }, [formatBytes(workflow.size), workflow.modified !== void 0 ? formatTs(new Date(workflow.modified).toISOString()) : ""].filter(Boolean).join(" · "))), (0, react.createElement)("span", { className: workflow.extracted ? "dsc-badge dsc-badge--ok" : "dsc-badge dsc-badge--warn" }, workflow.extracted ? `${t("wfExtracted")} ${workflow.derived.length}` : t("wfNotExtracted")))))));
2285
+ const tagCounts = /* @__PURE__ */ new Map();
2286
+ for (const workflow of list) for (const tag of workflow.tags ?? []) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
2287
+ const visibleTags = [...tagCounts.entries()].sort((a, b) => {
2288
+ const pa = PRESET_TAGS.indexOf(a[0]);
2289
+ const pb = PRESET_TAGS.indexOf(b[0]);
2290
+ if (pa !== -1 && pb !== -1) return pa - pb;
2291
+ if (pa !== -1) return -1;
2292
+ if (pb !== -1) return 1;
2293
+ return b[1] - a[1];
2294
+ }).map(([tag]) => tag);
2295
+ const visibleList = tagFilter === null ? list : list.filter((workflow) => (workflow.tags ?? []).includes(tagFilter));
2296
+ const apiFold = (0, react.createElement)("details", {
2297
+ className: "dsc-fold",
2298
+ open: true
2299
+ }, (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", {
2300
+ className: `dsc-tag-chip${tagFilter === null ? " dsc-tag-chip--active" : ""}`,
2301
+ onClick: () => setTagFilter(null)
2302
+ }, `${t("wfTagAll")} (${list.length})`), visibleTags.map((tag) => (0, react.createElement)("button", {
2303
+ key: tag,
2304
+ className: `dsc-tag-chip${tagFilter === tag ? " dsc-tag-chip--active" : ""}`,
2305
+ onClick: () => setTagFilter(tagFilter === tag ? null : tag)
2306
+ }, `${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", {
2307
+ key: workflow.id,
2308
+ className: "dsc-wf",
2309
+ title: t("wfNameCopyHint"),
2310
+ onClick: () => {
2311
+ copyText(workflow.name).then((ok) => {
2312
+ if (ok) setNotice(`${t("wfNameCopied")}:${workflow.name}`);
2313
+ });
2314
+ }
2315
+ }, (0, react.createElement)("div", { className: "dsc-wf-top" }, (0, react.createElement)("div", {
2316
+ className: "dsc-wf-name",
2317
+ title: workflow.name
2318
+ }, workflow.name), (workflow.tags ?? []).length > 0 ? (0, react.createElement)("div", { className: "dsc-wf-tags" }, (workflow.tags ?? []).map((tag) => (0, react.createElement)("span", {
2319
+ key: tag,
2320
+ className: "dsc-tag-chip dsc-tag-chip--mini"
2321
+ }, 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", {
2322
+ className: "dsc-btn",
2323
+ disabled: busy,
2324
+ onClick: (event) => {
2325
+ event.stopPropagation();
2326
+ run(workflow.id);
2327
+ }
2328
+ }, t("wfRun")), (0, react.createElement)("button", {
2329
+ className: "dsc-btn",
2330
+ disabled: busy,
2331
+ onClick: (event) => {
2332
+ event.stopPropagation();
2333
+ setEditing({
2334
+ ...workflow,
2335
+ workflow: JSON.stringify(workflow.workflow, null, 2),
2336
+ parameters: workflow.parameters ?? [],
2337
+ tags: workflow.tags ?? []
2338
+ });
2339
+ setError(null);
2340
+ }
2341
+ }, t("wfEdit")), (0, react.createElement)("button", {
2342
+ className: "dsc-btn",
2343
+ disabled: busy,
2344
+ onClick: (event) => {
2345
+ event.stopPropagation();
2346
+ remove(workflow.id);
2347
+ }
2348
+ }, t("wfDelete"))))))));
2349
+ return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "dsc-toolbar" }, (0, react.createElement)("button", {
2350
+ className: "dsc-btn",
2351
+ disabled: busy,
2352
+ onClick: () => {
2353
+ setEditing({
2354
+ workflow: {},
2355
+ parameters: [],
2356
+ tags: []
2357
+ });
2358
+ setError(null);
2359
+ }
2360
+ }, t("wfAdd")), (0, react.createElement)("label", { className: "dsc-btn" }, t("wfImportFile"), (0, react.createElement)("input", {
2361
+ type: "file",
2362
+ accept: ".json,application/json",
2363
+ style: { display: "none" },
2364
+ onChange: (event) => {
2365
+ const file = event.target.files?.[0];
2366
+ if (file !== void 0) importFile(file);
2367
+ event.target.value = "";
2368
+ }
2369
+ })), (0, react.createElement)("button", {
2370
+ className: "dsc-btn",
2371
+ disabled: busy,
2372
+ onClick: () => void load()
2373
+ }, t("wfRefresh"))), notice !== null ? (0, react.createElement)("div", { className: "dsc-ok" }, notice) : null, comfyuiFold, apiFold, (0, react.createElement)(LoadArea, {
2374
+ t,
2375
+ onNotice: setNotice
2376
+ }));
2377
+ }
2378
+ /** Extract-mode chooser: shows the canvas analysis and lets the user pick how
2379
+ * to extract runnable API workflows from a ComfyUI-side graph. */
2380
+ function ExtractDialog(props) {
2381
+ const { t, state, onUpdate, onRun, onClose } = props;
2382
+ (0, react.useEffect)(() => {
2383
+ if (state.analysis === null && state.error === null) {
2384
+ let cancelled = false;
2385
+ getJson(`/comfyui/comfy-workflows/analyze?file=${encodeURIComponent(state.file)}`).then((data) => {
2386
+ if (cancelled) return;
2387
+ if (data.ok !== true || data.analysis === void 0) onUpdate({
2388
+ ...state,
2389
+ error: data.error ?? "failed to analyze"
2390
+ });
2391
+ else onUpdate({
2392
+ ...state,
2393
+ analysis: data.analysis,
2394
+ mode: data.analysis.mode === "single" ? "all" : "split"
2395
+ });
2396
+ }).catch((cause) => {
2397
+ if (!cancelled) onUpdate({
2398
+ ...state,
2399
+ error: cause instanceof Error ? cause.message : String(cause)
2400
+ });
2401
+ });
2402
+ return () => {
2403
+ cancelled = true;
2404
+ };
2405
+ }
2406
+ });
2407
+ const modes = [
2408
+ {
2409
+ mode: "all",
2410
+ label: t("wfExtractModeAll"),
2411
+ hint: t("wfExtractModeAllHint")
2412
+ },
2413
+ {
2414
+ mode: "split",
2415
+ label: t("wfExtractModeSplit"),
2416
+ hint: t("wfExtractModeSplitHint")
2417
+ },
2418
+ {
2419
+ mode: "main",
2420
+ label: t("wfExtractModeMain"),
2421
+ hint: t("wfExtractModeMainHint")
2422
+ }
2423
+ ];
2424
+ const analysis = state.analysis;
2425
+ return (0, react.createElement)("div", { className: "dsc-dialog" }, (0, react.createElement)("div", { className: "dsc-dialog-head" }, (0, react.createElement)("span", { className: "dsc-wf-name" }, `${t("wfExtractTitle")}: ${state.file}`), (0, react.createElement)("button", {
2426
+ className: "dsc-panel-close",
2427
+ "aria-label": t("close"),
2428
+ onClick: onClose
2429
+ }, "✕")), analysis === null ? (0, react.createElement)("div", { className: "dsc-meta" }, "…") : (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "dsc-hint" }, `${t("wfComponents")}: ${analysis.components.length} · ${t("wfBypassed")}: ${analysis.bypassedCount} · ${t("wfIsolated")}: ${analysis.isolated.length}`), analysis.components.length > 0 ? (0, react.createElement)("ul", { className: "dsc-node-list" }, analysis.components.map((component) => (0, react.createElement)("li", { key: String(component.index) }, `${t("wfComponentLabel")} ${component.index}(${component.size} ${t("wfNodes")})${component.groups.length > 0 ? `[${component.groups.slice(0, 3).join("+")}]` : ""}`))) : (0, react.createElement)("div", { className: "dsc-meta" }, t("wfExtractEmpty")), (0, react.createElement)("div", { className: "dsc-extract-modes" }, modes.map((entry) => (0, react.createElement)("label", {
2430
+ key: entry.mode,
2431
+ className: "dsc-mode"
2432
+ }, (0, react.createElement)("input", {
2433
+ type: "radio",
2434
+ name: "extract-mode",
2435
+ checked: state.mode === entry.mode,
2436
+ onChange: () => onUpdate({
2437
+ ...state,
2438
+ mode: entry.mode
2439
+ })
2440
+ }), (0, react.createElement)("span", { className: "dsc-mode-label" }, entry.label), (0, react.createElement)("span", { className: "dsc-hint" }, entry.hint)))), state.error !== null ? (0, react.createElement)(ErrorNote, {
2441
+ t,
2442
+ message: state.error
2443
+ }) : null, (0, react.createElement)("div", { className: "dsc-row" }, (0, react.createElement)("button", {
2444
+ className: "dsc-btn",
2445
+ disabled: analysis.components.length === 0,
2446
+ onClick: () => void onRun(state)
2447
+ }, t("wfExtractRun")), (0, react.createElement)("button", {
2448
+ className: "dsc-btn",
2449
+ onClick: onClose
2450
+ }, t("wfCancel")))));
2451
+ }
2452
+ /** Read-only inspection of a ComfyUI-side saved workflow: node list + JSON. */
2453
+ function WorkflowView(props) {
2454
+ const nodes = Array.isArray(props.graph.nodes) ? props.graph.nodes.filter((node) => node.mode !== 4 && typeof node.type === "string" && node.type !== "") : [];
2455
+ const json = JSON.stringify(props.graph, null, 2);
2456
+ return (0, react.createElement)("div", { className: "dsc-view" }, (0, react.createElement)("div", { className: "dsc-view-head" }, (0, react.createElement)("span", { className: "dsc-wf-name" }, props.file), (0, react.createElement)("button", {
2457
+ className: "dsc-btn",
2458
+ onClick: props.onBack
2459
+ }, props.t("wfViewBack"))), (0, react.createElement)("div", { className: "dsc-view-label" }, `${props.t("wfViewNodes")} (${nodes.length})`), nodes.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, props.t("wfViewEmpty")) : (0, react.createElement)("ul", { className: "dsc-node-list" }, nodes.map((node) => (0, react.createElement)("li", { key: String(node.id) }, `${String(node.id)} · ${String(node.type)}`))), (0, react.createElement)("div", { className: "dsc-view-label" }, props.t("wfViewJson")), (0, react.createElement)("textarea", {
2460
+ className: "dsc-textarea dsc-textarea--json dsc-textarea--view",
2461
+ readOnly: true,
2462
+ value: json
2463
+ }));
2464
+ }
2465
+ /** Tab 2: asset preview — thumbnails of everything the plugin generated. */
2466
+ function AssetsTab({ t, onPreview }) {
2467
+ const [assets, setAssets] = (0, react.useState)(null);
2468
+ const [error, setError] = (0, react.useState)(null);
2469
+ const [filter, setFilter] = (0, react.useState)(() => panelStore.getAssetFilter());
2470
+ const load = async () => {
2471
+ try {
2472
+ const data = await getJson("/comfyui/assets");
2473
+ setAssets(data.assets);
2474
+ setError(null);
2475
+ } catch (cause) {
2476
+ setError(cause instanceof Error ? cause.message : String(cause));
2477
+ }
2478
+ };
2479
+ (0, react.useEffect)(() => {
2480
+ let cancelled = false;
2481
+ getJson("/comfyui/assets").then((data) => {
2482
+ if (!cancelled) setAssets(data.assets);
2483
+ }).catch((cause) => {
2484
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
2485
+ });
2486
+ return () => {
2487
+ cancelled = true;
2488
+ };
2489
+ }, []);
2490
+ if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
2491
+ t,
2492
+ message: error
2493
+ }));
2494
+ if (assets === null) return (0, react.createElement)("div", { className: "dsc-meta" }, "…");
2495
+ const names = [...new Set(assets.map((asset) => asset.workflowName ?? ""))];
2496
+ const visible = filter === "" ? assets : assets.filter((asset) => (asset.workflowName ?? "") === filter);
2497
+ const previewItems = visible.flatMap((asset) => asset.media.map((item) => ({
2498
+ url: item.url,
2499
+ kind: item.kind
2500
+ })));
2501
+ const openAsset = (asset) => {
2502
+ const first = asset.media[0];
2503
+ const index = first !== void 0 ? previewItems.findIndex((item) => item.url === first.url) : 0;
2504
+ onPreview(previewItems.map((item) => item.url), previewItems.map((item) => item.kind), index < 0 ? 0 : index);
2505
+ };
2506
+ return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "dsc-toolbar" }, (0, react.createElement)("select", {
2507
+ className: "dsc-input dsc-input--inline",
2508
+ value: filter,
2509
+ onChange: (event) => setFilter(event.target.value)
2510
+ }, (0, react.createElement)("option", { value: "" }, t("assetAll")), names.map((name) => (0, react.createElement)("option", {
2511
+ key: name,
2512
+ value: name
2513
+ }, name === "" ? "—" : name))), (0, react.createElement)("button", {
2514
+ className: "dsc-btn",
2515
+ onClick: () => void load()
2516
+ }, t("refresh"))), visible.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("assetEmpty")) : (0, react.createElement)("div", { className: "dsc-assets-grid" }, visible.map((asset) => (0, react.createElement)(AssetThumb, {
2517
+ key: asset.promptId,
2518
+ asset,
2519
+ t,
2520
+ onClick: () => openAsset(asset)
2521
+ }))));
2522
+ }
2523
+ function AssetThumb(props) {
2524
+ const first = props.asset.media[0];
2525
+ const [broken, setBroken] = (0, react.useState)(false);
2526
+ if (first === void 0 || broken) return (0, react.createElement)("div", {
2527
+ className: "dsc-asset dsc-asset--empty",
2528
+ onClick: props.onClick
2529
+ }, (0, react.createElement)("div", null, props.t("assetGone")), (0, react.createElement)("div", { className: "dsc-meta" }, props.asset.workflowName ?? "—"));
2530
+ return (0, react.createElement)("div", {
2531
+ className: "dsc-asset",
2532
+ onClick: props.onClick
2533
+ }, first.kind === "video" ? (0, react.createElement)("video", {
2534
+ src: first.url,
2535
+ controls: true,
2536
+ preload: "metadata",
2537
+ onClick: (event) => event.stopPropagation()
2538
+ }) : (0, react.createElement)("img", {
2539
+ src: first.url,
2540
+ alt: first.filename,
2541
+ loading: "lazy",
2542
+ onError: () => setBroken(true)
2543
+ }), (0, react.createElement)("div", { className: "dsc-asset-meta" }, props.asset.workflowName ?? "—"));
2544
+ }
2545
+ /**
2546
+ * Tab 3: the task center — active tasks (pending / generating) shown as
2547
+ * cards with id, status and progress, plus history filtered by the
2548
+ * completed / failed / cancelled chips. Polled every 3 seconds.
2549
+ */
2550
+ function QueueTab({ t, onPreview }) {
2551
+ const [filter, setFilter] = (0, react.useState)("all");
2552
+ const [active, setActive] = (0, react.useState)(null);
2553
+ const [history, setHistory] = (0, react.useState)(null);
2554
+ const [error, setError] = (0, react.useState)(null);
2555
+ const [busy, setBusy] = (0, react.useState)(false);
2556
+ const load = async (current) => {
2557
+ try {
2558
+ const historyStatus = current === "all" ? "completed,failed,cancelled" : current;
2559
+ const [activeData, historyData] = await Promise.all([getJson("/comfyui/jobs?status=pending,in_progress&limit=100"), getJson(`/comfyui/jobs?status=${historyStatus}&limit=100`)]);
2560
+ if (activeData.ok !== true) throw new Error(activeData.error ?? t("queueError"));
2561
+ if (historyData.ok !== true) throw new Error(historyData.error ?? t("queueError"));
2562
+ setActive([...activeData.jobs ?? []].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0)));
2563
+ setHistory(historyData.jobs);
2564
+ setError(null);
2565
+ } catch (cause) {
2566
+ setError(cause instanceof Error ? cause.message : String(cause));
2567
+ }
2568
+ };
2569
+ (0, react.useEffect)(() => {
2570
+ load(filter);
2571
+ const timer = setInterval(() => void load(filter), 3e3);
2572
+ return () => clearInterval(timer);
2573
+ }, [filter]);
2574
+ const act = async (payload) => {
2575
+ setBusy(true);
2576
+ try {
2577
+ const data = await postJson("/comfyui/jobs/actions", payload);
2578
+ if (data.ok !== true) throw new Error(data.error ?? "action failed");
2579
+ await load(filter);
2580
+ } catch (cause) {
2581
+ setError(cause instanceof Error ? cause.message : String(cause));
2582
+ } finally {
2583
+ setBusy(false);
2584
+ }
2585
+ };
2586
+ if (error !== null) return (0, react.createElement)("div", null, (0, react.createElement)(ErrorNote, {
2587
+ t,
2588
+ message: error
2589
+ }));
2590
+ if (active === null || history === null) return (0, react.createElement)("div", { className: "dsc-meta" }, "…");
2591
+ const historyPreviewUrls = history.map(previewUrlOf);
2592
+ const previewImages = historyPreviewUrls.filter((url) => url !== null);
2593
+ const previewPrefix = [];
2594
+ {
2595
+ let acc = 0;
2596
+ for (const url of historyPreviewUrls) {
2597
+ previewPrefix.push(acc);
2598
+ if (url !== null) acc += 1;
2599
+ }
2600
+ }
2601
+ return (0, react.createElement)("div", { className: "dsc-list" }, (0, react.createElement)("div", { className: "dsc-queue-actions" }, (0, react.createElement)("button", {
2602
+ className: "dsc-btn",
2603
+ onClick: () => void act({ action: "clear" }),
2604
+ disabled: busy
2605
+ }, t("jobClearQueue")), (0, react.createElement)("button", {
2606
+ className: "dsc-btn",
2607
+ onClick: () => void act({ action: "clearHistory" }),
2608
+ disabled: busy
2609
+ }, t("jobClearHistory")), (0, react.createElement)("button", {
2610
+ className: "dsc-btn",
2611
+ onClick: () => void act({
2612
+ action: "free",
2613
+ unloadModels: true,
2614
+ freeMemory: true
2615
+ }),
2616
+ disabled: busy
2617
+ }, t("jobFree"))), (0, react.createElement)("div", { className: "dsc-section" }, (0, react.createElement)("div", { className: "dsc-section-head" }, (0, react.createElement)("span", { className: "dsc-section-title" }, `${t("jobActive")} (${active.length})`)), active.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("queueEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, active.map((job) => (0, react.createElement)(JobRow, {
2618
+ key: job.id,
2619
+ job,
2620
+ t,
2621
+ busy,
2622
+ act
2623
+ })))), (0, react.createElement)("div", { className: "dsc-section" }, (0, react.createElement)("div", { className: "dsc-section-head" }, (0, react.createElement)("span", { className: "dsc-section-title" }, `${t("jobFilterLabel")} (${history.length})`), (0, react.createElement)("div", { className: "dsc-queue-head" }, JOB_FILTERS.map((item) => (0, react.createElement)("button", {
2624
+ key: item.key,
2625
+ className: `dsc-chip${filter === item.key ? " dsc-chip--active" : ""}`,
2626
+ onClick: () => setFilter(item.key),
2627
+ disabled: busy
2628
+ }, t(item.label))))), history.length === 0 ? (0, react.createElement)("div", { className: "dsc-meta" }, t("queueEmpty")) : (0, react.createElement)("div", { className: "dsc-list" }, history.map((job, index) => (0, react.createElement)(JobRow, {
2629
+ key: job.id,
2630
+ job,
2631
+ t,
2632
+ busy,
2633
+ act,
2634
+ onPreview: historyPreviewUrls[index] !== null && previewImages.length > 0 ? () => onPreview(previewImages, previewImages.map(() => "image"), previewPrefix[index]) : void 0
2635
+ })))));
2636
+ }
2637
+ function jobBadge(status) {
2638
+ switch (status) {
2639
+ case "in_progress": return "dsc-badge--info";
2640
+ case "completed": return "dsc-badge--ok";
2641
+ case "failed": return "dsc-badge--danger";
2642
+ default: return "";
2643
+ }
2644
+ }
2645
+ const STATUS_LABELS = {
2646
+ pending: "jobFilterPending",
2647
+ in_progress: "jobFilterInProgress",
2648
+ completed: "jobFilterCompleted",
2649
+ failed: "jobFilterFailed",
2650
+ cancelled: "jobFilterCancelled"
2651
+ };
2652
+ /**
2653
+ * One task card: a compact row with a status badge, task name + short id,
2654
+ * duration (and error/progress for in-flight tasks), and a ⋯ menu with
2655
+ * per-state actions. Hover highlights the row. Rendered as a component
2656
+ * instance (h(JobRow, ...)) so its hooks stay per-card.
2657
+ */
2658
+ function JobRow(props) {
2659
+ const { job, t, busy, act, onPreview } = props;
2660
+ const [menuOpen, setMenuOpen] = (0, react.useState)(false);
2661
+ const menuRef = (0, react.useRef)(null);
2662
+ (0, react.useEffect)(() => {
2663
+ if (!menuOpen) return;
2664
+ const onDoc = (event) => {
2665
+ if (menuRef.current !== null && !menuRef.current.contains(event.target)) setMenuOpen(false);
2666
+ };
2667
+ document.addEventListener("mousedown", onDoc);
2668
+ return () => document.removeEventListener("mousedown", onDoc);
2669
+ }, [menuOpen]);
2670
+ const running = job.status === "in_progress";
2671
+ const terminal = job.status === "completed" || job.status === "failed" || job.status === "cancelled";
2672
+ const pct = job.progress !== null && job.progress !== void 0 && job.progress.max > 0 ? Math.min(100, Math.round(job.progress.value / job.progress.max * 100)) : null;
2673
+ const duration = job.executionStartTime !== null && job.executionEndTime !== null ? Math.max(0, Math.round(((job.executionEndTime ?? 0) - (job.executionStartTime ?? 0)) / 1e3)) : null;
2674
+ const errMsg = job.executionError?.exception_message ?? job.executionError?.message;
2675
+ const name = job.workflowName ?? job.workflowId;
2676
+ const preview = previewUrlOf(job);
2677
+ const close = () => setMenuOpen(false);
2678
+ const statusClass = job.status === "failed" ? " dsc-job-item--failed" : job.status === "cancelled" ? " dsc-job-item--cancelled" : "";
2679
+ return (0, react.createElement)("div", { className: `dsc-job-item${statusClass}` }, preview !== null ? (0, react.createElement)("img", {
2680
+ src: preview,
2681
+ alt: "",
2682
+ loading: "lazy",
2683
+ className: onPreview !== void 0 ? "dsc-job-preview dsc-job-preview--clickable" : "dsc-job-preview",
2684
+ onClick: onPreview
2685
+ }) : null, (0, react.createElement)("div", { className: "dsc-job-main" }, (0, react.createElement)("div", {
2686
+ className: "dsc-job-name",
2687
+ title: name ?? void 0
2688
+ }, name !== null && name !== void 0 && name !== "" ? name : "—"), (0, react.createElement)("div", { className: "dsc-job-mid" }, running && job.progress !== null && job.progress !== void 0 ? (0, react.createElement)("div", { className: "dsc-progress" }, (0, react.createElement)("div", { className: "dsc-progress-track" }, (0, react.createElement)("div", {
2689
+ className: "dsc-progress-fill",
2690
+ style: { width: `${pct ?? 0}%` }
2691
+ })), (0, react.createElement)("span", { className: "dsc-job-progress" }, `${job.progress.value}/${job.progress.max} · ${pct ?? 0}%`)) : null, (0, react.createElement)("div", { className: "dsc-job-mid-right" }, (0, react.createElement)("span", { className: `dsc-badge dsc-badge--status ${jobBadge(job.status)}` }, t(STATUS_LABELS[job.status] ?? job.status)), (0, react.createElement)("div", {
2692
+ ref: menuRef,
2693
+ className: "dsc-job-menu"
2694
+ }, (0, react.createElement)("button", {
2695
+ className: "dsc-job-menu-btn",
2696
+ "aria-label": t("jobMore"),
2697
+ onClick: () => setMenuOpen(!menuOpen)
2698
+ }, "⋯"), menuOpen ? (0, react.createElement)("div", { className: "dsc-job-menu-pop" }, job.status === "pending" ? (0, react.createElement)("button", { onClick: () => {
2699
+ act({
2700
+ action: "delete",
2701
+ ids: [job.id]
2702
+ });
2703
+ close();
2704
+ } }, t("jobDelete")) : null, running ? (0, react.createElement)("button", { onClick: () => {
2705
+ act({
2706
+ action: "interrupt",
2707
+ promptId: job.id
2708
+ });
2709
+ close();
2710
+ } }, t("jobInterrupt")) : null, terminal ? (0, react.createElement)("button", { onClick: () => {
2711
+ act({
2712
+ action: "deleteHistory",
2713
+ ids: [job.id]
2714
+ });
2715
+ close();
2716
+ } }, t("jobDelete")) : null, terminal ? (0, react.createElement)("button", { onClick: () => {
2717
+ act({
2718
+ action: "rerun",
2719
+ jobId: job.id
2720
+ });
2721
+ close();
2722
+ } }, t("jobRerun")) : null, terminal ? (0, react.createElement)("button", { onClick: () => {
2723
+ panelStore.setAssetFilter(name ?? "");
2724
+ panelStore.setTab("assets");
2725
+ close();
2726
+ } }, t("jobViewAssets")) : null) : null))), (0, react.createElement)("div", { className: "dsc-job-bottom" }, (0, react.createElement)("span", { className: "dsc-job-duration" }, `${t("jobDuration")} ${duration ?? 0}s`), errMsg !== void 0 && errMsg !== null ? (0, react.createElement)("span", {
2727
+ className: "dsc-job-error",
2728
+ title: errMsg
2729
+ }, errMsg) : null)));
2730
+ }
2731
+ //#endregion
2732
+ //#region src/client/styles.ts
2733
+ /**
2734
+ * dsh-comfyui stylesheet, injected once with plugin ownership. Colors come
2735
+ * from the host theme tokens (--dsw-alias-*), so the UI follows light/dark.
2736
+ */
2737
+ const CSS = `
2738
+ /* --- shared primitives --- */
2739
+ .dsc-card {
2740
+ border: 1px solid var(--dsw-alias-border-l1);
2741
+ border-radius: 8px;
2742
+ padding: 10px 12px;
2743
+ margin: 4px 0;
2744
+ font-size: 13px;
2745
+ line-height: 1.5;
2746
+ color: var(--dsw-alias-label-primary);
2747
+ }
2748
+ .dsc-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
2749
+ .dsc-badge {
2750
+ border-radius: 999px; padding: 1px 8px; font-size: 11px;
2751
+ background: var(--dsw-alias-bg-layer-1);
2752
+ color: var(--dsw-alias-label-secondary);
2753
+ }
2754
+ .dsc-badge--ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent); }
2755
+ .dsc-badge--err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); }
2756
+ .dsc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-top: 6px; }
2757
+ .dsc-media { border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
2758
+ .dsc-media img, .dsc-media video, .dsc-media audio { display: block; width: 100%; max-height: 320px; object-fit: contain; background: #000; }
2759
+ .dsc-media audio { height: 44px; }
2760
+ .dsc-media-img--clickable { cursor: zoom-in; }
2761
+ .dsc-media-other { display: block; padding: 8px 6px; font-size: 12px; color: var(--dsw-alias-label-secondary); word-break: break-all; }
2762
+ .dsc-media-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 3px 6px; }
2763
+ .dsc-media-size { font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
2764
+ .dsc-media-meta a { display: inline; padding: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); text-decoration: none; }
2765
+ .dsc-media-meta a:hover { color: var(--dsw-alias-label-primary); }
2766
+ .dsc-meta { color: var(--dsw-alias-label-secondary); font-size: 12px; }
2767
+ .dsc-err { color: var(--dsw-alias-state-error-primary); font-size: 12px; margin: 4px 0; }
2768
+ .dsc-ok { color: var(--dsw-alias-state-success-primary); font-size: 12px; margin: 4px 0; }
2769
+ .dsc-form { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
2770
+ .dsc-field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: var(--dsw-alias-label-primary); }
2771
+ .dsc-input, .dsc-textarea {
2772
+ width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 6px;
2773
+ border: 1px solid var(--dsw-alias-border-l1);
2774
+ background: transparent; color: inherit; font-size: 13px;
2775
+ }
2776
+ select.dsc-input { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); color-scheme: inherit; }
2777
+ select.dsc-input option { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); }
2778
+ .dsc-textarea { min-height: 90px; resize: vertical; font-family: ui-monospace, monospace; }
2779
+ .dsc-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 2px; }
2780
+ .dsc-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
2781
+ .dsc-btn {
2782
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px;
2783
+ background: transparent; color: inherit; padding: 5px 12px; font-size: 13px; cursor: pointer;
2784
+ }
2785
+ .dsc-btn:disabled { opacity: 0.5; cursor: default; }
2786
+ .dsc-btn--link { display: inline-block; text-decoration: none; }
2787
+ /* --- header action trigger --- */
2788
+ .dsc-trigger {
2789
+ display: inline-flex; align-items: center; gap: 5px;
2790
+ border: 1px solid transparent; background: transparent; color: var(--dsw-alias-label-secondary);
2791
+ cursor: pointer; font-size: 13px; padding: 3px 8px; border-radius: 6px;
2792
+ white-space: nowrap;
2793
+ }
2794
+ .dsc-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); border-color: var(--dsw-alias-border-l1); }
2795
+ .dsc-trigger[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
2796
+ .dsc-trigger-glyph { font-size: 13px; line-height: 1; }
2797
+ /* --- panel shell: floating window, percentage-anchored to the page header
2798
+ (top) and the composer (bottom), draggable by the header and resizable via
2799
+ the corner handle. Follows the host light/dark theme. --- */
2800
+ .dsc-panel {
2801
+ position: fixed;
2802
+ top: var(--dsc-panel-top, 9%);
2803
+ right: var(--dsc-panel-right, 1.5%);
2804
+ bottom: var(--dsc-panel-bottom, 2%);
2805
+ width: var(--dsc-panel-width, 400px);
2806
+ max-width: min(92vw, 560px); min-width: 300px;
2807
+ z-index: 900;
2808
+ display: flex; flex-direction: column;
2809
+ background: var(--dsw-alias-bg-layer-1);
2810
+ border: 1px solid var(--dsw-alias-border-l2);
2811
+ border-radius: 14px;
2812
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28), 0 2px 8px rgba(0, 0, 0, 0.12);
2813
+ color: var(--dsw-alias-label-primary);
2814
+ overflow: hidden;
2815
+ user-select: none;
2816
+ }
2817
+ .dsc-panel--dragging { cursor: grabbing; }
2818
+ .dsc-panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); cursor: grab; touch-action: none; }
2819
+ .dsc-panel--dragging .dsc-panel-head { cursor: grabbing; }
2820
+ .dsc-panel-resize {
2821
+ position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
2822
+ cursor: nwse-resize; touch-action: none;
2823
+ background: linear-gradient(135deg, transparent 50%, var(--dsw-alias-label-tertiary) 50%);
2824
+ border-bottom-right-radius: 14px;
2825
+ opacity: 0.55;
2826
+ }
2827
+ .dsc-panel-resize:hover { opacity: 1; }
2828
+ .dsc-panel-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 14px; }
2829
+ .dsc-panel-close { border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-size: 14px; padding: 4px 8px; }
2830
+ .dsc-panel-close:hover { color: var(--dsw-alias-label-primary); }
2831
+ .dsc-tabs { display: flex; border-bottom: 1px solid var(--dsw-alias-border-l1); }
2832
+ .dsc-tab {
2833
+ flex: 1; padding: 8px 4px; background: transparent; border: none;
2834
+ border-bottom: 2px solid transparent; color: var(--dsw-alias-label-secondary);
2835
+ cursor: pointer; font-size: 13px;
2836
+ }
2837
+ .dsc-tab--active { color: var(--dsw-alias-label-primary); border-bottom-color: var(--dsw-alias-brand-primary); }
2838
+ .dsc-panel-body { flex: 1; overflow-y: auto; padding: 10px 12px; }
2839
+ .dsc-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
2840
+ .dsc-list { display: flex; flex-direction: column; gap: 8px; }
2841
+ /* --- workflows tab --- */
2842
+ .dsc-wf { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); transition: background 0.12s, border-color 0.12s; cursor: pointer; }
2843
+ .dsc-wf:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
2844
+ .dsc-wf-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
2845
+ .dsc-wf--comfyui { display: flex; align-items: center; gap: 10px; }
2846
+ .dsc-wf-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
2847
+ .dsc-wf-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2848
+ .dsc-wf-desc { color: var(--dsw-alias-label-secondary); font-size: 12px; margin: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2849
+ .dsc-wf-updated { color: var(--dsw-alias-label-tertiary); font-size: 11px; margin-top: 4px; }
2850
+ .dsc-wf-actions { display: flex; gap: 6px; margin-top: 6px; }
2851
+ .dsc-section { margin-top: 14px; }
2852
+ .dsc-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
2853
+ .dsc-section-title { font-weight: 600; font-size: 13px; }
2854
+ .dsc-fold { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; margin-top: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
2855
+ .dsc-fold summary { list-style: none; display: flex; align-items: center; gap: 6px; padding: 8px 10px; cursor: pointer; user-select: none; font-weight: 600; font-size: 13px; }
2856
+ .dsc-fold summary::-webkit-details-marker { display: none; }
2857
+ .dsc-fold summary::before { content: '▸'; font-size: 11px; color: var(--dsw-alias-label-secondary); transition: transform 0.15s; }
2858
+ .dsc-fold[open] summary::before { transform: rotate(90deg); }
2859
+ .dsc-fold summary:hover { background: var(--dsw-alias-bg-layer-2); }
2860
+ .dsc-fold-body { padding: 0 10px 10px; }
2861
+ .dsc-hint { color: var(--dsw-alias-label-secondary); font-size: 11px; margin: 2px 0 6px; white-space: pre-line; }
2862
+ .dsc-badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; }
2863
+ .dsc-badge--ok { color: var(--dsw-alias-state-success-primary); border: 1px solid var(--dsw-alias-state-success-primary); }
2864
+ .dsc-badge--warn { color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary); }
2865
+ .dsc-view-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
2866
+ .dsc-view-label { font-weight: 600; font-size: 12px; margin: 8px 0 4px; }
2867
+ .dsc-node-list { margin: 0; padding-left: 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); max-height: 180px; overflow-y: auto; }
2868
+ .dsc-node-list li { margin: 2px 0; }
2869
+ .dsc-textarea--view { min-height: 220px; resize: vertical; }
2870
+ .dsc-derived { margin: 6px 0; display: flex; flex-direction: column; gap: 4px; }
2871
+ .dsc-derived-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
2872
+ .dsc-derived-name { color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2873
+ .dsc-dialog { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px; }
2874
+ .dsc-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
2875
+ .dsc-extract-modes { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; }
2876
+ .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; }
2877
+ .dsc-mode input { accent-color: var(--dsw-alias-brand-primary); }
2878
+ .dsc-mode-label { font-size: 13px; }
2879
+ /* --- assets tab --- */
2880
+ .dsc-assets-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
2881
+ .dsc-asset { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden; cursor: pointer; background: var(--dsw-alias-bg-layer-1); }
2882
+ .dsc-asset img, .dsc-asset video { width: 100%; height: 110px; object-fit: cover; display: block; background: #000; }
2883
+ .dsc-asset--empty {
2884
+ height: 110px; padding: 8px; box-sizing: border-box;
2885
+ display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 4px;
2886
+ font-size: 11px; color: var(--dsw-alias-label-secondary); text-align: center;
2887
+ }
2888
+ .dsc-asset-meta { padding: 3px 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2889
+ .dsc-asset-detail-media { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
2890
+ .dsc-asset-detail-media img, .dsc-asset-detail-media video { width: 100%; max-height: 320px; object-fit: contain; background: #000; border-radius: 8px; }
2891
+ .dsc-asset-detail-file { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; }
2892
+ .dsc-input--inline { width: auto; }
2893
+ /* --- queue tab --- */
2894
+ .dsc-queue-head { display: flex; gap: 8px; flex-wrap: wrap; }
2895
+ .dsc-queue-item { display: flex; gap: 8px; align-items: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; font-size: 12px; flex-wrap: wrap; }
2896
+ .dsc-queue-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
2897
+ .dsc-queue-meta { color: var(--dsw-alias-label-secondary); font-size: 11px; }
2898
+ .dsc-queue-tracked { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
2899
+ .dsc-progress { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 90px; }
2900
+ .dsc-progress-track { flex: 1; height: 6px; border-radius: 3px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
2901
+ .dsc-progress-fill { height: 100%; background: #3b82f6; transition: width 0.4s; }
2902
+ .dsc-progress:hover .dsc-progress-fill { background: #2563eb; }
2903
+ .dsc-param-upload-wrap { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
2904
+ .dsc-param-upload-wrap select { flex: 1; min-width: 0; }
2905
+ .dsc-dropzone { flex: 1; min-width: 120px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 6px; padding: 5px 10px; font-size: 11px; color: var(--dsw-alias-label-secondary); text-align: center; cursor: pointer; transition: border-color 0.12s, color 0.12s; }
2906
+ .dsc-dropzone:hover, .dsc-dropzone--over { border-color: #3b82f6; color: #3b82f6; }
2907
+ .dsc-upload-select { position: relative; flex: 1; min-width: 0; }
2908
+ .dsc-upload-select-btn { display: flex; align-items: center; gap: 6px; width: 100%; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 3px 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); cursor: pointer; font-size: 12px; text-align: left; }
2909
+ .dsc-upload-select-btn:hover { border-color: var(--dsw-alias-border-l2); }
2910
+ .dsc-upload-select-thumb { width: 36px; height: 24px; object-fit: cover; border-radius: 3px; background: var(--dsw-alias-bg-layer-2); flex: none; }
2911
+ .dsc-upload-select-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
2912
+ .dsc-upload-select-pop { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); padding: 4px; }
2913
+ .dsc-upload-select-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 6px; cursor: pointer; font-size: 12px; color: var(--dsw-alias-label-primary); }
2914
+ .dsc-upload-select-item:hover { background: var(--dsw-alias-bg-layer-3); }
2915
+ .dsc-upload-dock { margin-top: 14px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }
2916
+ .dsc-upload-dock-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
2917
+ .dsc-upload-dock-title { font-weight: 600; font-size: 12px; color: var(--dsw-alias-label-primary); }
2918
+ .dsc-upload-dock-zone { min-height: 44px; display: flex; align-items: center; justify-content: center; }
2919
+ .dsc-upload-list { display: flex; flex-direction: column; gap: 4px; }
2920
+ .dsc-upload-item { display: flex; align-items: center; gap: 8px; }
2921
+ .dsc-upload-thumb { width: 40px; height: 28px; object-fit: cover; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); flex: none; }
2922
+ .dsc-upload-thumb--media { display: inline-flex; align-items: center; justify-content: center; font-size: 14px; }
2923
+ .dsc-upload-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--dsw-alias-label-primary); }
2924
+ .dsc-upload-select-item--active { background: var(--dsw-alias-bg-layer-3); }
2925
+ /* --- load area: big preview + picker dialog (ComfyUI LoadImage-like) --- */
2926
+ .dsc-loadarea { margin-top: 14px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }
2927
+ .dsc-loadarea-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
2928
+ .dsc-loadarea-title { font-weight: 600; font-size: 12px; color: var(--dsw-alias-label-primary); }
2929
+ .dsc-loadarea-main { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 10px; background: var(--dsw-alias-bg-layer-1); cursor: pointer; transition: border-color 0.12s; }
2930
+ .dsc-loadarea-main:hover { border-color: #3b82f6; }
2931
+ .dsc-loadarea-main:disabled { opacity: 0.6; cursor: default; }
2932
+ .dsc-loadarea-img { width: 100%; max-height: 260px; object-fit: contain; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); }
2933
+ .dsc-loadarea-empty { display: flex; align-items: center; justify-content: center; height: 120px; width: 100%; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-tertiary); font-size: 13px; }
2934
+ .dsc-loadarea-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--dsw-alias-label-primary); }
2935
+ /* picker overlay: dims the page, dialog floats in the center */
2936
+ .dsc-picker-overlay { position: fixed; inset: 0; z-index: 9990; background: rgba(6, 8, 12, 0.6); display: flex; align-items: center; justify-content: center; padding: 24px; }
2937
+ .dsc-picker { position: relative; display: flex; flex-direction: column; width: min(860px, 92vw); height: min(560px, 84vh); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: 0 16px 56px rgba(0, 0, 0, 0.5); overflow: hidden; }
2938
+ .dsc-picker-toast { position: absolute; top: 54px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); padding: 6px 14px; border-radius: 8px; font-size: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); pointer-events: none; max-width: 80%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2939
+ .dsc-picker-bar { display: flex; align-items: center; gap: 4px; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); flex-wrap: wrap; }
2940
+ .dsc-picker-tab { border: 1px solid transparent; border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; background: transparent; color: var(--dsw-alias-label-primary); }
2941
+ .dsc-picker-tab--active { background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
2942
+ .dsc-picker-tabs { display: flex; gap: 4px; }
2943
+ .dsc-picker-type { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 3px 8px; font-size: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); }
2944
+ .dsc-picker-upload { margin: 0 auto; flex: none; max-width: 200px; min-height: 32px; display: flex; align-items: center; justify-content: center; padding: 0 12px; font-size: 12px; white-space: nowrap; }
2945
+ .dsc-picker-empty { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
2946
+ .dsc-picker-grid { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 12px; display: flex; gap: 10px; align-items: flex-start; }
2947
+ .dsc-picker-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
2948
+ .dsc-picker-card { display: flex; flex-direction: column; gap: 4px; min-width: 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px; background: var(--dsw-alias-bg-layer-2); cursor: pointer; text-align: left; }
2949
+ .dsc-picker-card:hover { border-color: #3b82f6; }
2950
+ .dsc-picker-card--active { border-color: var(--dsw-alias-brand-primary); box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
2951
+ .dsc-picker-thumb { width: 100%; height: auto; object-fit: contain; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); }
2952
+ .dsc-picker-thumb--media { display: flex; align-items: center; justify-content: center; height: 100px; font-size: 22px; }
2953
+ .dsc-picker-name { font-size: 11px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2954
+ /* --- workflow tags: filter bar, chips, editor --- */
2955
+ .dsc-tag-filter { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 0 2px; }
2956
+ .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); }
2957
+ .dsc-tag-chip--active { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
2958
+ .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); }
2959
+ .dsc-wf-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
2960
+ .dsc-wf-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; min-width: 0; }
2961
+ .dsc-wf-top .dsc-wf-tags { margin-top: 0; flex: none; justify-content: flex-end; }
2962
+ .dsc-tag-editor { display: flex; flex-direction: column; gap: 6px; }
2963
+ .dsc-tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
2964
+ .dsc-tag-input { max-width: 260px; }
2965
+ .dsc-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); }
2966
+ .dsc-chip--active { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
2967
+ .dsc-badge--info { background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-layer-1); }
2968
+ .dsc-badge--danger { background: var(--dsw-alias-state-error-primary); color: #fff; }
2969
+ .dsc-queue-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
2970
+ .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); }
2971
+ .dsc-btn--sm { padding: 1px 6px; font-size: 11px; }
2972
+ .dsc-btn:disabled { opacity: 0.5; cursor: default; }
2973
+ .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; }
2974
+ .dsc-job-item:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
2975
+ .dsc-job-item--failed { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 55%, transparent); }
2976
+ .dsc-job-item--cancelled { border-color: var(--dsw-alias-border-l2); opacity: 0.85; }
2977
+ .dsc-badge--status { flex: none; font-weight: 600; }
2978
+ .dsc-job-preview { width: 56px; height: 56px; flex: none; border-radius: 6px; object-fit: cover; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
2979
+ .dsc-job-preview--clickable { cursor: zoom-in; }
2980
+ /* --- lightbox: floating local panel, no full-screen dim --- */
2981
+ .dsc-lightbox { position: fixed; inset: 0; z-index: 9999; background: transparent; display: flex; align-items: center; justify-content: center; }
2982
+ .dsc-lightbox-body { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; width: 90vw; padding: 12px 12px 10px; background: rgba(12, 14, 18, 0.88); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45); }
2983
+ .dsc-lightbox-img { width: 100%; height: 80vh; object-fit: contain; border-radius: 8px; }
2984
+ .dsc-lightbox-media { max-width: 100%; max-height: 80vh; border-radius: 8px; }
2985
+ .dsc-lightbox-meta { display: flex; align-items: center; gap: 14px; color: rgba(255, 255, 255, 0.75); font-size: 12px; }
2986
+ .dsc-lightbox-download { border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 999px; padding: 5px 16px; color: #fff; font-size: 12px; text-decoration: none; display: inline-block; }
2987
+ .dsc-lightbox-download:hover { background: rgba(255, 255, 255, 0.12); }
2988
+ .dsc-lightbox-close { position: absolute; top: clamp(4px, 0.6vw, 14px); right: clamp(4px, 0.6vw, 14px); z-index: 3; width: clamp(28px, 2.2vw, 48px); height: clamp(28px, 2.2vw, 48px); border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.35); background: transparent; color: #fff; font-size: clamp(13px, 1.1vw, 22px); line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; }
2989
+ .dsc-lightbox-close:hover { background: rgba(255, 255, 255, 0.12); }
2990
+ .dsc-lightbox-nav { position: absolute; top: 0; bottom: 0; z-index: 2; width: clamp(64px, 7vw, 128px); display: flex; align-items: center; background: transparent; border: none; color: #fff; font-size: clamp(38px, 3.4vw, 84px); line-height: 1; cursor: pointer; opacity: 0.6; padding: 0; }
2991
+ .dsc-lightbox-nav:hover { opacity: 1; color: var(--dsw-alias-brand-primary); background: rgba(255, 255, 255, 0.06); }
2992
+ .dsc-lightbox-nav--prev { left: 0; justify-content: flex-start; padding-left: clamp(14px, 1.8vw, 36px); }
2993
+ .dsc-lightbox-nav--next { right: 0; justify-content: flex-end; padding-right: clamp(14px, 1.8vw, 36px); }
2994
+ .dsc-job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
2995
+ .dsc-job-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
2996
+ .dsc-job-mid { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
2997
+ .dsc-job-mid-right { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }
2998
+ .dsc-job-bottom { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--dsw-alias-label-secondary); min-width: 0; }
2999
+ .dsc-job-duration { flex: none; }
3000
+ .dsc-job-progress { color: #3b82f6; font-variant-numeric: tabular-nums; }
3001
+ .dsc-job-menu { position: relative; flex: none; }
3002
+ .dsc-job-menu-btn { border: none; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; padding: 3px 7px; border-radius: 6px; cursor: pointer; }
3003
+ .dsc-job-menu-btn:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
3004
+ .dsc-job-menu-pop { position: absolute; right: 0; top: calc(100% + 4px); z-index: 950; min-width: 128px; padding: 4px; display: flex; flex-direction: column; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); }
3005
+ .dsc-job-menu-pop button { border: none; background: transparent; color: var(--dsw-alias-label-primary); text-align: left; font-size: 13px; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
3006
+ .dsc-job-menu-pop button:hover { background: var(--dsw-alias-bg-layer-3); }
3007
+ .dsc-job-error { color: var(--dsw-alias-state-error-primary); font-size: 11px; margin-left: auto; flex: 1; min-width: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3008
+ /* --- parameter editor --- */
3009
+ .dsc-params { display: flex; flex-direction: column; gap: 8px; }
3010
+ .dsc-param-row { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; }
3011
+ .dsc-param-fields { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
3012
+ .dsc-param-default { min-width: 0; }
3013
+ .dsc-param-meta { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
3014
+ .dsc-param-random { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
3015
+ .dsc-param-advanced { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--dsw-alias-border-l2); border-radius: 8px; padding: 8px; }
3016
+ `;
3017
+ /** Inject the stylesheet once (idempotent), owned by this plugin for HMR. */
3018
+ function injectStyles() {
3019
+ if (typeof document === "undefined") return;
3020
+ if (document.getElementById("dsh-comfyui-styles") !== null) return;
3021
+ const style = document.createElement("style");
3022
+ style.id = "dsh-comfyui-styles";
3023
+ style.setAttribute("data-plugin", "dsh-comfyui");
3024
+ style.textContent = CSS;
3025
+ document.head.append(style);
3026
+ }
3027
+ //#endregion
3028
+ //#region src/client/index.ts
3029
+ /**
3030
+ * dsh-comfyui client half: registers the comfyui_run tool card and the
3031
+ * ComfyUI settings page. Registered through slots.inject so contributions
3032
+ * wait on the real slot declarations and unwind with this plugin's fiber.
3033
+ */
3034
+ const name = "dsh-comfyui";
3035
+ const inject = ["slots"];
3036
+ function apply(ctx) {
3037
+ const t = makeT(getLang());
3038
+ ctx.effect(() => injectStyles(), "dsh-comfyui: styles");
3039
+ ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
3040
+ name: "tool.call.toolview",
3041
+ key: "comfyui_run"
3042
+ }, (props) => (0, react.createElement)(ComfyUICard, {
3043
+ t,
3044
+ ...props ?? {}
3045
+ })));
3046
+ ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
3047
+ name: "tool.call.toolview",
3048
+ key: "comfyui_workflow"
3049
+ }, (props) => (0, react.createElement)(ComfyUICard, {
3050
+ t,
3051
+ ...props ?? {}
3052
+ })));
3053
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
3054
+ name: "settings.section",
3055
+ id: "comfyui",
3056
+ order: 30,
3057
+ label: () => t("settingsTitle")
3058
+ }, (props) => (0, react.createElement)(ComfyUISettings, {
3059
+ t,
3060
+ ...props ?? {}
3061
+ })));
3062
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
3063
+ name: "shell.overlay",
3064
+ id: "comfyui.panel",
3065
+ order: 20,
3066
+ label: () => t("panelTitle")
3067
+ }, (props) => (0, react.createElement)(ComfyUIPanel, {
3068
+ t,
3069
+ ...props ?? {}
3070
+ })));
3071
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
3072
+ name: "conversation.session.header.actions",
3073
+ id: "comfyui",
3074
+ order: 100,
3075
+ label: () => t("panelTitle")
3076
+ }, (props) => (0, react.createElement)(ComfyUITrigger, {
3077
+ t,
3078
+ ...props ?? {}
3079
+ })));
3080
+ }
3081
+ //#endregion
3082
+ exports.apply = apply;
3083
+ exports.inject = inject;
3084
+ exports.name = name;
3085
+ return module.exports;
3086
+ }
3087
+ });
3088
+
3089
+ //# sourceMappingURL=client.js.map