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();
},
});