diff --git a/README.md b/README.md index 9eaa3f1..5c950c0 100644 --- a/README.md +++ b/README.md @@ -132,45 +132,28 @@ O1KEY_API_KEY=你的API密钥 ## 🔄 更新插件 -自动更新脚本**已改为从国内镜像(Gitee)拉取**,国内用户无需科学上网即可更新。 +### 界面更新 -### 方法一:自动更新(推荐)⭐ +在 ComfyUI 左侧功能栏点击「更新」(位于「重启」下方)。按钮会从当前 Git 仓库的 `origin/main` 拉取最新版本。完成后点击「重启」使新版本生效。 -**Windows 用户:** -1. 进入插件目录:`ComfyUI\custom_nodes\comfyui_o1key` -2. 双击运行 `自动更新插件(win).bat` -3. 等待更新完成 -4. 重启 ComfyUI +界面更新需要通过 Git 安装、处于 `main` 分支,且节点包文件没有本地修改。更新仅允许快进,不会覆盖本地修改或删除配置。ZIP 安装、分支分叉或网络连接失败时,界面会显示原因,需要手动处理。 + +如果提示依赖列表已变化,请在 ComfyUI 使用的 Python 环境中执行: -**Linux/Mac 用户:** ```bash cd ComfyUI/custom_nodes/comfyui_o1key -chmod +x "自动更新插件(mac).sh" # 首次运行需要添加执行权限 -./"自动更新插件(mac).sh" +python -m pip install -r requirements.txt ``` -### 方法二:手动更新 +### 手动更新 -从 Gitee 镜像拉取(国内推荐): ```bash cd ComfyUI/custom_nodes/comfyui_o1key -git remote get-url gitee &>/dev/null || git remote add gitee https://gitee.com/resonLzy/comfyui_o1key.git -git pull gitee main -pip install -r requirements.txt --upgrade +git pull --ff-only origin main +python -m pip install -r requirements.txt ``` -从 GitHub 拉取: -```bash -cd ComfyUI/custom_nodes/comfyui_o1key -git pull origin main -pip install -r requirements.txt --upgrade -``` - -**💡 提示:** -- 自动更新脚本会自动备份和恢复你的 `.config` 配置文件 -- 更新会保留环境变量中配置的 API 密钥 -- 更新检查在每次启动 ComfyUI 时自动进行(不会影响性能) -- 如果发现新版本,终端会显示更新提示 +更新保留环境变量中配置的 API 密钥。启动时仍会检查是否有新版本。 --- diff --git a/__init__.py b/__init__.py index 1e60d60..6cb010e 100644 --- a/__init__.py +++ b/__init__.py @@ -208,6 +208,10 @@ try: from server import PromptServer import folder_paths from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES + from .utils.updater import UpdateError, update_package + import threading as _update_threading + + _update_lock = _update_threading.Lock() def _get_o1key_server_port(): try: @@ -612,6 +616,23 @@ try: pass return web.json_response({"success": True, "deleted": deleted_files}) + @PromptServer.instance.routes.post("/o1key/update") + async def update_node_package(request): + if request.headers.get("X-O1Key-Update") != "1": + return web.json_response({"error": "无效的更新请求。"}, status=403) + if not _update_lock.acquire(blocking=False): + return web.json_response({"error": "更新正在进行,请稍候。"}, status=409) + try: + result = await asyncio.to_thread(update_package) + return web.json_response(result) + except UpdateError as exc: + return web.json_response({"error": str(exc)}, status=409) + except Exception: + logging.exception("o1key update failed") + return web.json_response({"error": "更新失败,请查看 ComfyUI 日志。"}, status=500) + finally: + _update_lock.release() + # === AI 聊天代理(流式 SSE 透传) === @PromptServer.instance.routes.post("/o1key/restart") async def restart_server(request): diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..0a715d8 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,73 @@ +"""Git integration checks for the sidebar updater.""" + +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + + +UPDATER_PATH = Path(__file__).resolve().parents[1] / "utils" / "updater.py" +spec = importlib.util.spec_from_file_location("o1key_updater_under_test", UPDATER_PATH) +updater = importlib.util.module_from_spec(spec) +spec.loader.exec_module(updater) + + +class UpdaterTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + self.remote = root / "remote.git" + self.author = root / "author" + self.install = root / "install" + self.git(root, "init", "--bare", str(self.remote)) + self.git(root, "clone", str(self.remote), str(self.author)) + self.git(self.author, "config", "user.email", "test@example.com") + self.git(self.author, "config", "user.name", "Updater Test") + self.git(self.author, "switch", "-c", "main") + (self.author / "requirements.txt").write_text("requests>=2\n", encoding="utf-8") + (self.author / "version.txt").write_text("1\n", encoding="utf-8") + self.commit_and_push() + self.git(root, "clone", "--branch", "main", str(self.remote), str(self.install)) + updater.PLUGIN_DIR = self.install + + def git(self, cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip() + + def commit_and_push(self): + self.git(self.author, "add", ".") + self.git(self.author, "commit", "-m", "test update") + self.git(self.author, "push", "origin", "main") + + def test_fast_forward_and_requirements_change(self): + self.assertFalse(updater.update_package()["updated"]) + (self.author / "version.txt").write_text("2\n", encoding="utf-8") + (self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8") + self.commit_and_push() + result = updater.update_package() + self.assertTrue(result["updated"]) + self.assertTrue(result["requirements_changed"]) + self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "2\n") + + def test_local_changes_are_preserved(self): + (self.install / "version.txt").write_text("local\n", encoding="utf-8") + with self.assertRaisesRegex(updater.UpdateError, "本地修改"): + updater.update_package() + self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local\n") + + def test_diverged_branch_is_rejected(self): + self.git(self.install, "config", "user.email", "test@example.com") + self.git(self.install, "config", "user.name", "Updater Test") + (self.install / "version.txt").write_text("local commit\n", encoding="utf-8") + self.git(self.install, "add", ".") + self.git(self.install, "commit", "-m", "local") + (self.author / "version.txt").write_text("remote commit\n", encoding="utf-8") + self.commit_and_push() + with self.assertRaisesRegex(updater.UpdateError, "已分叉"): + updater.update_package() + self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local commit\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/updater.py b/utils/updater.py new file mode 100644 index 0000000..bc2ac9d --- /dev/null +++ b/utils/updater.py @@ -0,0 +1,68 @@ +"""Safely fast-forward a Git installation of this node package.""" + +import os +import subprocess +from pathlib import Path + + +PLUGIN_DIR = Path(__file__).resolve().parent.parent + + +class UpdateError(Exception): + pass + + +def _git(*args, timeout=60, check=True): + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "Never" + try: + result = subprocess.run( + ["git", *args], + cwd=PLUGIN_DIR, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except FileNotFoundError as exc: + raise UpdateError("未找到 Git,请先安装 Git。") from exc + except subprocess.TimeoutExpired as exc: + raise UpdateError("Git 操作超时,请检查网络后重试。") from exc + if check and result.returncode: + detail = (result.stderr or result.stdout).strip().splitlines() + raise UpdateError(detail[-1] if detail else "Git 操作失败。") + return result + + +def update_package(): + """Update origin/main without discarding local changes or switching branches.""" + if not (PLUGIN_DIR / ".git").exists(): + raise UpdateError("当前节点包不是 Git 安装。请通过 Git 安装后再使用界面更新。") + + branch = _git("symbolic-ref", "--quiet", "--short", "HEAD", check=False) + if branch.returncode or branch.stdout.strip() != "main": + raise UpdateError("当前不在 main 分支,请手动检查分支后更新。") + + if _git("status", "--porcelain", "--untracked-files=no").stdout.strip(): + raise UpdateError("节点包有本地修改,请先保存或处理修改后再更新。") + + old_commit = _git("rev-parse", "HEAD").stdout.strip() + old_requirements = _git("show", "HEAD:requirements.txt", check=False).stdout + _git("fetch", "origin", "main") + new_commit = _git("rev-parse", "FETCH_HEAD").stdout.strip() + if old_commit == new_commit: + return {"updated": False, "version": old_commit[:7], "requirements_changed": False} + + if _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", check=False).returncode: + raise UpdateError("本地与 origin/main 已分叉,无法安全快进。请手动处理。") + + _git("merge", "--ff-only", "FETCH_HEAD") + requirements_changed = old_requirements != (PLUGIN_DIR / "requirements.txt").read_text(encoding="utf-8") + return { + "updated": True, + "version": new_commit[:7], + "requirements_changed": requirements_changed, + } diff --git a/web/js/restartButton.js b/web/js/restartButton.js index cf1f5cd..6066d59 100644 --- a/web/js/restartButton.js +++ b/web/js/restartButton.js @@ -3,11 +3,8 @@ import { app } from "../../../scripts/app.js"; app.registerExtension({ name: "o1key.restartButton", async setup() { - let injected = false; - function inject() { - if (injected) return; - if (document.querySelector("#o1k-restart-btn")) { injected = true; return; } + if (document.querySelector("#o1k-restart-btn") && document.querySelector("#o1k-update-btn")) return; const allBtns = document.querySelectorAll("button, .p-togglebutton, .side-bar-button"); let logBtn = null; @@ -20,36 +17,77 @@ app.registerExtension({ } if (!logBtn || !logBtn.parentNode) return; - const btn = logBtn.cloneNode(false); - btn.id = "o1k-restart-btn"; - btn.setAttribute("aria-label", "重启"); - btn.title = "重启 ComfyUI"; + function makeButton(id, label, title, icon) { + const btn = logBtn.cloneNode(false); + btn.id = id; + btn.setAttribute("aria-label", label); + btn.title = title; + const logStyle = window.getComputedStyle(logBtn); + btn.style.display = "flex"; + btn.style.flexDirection = "column"; + btn.style.alignItems = "center"; + btn.style.justifyContent = "center"; + btn.style.gap = logStyle.gap || "4px"; + const iconSpan = document.createElement("span"); + iconSpan.innerHTML = icon; + const textSpan = document.createElement("span"); + textSpan.textContent = label; + btn.append(iconSpan, textSpan); + return btn; + } - const logStyle = window.getComputedStyle(logBtn); - btn.style.display = "flex"; - btn.style.flexDirection = "column"; - btn.style.alignItems = "center"; - btn.style.justifyContent = "center"; - btn.style.gap = logStyle.gap || "4px"; + let restartBtn = document.querySelector("#o1k-restart-btn"); + if (!restartBtn) { + restartBtn = makeButton("o1k-restart-btn", "重启", "重启 ComfyUI", + ``); + restartBtn.addEventListener("click", async () => { + if (!confirm("确定要重启 ComfyUI 吗?")) return; + restartBtn.style.opacity = "0.5"; + restartBtn.style.pointerEvents = "none"; + await disableExperimentalAssetApi(); + try { await fetch("/o1key/restart", { method: "POST" }); } catch {} + pollUntilReady(); + }); + logBtn.parentNode.insertBefore(restartBtn, logBtn); + } - const iconSpan = document.createElement("span"); - iconSpan.innerHTML = ``; - const textSpan = document.createElement("span"); - textSpan.textContent = "重启"; - btn.appendChild(iconSpan); - btn.appendChild(textSpan); - - btn.addEventListener("click", async () => { - if (!confirm("确定要重启 ComfyUI 吗?")) return; - btn.style.opacity = "0.5"; - btn.style.pointerEvents = "none"; - await disableExperimentalAssetApi(); - try { await fetch("/o1key/restart", { method: "POST" }); } catch {} - pollUntilReady(); - }); - - logBtn.parentNode.insertBefore(btn, logBtn); - injected = true; + if (!document.querySelector("#o1k-update-btn")) { + const updateBtn = makeButton("o1k-update-btn", "更新", "更新 comfyui_o1key 节点包", + ``); + let updating = false; + updateBtn.addEventListener("click", async () => { + if (updating) return; + if (!confirm("从 origin/main 拉取 comfyui_o1key 最新版本?")) return; + updating = true; + updateBtn.disabled = true; + updateBtn.style.opacity = "0.5"; + updateBtn.title = "正在更新..."; + try { + const response = await fetch("/o1key/update", { + method: "POST", + headers: { "X-O1Key-Update": "1" }, + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "更新失败"); + if (!result.updated) { + alert(`已是最新版本(${result.version})。`); + } else { + const dependencies = result.requirements_changed + ? "\n依赖列表已变化,请先在 ComfyUI 的 Python 环境中执行 pip install -r requirements.txt。" + : ""; + alert(`更新完成(${result.version})。${dependencies}\n请点击“重启”使新版本生效。`); + } + } catch (error) { + alert(`更新失败:${error.message}`); + } finally { + updating = false; + updateBtn.disabled = false; + updateBtn.style.opacity = ""; + updateBtn.title = "更新 comfyui_o1key 节点包"; + } + }); + restartBtn.after(updateBtn); + } } async function disableExperimentalAssetApi() {