Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
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 = `
|
||
<form class="o1key-api-form">
|
||
<div class="o1key-api-header">
|
||
<h2>令牌管理</h2>
|
||
<button class="o1key-close" type="button" aria-label="关闭" title="关闭">✕</button>
|
||
</div>
|
||
<label for="o1key-api-key-input">API Key</label>
|
||
<input id="o1key-api-key-input" type="password" autocomplete="new-password"
|
||
placeholder="留空则保留当前 API Key" spellcheck="false" />
|
||
<div class="o1key-hint o1key-key-hint">正在读取当前配置…</div>
|
||
|
||
<label for="o1key-network-route-select">网络线路</label>
|
||
<select id="o1key-network-route-select"></select>
|
||
<div class="o1key-hint">保存后,所有 O1Key 节点从下一次执行开始使用新配置,无需重启。</div>
|
||
|
||
<div class="o1key-status" role="status" aria-live="polite"></div>
|
||
<div class="o1key-actions">
|
||
<button class="o1key-danger" type="button" data-action="clear">清除 Key</button>
|
||
<button type="button" data-action="test">测试连接</button>
|
||
<button class="o1key-primary" type="button" data-action="save">保存并立即生效</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
|
||
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();
|
||
},
|
||
});
|