import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; const STYLE_ID = "o1key-cases-styles"; const DIALOG_ID = "o1key-cases-dialog"; const CASES_API = "/o1key/cases"; let cases = []; let dialogRoot = null; const CSS = ` #o1key-cases-dialog{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.52);color:#ddd;font-family:inherit} #o1key-cases-modal{width:min(720px,calc(100vw - 40px));height:min(560px,calc(100vh - 56px));display:flex;flex-direction:column;overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:var(--comfy-menu-bg,#202020);box-shadow:0 24px 80px rgba(0,0,0,.48)} #o1key-cases-header{height:52px;padding:0 16px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;flex-shrink:0} #o1key-cases-title{font-size:15px;font-weight:700;color:#eee} #o1key-cases-actions{display:flex;gap:8px;align-items:center} .o1c-icon-btn{width:30px;height:30px;border:1px solid rgba(255,255,255,.12);border-radius:6px;background:rgba(255,255,255,.04);color:#aaa;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s,border-color .15s,color .15s;font-family:inherit} .o1c-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)} #o1key-cases-list{flex:1;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px} #o1key-cases-list::-webkit-scrollbar{width:6px} #o1key-cases-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px} .o1c-item{border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:11px 12px;background:rgba(255,255,255,.03);color:inherit;text-align:left;cursor:pointer;transition:background .12s,border-color .12s;font-family:inherit} .o1c-item:hover{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.16)} .o1c-item-title{font-size:13px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .o1c-item-meta{margin-top:6px;color:#7e7e7e;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #o1key-cases-empty{flex:1;min-height:180px;display:flex;align-items:center;justify-content:center;color:#777;font-size:13px} #o1key-cases-status{height:30px;padding:0 16px;color:#858585;font-size:12px;line-height:30px;border-top:1px solid rgba(255,255,255,.08);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0} #o1key-cases-status[data-kind="error"]{color:#df7777} #o1key-cases-status[data-kind="success"]{color:#63bf8a} `; function injectStyles() { if (document.getElementById(STYLE_ID)) return; const el = document.createElement("style"); el.id = STYLE_ID; el.textContent = CSS; document.head.appendChild(el); } function iconRefresh() { return ``; } function iconClose() { return ``; } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function setStatus(message, kind = "muted") { const el = dialogRoot?.querySelector("#o1key-cases-status"); if (!el) return; el.textContent = message || ""; el.dataset.kind = kind; } async function loadCases() { try { const resp = await api.fetchApi(CASES_API); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); cases = Array.isArray(data.cases) ? data.cases : []; return true; } catch (e) { console.warn("[o1key cases] failed to load cases", e); cases = []; return false; } } function renderCasesList() { const list = dialogRoot?.querySelector("#o1key-cases-list"); if (!list) return; if (!cases.length) { list.innerHTML = `
暂无案例
`; return; } list.innerHTML = cases.map(item => ` `).join(""); list.querySelectorAll(".o1c-item").forEach(btn => { btn.addEventListener("click", () => loadCaseWorkflow(btn.dataset.filename)); }); } function unwrapWorkflow(payload) { const data = payload?.case ?? payload; if (data?.workflow && typeof data.workflow === "object") return data.workflow; return data; } async function loadCaseWorkflow(filename) { if (!filename) return; setStatus("正在加载案例..."); try { const resp = await api.fetchApi(`/o1key/case?file=${encodeURIComponent(filename)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const payload = await resp.json(); const workflow = unwrapWorkflow(payload); if (!workflow || typeof workflow !== "object") throw new Error("invalid workflow"); if (typeof app.loadGraphData === "function") { await app.loadGraphData(workflow); } else if (app.graph?.configure) { app.graph.configure(workflow); app.graph.setDirtyCanvas?.(true, true); } else { throw new Error("loadGraphData unavailable"); } setStatus("案例已加载", "success"); setTimeout(closeCasesDialog, 250); } catch (e) { console.warn("[o1key cases] failed to load case", e); setStatus("案例加载失败", "error"); } } async function refreshCases() { setStatus("正在刷新案例..."); const ok = await loadCases(); renderCasesList(); if (ok) { setStatus(cases.length ? `已加载 ${cases.length} 个案例` : "暂无案例"); } else { setStatus("案例加载失败,请重启 ComfyUI 后再试", "error"); } } function closeCasesDialog() { document.removeEventListener("keydown", handleDialogKeydown); dialogRoot?.remove(); dialogRoot = null; } function handleDialogKeydown(event) { if (event.key === "Escape") closeCasesDialog(); } async function openCasesDialog() { injectStyles(); const existing = document.getElementById(DIALOG_ID); if (existing) { dialogRoot = existing; await refreshCases(); return; } dialogRoot = document.createElement("div"); dialogRoot.id = DIALOG_ID; dialogRoot.innerHTML = ` `; document.body.appendChild(dialogRoot); dialogRoot.addEventListener("click", (event) => { if (event.target === dialogRoot) closeCasesDialog(); }); dialogRoot.querySelector("[data-o1key-close]").addEventListener("click", closeCasesDialog); dialogRoot.querySelector("[data-o1key-refresh]").addEventListener("click", refreshCases); document.addEventListener("keydown", handleDialogKeydown); renderCasesList(); await refreshCases(); dialogRoot.querySelector("[data-o1key-close]")?.focus(); } function renderCasesPanel(container) { container.innerHTML = ""; openCasesDialog(); } function getControlLabel(el) { return [ el.getAttribute("aria-label"), el.getAttribute("title"), el.getAttribute("data-title"), el.getAttribute("data-label"), el.textContent, ].filter(Boolean).join(" ").trim().toLowerCase(); } function bindCaseControlClick(caseControl) { if (!caseControl || caseControl.dataset.o1keyCasesBound) return; caseControl.dataset.o1keyCasesBound = "1"; caseControl.addEventListener("click", (event) => { event.preventDefault(); event.stopImmediatePropagation(); openCasesDialog(); }, true); } function placeCasesAfterTemplates() { const controls = [...document.querySelectorAll(".side-bar-button, [class*='sidebar'] button, .p-togglebutton, [role='tab']")]; const caseControl = controls.find(el => getControlLabel(el).includes("案例")); const templateControl = controls.find(el => { const label = getControlLabel(el); return label.includes("模板") || label.includes("template"); }); bindCaseControlClick(caseControl); if (!caseControl || !templateControl || !templateControl.parentElement) return false; if (templateControl.nextElementSibling !== caseControl) { templateControl.parentElement.insertBefore(caseControl, templateControl.nextElementSibling); } return true; } function watchSidebarOrder() { const apply = () => placeCasesAfterTemplates(); setTimeout(apply, 300); setTimeout(apply, 1200); // 防抖:避免高频触发,增加防抖时间 let debounceTimer = null; const debouncedApply = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(apply, 300); // 从 150ms 增加到 300ms }; // 只监听侧边栏区域,而不是整个 body const observer = new MutationObserver((mutations) => { // 只在侧边栏相关变化时才执行 const hasSidebarChange = mutations.some(m => { const target = m.target; if (!target || !target.nodeType === 1) return false; const el = target.classList ? target : target.parentElement; if (!el) return false; const cls = el.className || ''; return cls.includes('sidebar') || cls.includes('side-bar') || cls.includes('p-togglebutton') || el.getAttribute?.('role') === 'tab'; }); if (hasSidebarChange) debouncedApply(); }); // 尝试只监听侧边栏容器;如果找不到则降级到 body(但使用防抖) const sidebarContainer = document.querySelector('[class*="sidebar"], .side-bar, [class*="Sidebar"]') || document.body; observer.observe(sidebarContainer, { childList: true, subtree: true }); } app.registerExtension({ name: "o1key.casePanel", async setup() { await loadCases(); app.extensionManager.registerSidebarTab({ id: "o1key-cases", title: "案例", icon: "pi pi-book", type: "custom", render: (container) => { injectStyles(); renderCasesPanel(container); }, }); watchSidebarOrder(); }, });