import { app } from "../../../scripts/app.js"; const DIALOG_ID = "o1key-api-settings-dialog"; const STYLE_ID = "o1key-api-settings-styles"; const RESTART_STATUS_URL = "/o1key/restart/status"; const RESTART_TIMEOUT_MS = 120_000; let restartInProgress = false; function ensureStyles() { if (document.querySelector(`#${STYLE_ID}`)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${DIALOG_ID} { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-color, #444); border-radius: 12px; color: var(--fg-color, #eee); background: var(--comfy-menu-bg, #202020); box-shadow: 0 20px 60px rgba(0, 0, 0, .45); } #${DIALOG_ID}::backdrop { background: rgba(0, 0, 0, .58); } #${DIALOG_ID} .o1key-api-form { padding: 22px; } #${DIALOG_ID} .o1key-api-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; } #${DIALOG_ID} h2 { margin: 0; font-size: 18px; } #${DIALOG_ID} .o1key-close { width: 32px; height: 32px; border: 0; border-radius: 8px; color: inherit; background: transparent; cursor: pointer; } #${DIALOG_ID} .o1key-close:hover { background: rgba(127, 127, 127, .18); } #${DIALOG_ID} label { display: block; margin: 14px 0 6px; font-size: 13px; color: var(--fg-color, #ddd); } #${DIALOG_ID} input, #${DIALOG_ID} select { box-sizing: border-box; width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--border-color, #555); border-radius: 7px; color: inherit; background: var(--comfy-input-bg, #111); } #${DIALOG_ID} .o1key-hint, #${DIALOG_ID} .o1key-status { margin-top: 7px; font-size: 12px; color: #aaa; } #${DIALOG_ID} .o1key-status[data-kind="success"] { color: #55c98b; } #${DIALOG_ID} .o1key-status[data-kind="error"] { color: #ef7777; } #${DIALOG_ID} .o1key-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 20px; } #${DIALOG_ID} .o1key-actions button { min-height: 34px; padding: 7px 14px; border: 1px solid var(--border-color, #555); border-radius: 7px; color: var(--fg-color, #eee); background: var(--comfy-input-bg, #292929); cursor: pointer; } #${DIALOG_ID} .o1key-actions button:hover { filter: brightness(1.12); } #${DIALOG_ID} .o1key-primary { border-color: #2f8d64; background: #287653; } #${DIALOG_ID} .o1key-danger { margin-right: auto; border-color: #8b4444; background: #703838; } #${DIALOG_ID} button:disabled { cursor: wait; opacity: .6; } `; document.head.appendChild(style); } function setStatus(dialog, message, kind = "info") { const status = dialog.querySelector(".o1key-status"); status.textContent = message; status.dataset.kind = kind; } async function readJson(response) { const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`); return data; } function delay(milliseconds) { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } async function fetchWithTimeout(url, options = {}, timeout = 2_500) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeout); try { return await fetch(url, { ...options, cache: "no-store", signal: controller.signal, }); } finally { window.clearTimeout(timer); } } async function getRestartStatus() { const separator = RESTART_STATUS_URL.includes("?") ? "&" : "?"; const response = await fetchWithTimeout( `${RESTART_STATUS_URL}${separator}_=${Date.now()}` ); return readJson(response); } function setRestartButtonState(button, busy) { button.disabled = busy; button.setAttribute("aria-busy", String(busy)); button.setAttribute("aria-label", busy ? "ComfyUI 正在重启" : "重启"); button.setAttribute("title", busy ? "ComfyUI 正在重启" : "重启"); const icon = button.querySelector(".side-bar-button-icon"); if (icon) { icon.className = busy ? "pi pi-spinner pi-spin side-bar-button-icon" : "pi pi-refresh side-bar-button-icon"; } const label = button.querySelector(".side-bar-button-label"); if (label) label.textContent = busy ? "重启中" : "重启"; } async function waitForRestart(oldBootId) { const deadline = Date.now() + RESTART_TIMEOUT_MS; while (Date.now() < deadline) { try { const status = await getRestartStatus(); if (status.ready && status.boot_id && status.boot_id !== oldBootId) { const systemStatus = await fetchWithTimeout( `/api/system_stats?_=${Date.now()}` ); if (systemStatus.ok) return; } } catch { // 旧进程退出到新进程监听端口之间,接口暂时不可用是正常状态。 } await delay(1_000); } throw new Error("等待 ComfyUI 重启超时,请查看终端中的错误信息。"); } function reloadAfterRestart() { const url = new URL(window.location.href); url.searchParams.set("o1key_restart", Date.now().toString()); window.location.replace(url.toString()); } async function restartComfyUI(event) { if (restartInProgress) return; if (!window.confirm("确定要重启 ComfyUI 吗?当前正在执行的任务会被中断。")) return; const button = event.currentTarget; restartInProgress = true; setRestartButtonState(button, true); try { let currentStatus = null; try { currentStatus = await getRestartStatus(); } catch { // POST 响应还会返回旧进程的 boot_id,因此预检失败不阻断重启。 } const response = await fetchWithTimeout("/o1key/restart", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }, 5_000); const result = await readJson(response); const oldBootId = result.boot_id || currentStatus?.boot_id; if (!oldBootId) throw new Error("无法确认当前 ComfyUI 进程标识。"); await waitForRestart(oldBootId); reloadAfterRestart(); } catch (error) { restartInProgress = false; setRestartButtonState(button, false); window.alert(error?.message || "ComfyUI 重启失败,请查看终端中的错误信息。"); } } function createDialog() { const dialog = document.createElement("dialog"); dialog.id = DIALOG_ID; dialog.innerHTML = `
`; dialog.querySelector("form").addEventListener("submit", (event) => event.preventDefault()); dialog.querySelector(".o1key-close").addEventListener("click", () => dialog.close()); dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); }); const runAction = async (button, action) => { const buttons = dialog.querySelectorAll("button[data-action]"); buttons.forEach((item) => { item.disabled = true; }); try { await action(); } catch (error) { setStatus(dialog, error.message || "操作失败", "error"); } finally { buttons.forEach((item) => { item.disabled = false; }); button.focus(); } }; dialog.querySelector('[data-action="save"]').addEventListener("click", (event) => { runAction(event.currentTarget, async () => { setStatus(dialog, "正在保存…"); const apiKey = dialog.querySelector("#o1key-api-key-input").value.trim(); const networkRoute = dialog.querySelector("#o1key-network-route-select").value; const data = await readJson(await fetch("/o1key/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: apiKey || undefined, network_route: networkRoute }), })); dialog.querySelector("#o1key-api-key-input").value = ""; dialog.querySelector(".o1key-key-hint").textContent = data.has_key ? `当前 Key:${data.masked}` : "当前未配置 API Key"; setStatus(dialog, "已保存,下一次执行立即使用新配置。", "success"); }); }); dialog.querySelector('[data-action="test"]').addEventListener("click", (event) => { runAction(event.currentTarget, async () => { setStatus(dialog, "正在测试连接…"); const apiKey = dialog.querySelector("#o1key-api-key-input").value.trim(); const networkRoute = dialog.querySelector("#o1key-network-route-select").value; const data = await readJson(await fetch("/o1key/test_key", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: apiKey || undefined, network_route: networkRoute }), })); if (!data.valid) throw new Error(data.error || "连接测试失败"); setStatus(dialog, "连接正常。测试不会自动保存,请按“保存并立即生效”。", "success"); }); }); dialog.querySelector('[data-action="clear"]').addEventListener("click", (event) => { if (!window.confirm("确定清除已保存的 O1Key API Key 吗?")) return; runAction(event.currentTarget, async () => { await readJson(await fetch("/o1key/api_key", { method: "DELETE" })); dialog.querySelector("#o1key-api-key-input").value = ""; dialog.querySelector(".o1key-key-hint").textContent = "当前未配置 API Key"; setStatus(dialog, "API Key 已清除。", "success"); }); }); document.body.appendChild(dialog); return dialog; } async function refreshDialog(dialog) { setStatus(dialog, "正在读取配置…"); const data = await readJson(await fetch("/o1key/api_key", { cache: "no-store" })); const select = dialog.querySelector("#o1key-network-route-select"); select.replaceChildren(); for (const route of data.network_route_options || []) { const option = document.createElement("option"); option.value = route; option.textContent = route; select.appendChild(option); } select.value = data.network_route || "全球加速"; dialog.querySelector(".o1key-key-hint").textContent = data.has_key ? `当前 Key:${data.masked}` : "当前未配置 API Key"; setStatus(dialog, ""); } async function openApiSettings() { ensureStyles(); const dialog = document.querySelector(`#${DIALOG_ID}`) || createDialog(); if (!dialog.open) dialog.showModal(); try { await refreshDialog(dialog); } catch (error) { setStatus(dialog, error.message || "读取配置失败", "error"); } } function createSidebarButton(sourceButton, { id, className, iconClass, label, onClick }) { const button = sourceButton.cloneNode(true); button.id = id; button.type = "button"; button.classList.remove("side-bar-button-selected", "o1key-token-manager-button", "o1key-restart-button"); button.classList.add(className); button.setAttribute("aria-label", label); button.setAttribute("title", label); button.removeAttribute("aria-pressed"); const icon = button.querySelector(".side-bar-button-icon"); if (icon) icon.className = `${iconClass} side-bar-button-icon`; const buttonLabel = button.querySelector(".side-bar-button-label"); if (buttonLabel) buttonLabel.textContent = label; button.addEventListener("click", onClick); return button; } function mountSidebarButtons() { let tokenButton = document.querySelector("#o1key-token-manager-button"); const consoleIcon = document.querySelector( '.side-tool-bar-container [class~="icon-[ph--terminal-bold]"]' ); const consoleButton = consoleIcon?.closest("button"); if (!consoleButton) return false; if (!tokenButton) { tokenButton = createSidebarButton(consoleButton, { id: "o1key-token-manager-button", className: "o1key-token-manager-button", iconClass: "pi pi-key", label: "令牌管理", onClick: openApiSettings, }); consoleButton.before(tokenButton); } if (!document.querySelector("#o1key-restart-button")) { const restartButton = createSidebarButton(tokenButton, { id: "o1key-restart-button", className: "o1key-restart-button", iconClass: "pi pi-refresh", label: "重启", onClick: restartComfyUI, }); tokenButton.after(restartButton); } return true; } function mountSidebarButtonsWhenReady() { if (mountSidebarButtons()) return; // ComfyUI mounts the Vue sidebar after extension setup on some versions. // Observe only during that initial mount and disconnect immediately once // the button has been inserted. This is event-driven, not periodic polling. const observer = new MutationObserver(() => { if (mountSidebarButtons()) observer.disconnect(); }); observer.observe(document.body, { childList: true, subtree: true }); } app.registerExtension({ name: "o1key.apiSettings", commands: [ { id: "o1key.OpenApiSettings", icon: "pi pi-key", label: "令牌管理", function: openApiSettings, }, ], setup() { ensureStyles(); // 重启完成后的地址参数只用于绕过浏览器缓存,进入新页面后立即清理。 const pageUrl = new URL(window.location.href); if (pageUrl.searchParams.has("o1key_restart")) { pageUrl.searchParams.delete("o1key_restart"); window.history.replaceState(window.history.state, "", pageUrl.toString()); } mountSidebarButtonsWhenReady(); }, });