Files
comfyui_o1key/web/js/casePanel.js
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

281 lines
11 KiB
JavaScript

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 `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>`;
}
function iconClose() {
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 = `<div id="o1key-cases-empty">暂无案例</div>`;
return;
}
list.innerHTML = cases.map(item => `
<button class="o1c-item" data-filename="${escapeHtml(item.filename)}" title="${escapeHtml(item.title)}">
<div class="o1c-item-title">${escapeHtml(item.title)}</div>
<div class="o1c-item-meta">${escapeHtml(item.filename)}</div>
</button>
`).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 = `
<div id="o1key-cases-modal" role="dialog" aria-modal="true" aria-labelledby="o1key-cases-title">
<div id="o1key-cases-header">
<div id="o1key-cases-title">案例</div>
<div id="o1key-cases-actions">
<button class="o1c-icon-btn" data-o1key-refresh title="刷新" aria-label="刷新">${iconRefresh()}</button>
<button class="o1c-icon-btn" data-o1key-close title="关闭" aria-label="关闭">${iconClose()}</button>
</div>
</div>
<div id="o1key-cases-list"></div>
<div id="o1key-cases-status"></div>
</div>
`;
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();
},
});