From 0cf99740c0bde3f0c573cb0c8913edaff36bde11 Mon Sep 17 00:00:00 2001 From: Jony <951565127@qq.com> Date: Thu, 24 Sep 2026 21:52:01 +0800 Subject: [PATCH] Polish customer update flow and prepare ZIP installation --- README.md | 19 +- __init__.py | 21 +- docs/architecture.md | 4 +- docs/development.md | 2 +- tests/test_o1key_update_button.mjs | 258 ++++++++++--- tests/test_updater.py | 10 + utils/updater.py | 35 +- web/js/o1keyUpdateButton.js | 556 ++++++++++++++++++++++++----- 8 files changed, 740 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 0cb035a..a803420 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,16 @@ pip install -r requirements.txt 然后重启 ComfyUI。 +### 方法三:客户压缩包安装(Windows 便携版) + +把压缩包中的 `comfyui_o1key` 文件夹完整解压到 `ComfyUI\custom_nodes\`,不要只复制其中的 Python 文件。然后在插件目录运行: + +```powershell +..\..\..\python_embeded\python.exe -m pip install -r requirements.txt +``` + +重启 ComfyUI 后,在左侧「令牌管理」中填写自己的 API Key。不要把其他人的 `.config` 文件复制到新安装目录。使用内置更新还需要系统能够运行 `git --version`。 + Windows 用户也可以在 ComfyUI 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。 --- @@ -94,12 +104,11 @@ O1KEY_ASYNC_API_BASE_URL=https://your-async-api-domain.com ### 方法一:ComfyUI 侧栏 -1. 点击 ComfyUI 左侧侧栏的“更新”图标。 -2. 在面板中点击“检查并更新”,确认从 `git.o1key.com/publisher/comfyui_o1key` 获取后等待结果。 -3. 如果提示依赖变化,在 ComfyUI 使用的 Python 环境中安装 `requirements.txt`。 -4. 重启 ComfyUI。 +1. 点击 ComfyUI 左侧工具栏中“令牌管理”下方的“更新”按钮。 +2. 等待版本检查。如果发现新版本,确认后等待更新完成;需要时可在面板中重新检查。 +3. 按完成提示重启 ComfyUI。如果面板提示需要技术支持,请联系维护人员。 -> 面板只对没有已跟踪文件修改的 Git `main` 分支执行快进更新。本地代码有修改、历史分叉、网络故障或新版本与未跟踪文件冲突时会给出原因和处理建议,不会强制重置或清理本地文件。已有安装的 `origin` 地址不会被自动改写。 +> 如果当前安装包含自定义修改,面板会停止更新并保留现有内容。此时请联系维护人员处理。 ### 方法二:手动更新 diff --git a/__init__.py b/__init__.py index 944beee..7cb0fae 100644 --- a/__init__.py +++ b/__init__.py @@ -240,7 +240,7 @@ try: from .utils.o1key_image_jobs import register_o1key_image_job_routes from .utils.o1key_image_thumbnail import register_o1key_image_thumbnail_route from .utils.o1key_video_jobs import register_o1key_video_job_routes - from .utils.updater import UpdateError, update_package + from .utils.updater import UpdateError, check_for_update, update_package _O1KEY_IMAGE_JOB_MANAGER = register_o1key_image_job_routes( PromptServer, @@ -266,6 +266,25 @@ try: _o1key_restart_pending = _restart_threading.Event() _o1key_update_lock = _restart_threading.Lock() + @PromptServer.instance.routes.get("/o1key/update/check") + async def check_o1key_update(request): + if not _o1key_update_lock.acquire(blocking=False): + return web.json_response( + {"code": "update_in_progress", "error": "更新正在进行。"}, status=409, + ) + try: + result = await asyncio.to_thread(check_for_update) + return web.json_response(result) + except UpdateError as exc: + return web.json_response(exc.as_dict(), status=exc.status) + except Exception: + logging.exception("O1Key 检查更新失败") + return web.json_response( + {"code": "internal_error", "error": "检查更新失败。"}, status=500, + ) + finally: + _o1key_update_lock.release() + @PromptServer.instance.routes.post("/o1key/update") async def update_o1key_package(request): if request.headers.get("X-O1Key-Update") != "1": diff --git a/docs/architecture.md b/docs/architecture.md index 5127339..5d4dbf0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,7 +109,9 @@ Like `clients`, the `utils` package uses lazy exports to reduce startup work. Every JavaScript file in `web/` is served as a ComfyUI extension. Major responsibilities include settings, chat, cases, notes, element management, workflow migration, upload helpers, previews, painting, trimming, and the panel-style image generator. -`web/js/o1keyUpdateButton.js` registers an O1Key tab in the left sidebar. Its panel calls `/o1key/update`, which delegates to `utils/updater.py`. The updater fetches the public `main` branch from `https://git.o1key.com/publisher/comfyui_o1key.git` without changing the user's `origin`. It only fast-forwards a clean local Git `main`; local tracked changes, divergent history, and file collisions receive structured error codes and guidance. It never resets or cleans the worktree. A completed update requires a ComfyUI restart to load new Python and JavaScript code. +`web/js/o1keyUpdateButton.js` places an Update button directly below the Token Manager button in the left toolbar, before Restart. Opening its dialog immediately calls `GET /o1key/update/check` and asks for confirmation only when a newer version is available; confirmation calls `POST /o1key/update`. Both routes delegate to `utils/updater.py` and share a lock. The updater fetches the public `main` branch from `https://git.o1key.com/publisher/comfyui_o1key.git` without changing the user's `origin`. It reports an already current installation before checking local modifications, and only fast-forwards a clean local Git `main` when an update exists. Local tracked changes, divergent history, and file collisions receive structured error codes; the UI maps those codes to customer-facing messages without exposing the repository or Git details. It never resets or cleans the worktree. A completed update requires a ComfyUI restart to load new Python and JavaScript code. + +The updater dialog keeps the check result, update confirmation, progress, and retry actions in one ComfyUI-styled modal. Opening it runs the check; a newer version changes the primary action to “立即更新”, while “稍后再说” closes without updating. Because the directory is auto-loaded, unused or experimental JavaScript must not be left here. diff --git a/docs/development.md b/docs/development.md index dc87f32..cfa87ce 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,7 @@ The primary development target is the portable Windows installation containing t Do not install packages into a system Python when validating the portable build. -The left sidebar update panel uses `utils/updater.py` through `/o1key/update`. Its release source is `https://git.o1key.com/publisher/comfyui_o1key.git`, independent of the installation's `origin`. Keep its Git operation fast-forward only, preserve local files, and return an actionable `code`, `error`, and `suggestion` for expected failures. Run `tests/test_updater.py` and `tests/test_o1key_update_button.mjs` after changing this path; restart ComfyUI to load backend route changes. +The left toolbar Update button sits after Token Manager and before Restart. Opening its dialog immediately checks `GET /o1key/update/check` before requesting confirmation, then calls `POST /o1key/update`. Both routes use `utils/updater.py` and one lock. Its release source is `https://git.o1key.com/publisher/comfyui_o1key.git`, independent of the installation's `origin`. Keep its Git operation fast-forward only and preserve local files. The backend returns stable error codes; map these to customer-facing text in the frontend rather than displaying raw Git errors, repository URLs, or implementation details. A current installation should report no update even when tracked files were changed locally. Run `tests/test_updater.py` and `tests/test_o1key_update_button.mjs` after changing this path; restart ComfyUI to load backend route changes. ## Add or change a node diff --git a/tests/test_o1key_update_button.mjs b/tests/test_o1key_update_button.mjs index 1f78370..35cce4d 100644 --- a/tests/test_o1key_update_button.mjs +++ b/tests/test_o1key_update_button.mjs @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import vm from "node:vm"; - const sourcePath = new URL("../web/js/o1keyUpdateButton.js", import.meta.url); const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, ""); @@ -11,82 +10,227 @@ class Element { this.tagName = tagName; this.children = []; this.listeners = new Map(); + this.attributes = new Map(); this.style = {}; - this.disabled = false; + this.dataset = {}; + this.className = ""; this.textContent = ""; + this.disabled = false; + this.open = false; + this.parentElement = null; + this.classList = { + add: (...names) => this.updateClasses(names, []), + remove: (...names) => this.updateClasses([], names), + }; } - append(...children) { this.children.push(...children); } - replaceChildren(...children) { this.children = [...children]; } - setAttribute(name, value) { this[name] = value; } + updateClasses(add, remove) { + const names = new Set(this.className.split(/\s+/).filter(Boolean)); + for (const name of remove) names.delete(name); + for (const name of add) names.add(name); + this.className = [...names].join(" "); + } + append(...elements) { + for (const element of elements) { + if (element.parentElement) { + const siblings = element.parentElement.children; + siblings.splice(siblings.indexOf(element), 1); + } + element.parentElement = this; + this.children.push(element); + } + } + appendChild(element) { this.append(element); } + after(element) { + if (element.parentElement) { + const siblings = element.parentElement.children; + siblings.splice(siblings.indexOf(element), 1); + } + const siblings = this.parentElement.children; + siblings.splice(siblings.indexOf(this) + 1, 0, element); + element.parentElement = this.parentElement; + } + get nextElementSibling() { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + cloneNode(deep) { + const clone = new Element(this.tagName); + clone.id = this.id; + clone.type = this.type; + clone.className = this.className; + clone.textContent = this.textContent; + clone.attributes = new Map(this.attributes); + if (deep) clone.append(...this.children.map((child) => child.cloneNode(true))); + return clone; + } + querySelector(selector) { + const test = (element) => selector.startsWith("#") + ? element.id === selector.slice(1) + : element.className.split(/\s+/).includes(selector.slice(1)); + for (const child of this.children) { + if (test(child)) return child; + const nested = child.querySelector(selector); + if (nested) return nested; + } + return null; + } + setAttribute(name, value) { this.attributes.set(name, value); } + removeAttribute(name) { this.attributes.delete(name); } addEventListener(name, listener) { this.listeners.set(name, listener); } + showModal() { this.open = true; } + close() { this.open = false; } } -let extension; -let tab; -let requests = 0; -let fetchFailure = false; -let response = { ok: true, async json() { return { updated: true, version: "abc1234", requirements_changed: false }; } }; -const app = { - registerExtension(value) { extension = value; }, - extensionManager: { - getSidebarTabs() { return tab ? [tab] : []; }, - registerSidebarTab(value) { tab = value; }, - }, +const body = new Element("body"); +const head = new Element("head"); +const toolbar = new Element("div"); +body.append(toolbar); +const document = { + body, + head, + createElement: (tag) => new Element(tag), + querySelector: (selector) => body.querySelector(selector) || head.querySelector(selector), }; +let extension; +let observer; +const requests = []; +const responses = []; +let fetchFailure = false; +const reply = (body, status = 200) => ({ ok: status < 400, status, async json() { return body; } }); +const app = { registerExtension(value) { extension = value; } }; const api = { async fetchApi(path, options) { - requests++; - assert.equal(path, "/o1key/update"); - assert.equal(options.method, "POST"); - assert.equal(options.headers["X-O1Key-Update"], "1"); + requests.push({ path, options }); if (fetchFailure) throw new Error("offline"); - return response; + return responses.shift(); }, }; -vm.runInNewContext(source, { app, api, document: { createElement: (tag) => new Element(tag) }, window: { confirm: () => true } }); +class MutationObserver { + constructor(callback) { this.callback = callback; observer = this; } + observe() {} + disconnect() { this.disconnected = true; } +} +vm.runInNewContext(source, { app, api, document, window: {}, MutationObserver }); extension.setup(); -assert.equal(tab.id, "o1key-update"); -assert.equal(tab.title, "更新"); -const firstTab = tab; +assert.ok(observer); + +const token = new Element("button"); +token.id = "o1key-token-manager-button"; +token.className = "side-bar-button o1key-token-manager-button"; +const tokenIcon = new Element("span"); +tokenIcon.className = "pi pi-key side-bar-button-icon"; +const tokenLabel = new Element("span"); +tokenLabel.className = "side-bar-button-label"; +tokenLabel.textContent = "令牌管理"; +token.append(tokenIcon, tokenLabel); +const restart = new Element("button"); +restart.id = "o1key-restart-button"; +toolbar.append(token, restart); +observer.callback(); + +const update = document.querySelector("#o1key-update-button"); +assert.equal(toolbar.children[0], token); +assert.equal(toolbar.children[1], update); +assert.equal(toolbar.children[2], restart); +assert.equal(update.querySelector(".side-bar-button-label").textContent, "更新"); +assert.match(update.querySelector(".side-bar-button-icon").className, /pi-download/); +assert.ok(observer.disconnected); extension.setup(); -assert.equal(tab, firstTab); +assert.equal(toolbar.children.length, 3); -const container = new Element("div"); -tab.render(container); -const [, description, sourceLabel, button, status, suggestion] = container.children[0].children; -assert.match(description.textContent, /安全快进/); -assert.match(sourceLabel.textContent, /git\.o1key\.com\/publisher\/comfyui_o1key/); -await button.listeners.get("click")(); -assert.equal(requests, 1); -assert.match(status.textContent, /abc1234/); -assert.match(suggestion.textContent, /重启 ComfyUI/); - -response = { ok: true, async json() { return { updated: true, version: "def5678", requirements_changed: true }; } }; -await button.listeners.get("click")(); -assert.equal(requests, 2); -assert.match(suggestion.textContent, /安装 requirements.txt/); - -response = { ok: true, async json() { return { updated: false, version: "def5678" }; } }; -await button.listeners.get("click")(); +responses.push(reply({ update_available: false })); +update.listeners.get("click")(); +const checkingDialog = document.querySelector("#o1key-update-dialog"); +const checkingIcon = checkingDialog.children[0].children[1].children[1].children[0]; +assert.equal(checkingDialog.dataset.state, "checking"); +assert.match(checkingIcon.className, /is-loading/); +assert.doesNotMatch(checkingIcon.className, /pi-spin/); +assert.match(head.children[0].textContent, /flex: 0 0 18px/); +assert.match(head.children[0].textContent, /animation: o1key-update-spin/); +assert.match(head.children[0].textContent, /--p-primary-500/); +await new Promise(setImmediate); +const dialog = document.querySelector("#o1key-update-dialog"); +assert.equal(dialog.open, true); +const [header, content, actions] = dialog.children[0].children; +const [description, resultCard] = content.children; +const [status, suggestion] = resultCard.children[1].children; +const [secondary, primary] = actions.children; +assert.equal(header.children[1].children[1].textContent, "更新 O1Key 节点包"); +assert.equal(description.textContent, "检查是否有新版本可用。"); +assert.doesNotMatch(source, /git\.o1key\.com|发布仓库|main 分支|requirements\.txt/); +assert.doesNotMatch(source, /window\.confirm/); +assert.equal(requests.length, 1); +assert.equal(requests[0].path, "/o1key/update/check"); +assert.equal(requests[0].options.cache, "no-store"); assert.match(status.textContent, /已是最新版本/); -assert.match(suggestion.textContent, /无需重启/); +assert.equal(dialog.dataset.state, "current"); +assert.equal(resultCard.dataset.kind, "success"); +assert.equal(primary.textContent, "重新检查"); +assert.equal(head.children.length, 1); -response = { ok: false, status: 409, async json() { return { code: "local_changes", error: "插件目录有本地修改", suggestion: "请先保存修改" }; } }; -await button.listeners.get("click")(); -assert.match(status.textContent, /本地修改/); -assert.match(suggestion.textContent, /请先保存修改/); +responses.push(reply({ update_available: false })); +await primary.listeners.get("click")(); +assert.equal(requests[1].path, "/o1key/update/check"); +assert.match(status.textContent, /已是最新版本/); -response = { ok: false, status: 404 }; -await button.listeners.get("click")(); -assert.match(status.textContent, /尚未加载/); +responses.push(reply({ update_available: true })); +await primary.listeners.get("click")(); +assert.equal(requests.length, 3); +assert.match(status.textContent, /发现新版本/); +assert.equal(dialog.dataset.state, "available"); +assert.equal(primary.textContent, "立即更新"); +assert.equal(secondary.textContent, "稍后再说"); +assert.equal(resultCard.dataset.kind, "available"); +const requestsBeforeDecline = requests.length; +secondary.listeners.get("click")(); +assert.equal(dialog.open, false); +assert.equal(requests.length, requestsBeforeDecline); +responses.push(reply({ update_available: true })); +update.listeners.get("click")(); +await new Promise(setImmediate); +assert.equal(dialog.open, true); +assert.equal(dialog.dataset.state, "available"); + +responses.push(reply({ updated: true, version: "abc1234", requirements_changed: false })); +await primary.listeners.get("click")(); +assert.equal(requests.at(-1).path, "/o1key/update"); +assert.equal(requests.at(-1).options.method, "POST"); +assert.equal(requests.at(-1).options.headers["X-O1Key-Update"], "1"); +assert.match(status.textContent, /更新完成/); assert.match(suggestion.textContent, /重启 ComfyUI/); +assert.equal(primary.textContent, "完成"); +assert.equal(dialog.dataset.state, "success"); -response = { ok: false, status: 500, async json() { throw new Error("invalid JSON"); } }; -await button.listeners.get("click")(); -assert.match(status.textContent, /无法读取/); +responses.push(reply({ update_available: true })); +await dialog.checkForUpdate(); +responses.push(reply({ updated: true, requirements_changed: true })); +await primary.listeners.get("click")(); +assert.match(suggestion.textContent, /联系技术支持/); +assert.doesNotMatch(suggestion.textContent, /requirements\.txt/); + +const requestsBeforeError = requests.length; +responses.push(reply({ code: "local_changes", error: "插件目录有未提交的代码修改。", suggestion: "请先提交" }, 409)); +await dialog.checkForUpdate(); +assert.equal(requests.length, requestsBeforeError + 1); +assert.match(status.textContent, /自定义修改/); +assert.match(suggestion.textContent, /联系技术支持/); +assert.doesNotMatch(status.textContent + suggestion.textContent, /插件目录|提交|Git/); +assert.equal(dialog.dataset.state, "error"); +assert.equal(resultCard.dataset.kind, "error"); + +responses.push({ ok: false, status: 404 }); +await primary.listeners.get("click")(); +assert.match(status.textContent, /尚未就绪/); + +responses.push({ ok: false, status: 500, async json() { throw new Error("invalid JSON"); } }); +await primary.listeners.get("click")(); +assert.match(status.textContent, /无法读取检查结果/); fetchFailure = true; -await button.listeners.get("click")(); -assert.match(status.textContent, /无法连接本地 ComfyUI/); -assert.equal(button.disabled, false); +await primary.listeners.get("click")(); +assert.match(status.textContent, /暂时无法完成操作/); +assert.equal(primary.disabled, false); +assert.equal(head.children.length, 1); +header.children[2].listeners.get("click")(); +assert.equal(dialog.open, false); diff --git a/tests/test_updater.py b/tests/test_updater.py index 620615c..0851bb7 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -48,11 +48,14 @@ class UpdaterTests(unittest.TestCase): self.git(self.author, "push", "origin", "main") def test_fast_forward_and_requirements_change(self): + self.assertFalse(updater.check_for_update()["update_available"]) self.assertFalse(updater.update_package()["updated"]) # The release URL, not the user's origin, determines the update source. self.git(self.install, "remote", "set-url", "origin", str(self.install / "unused-origin")) (self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8") self.commit_and_push() + self.assertTrue(updater.check_for_update()["update_available"]) + self.assertEqual((self.install / "requirements.txt").read_text(encoding="utf-8"), "requests>=2\n") result = updater.update_package() self.assertTrue(result["updated"]) self.assertTrue(result["requirements_changed"]) @@ -60,6 +63,13 @@ class UpdaterTests(unittest.TestCase): def test_local_modification_is_preserved(self): (self.install / "version.txt").write_text("local work\n", encoding="utf-8") + self.assertFalse(updater.check_for_update()["update_available"]) + self.assertFalse(updater.update_package()["updated"]) + (self.author / "version.txt").write_text("new release\n", encoding="utf-8") + self.commit_and_push() + with self.assertRaises(updater.UpdateError) as caught: + updater.check_for_update() + self.assertEqual(caught.exception.code, "local_changes") with self.assertRaises(updater.UpdateError) as caught: updater.update_package() self.assertEqual(caught.exception.code, "local_changes") diff --git a/utils/updater.py b/utils/updater.py index bb57f19..8cba78a 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -49,8 +49,8 @@ def _git(*args, timeout=60, check=True): return result -def update_package(): - """Fetch the release main branch and fast-forward only a clean local main.""" +def _check_release(): + """Inspect the release before considering any local file changes.""" if not (PLUGIN_DIR / ".git").exists(): raise UpdateError( "not_git", "当前插件不是 Git 安装。", @@ -64,14 +64,7 @@ def update_package(): "请先检查并切换分支;本地分支上的修改不会自动合并。", ) - if _git("status", "--porcelain", "--untracked-files=no").stdout.strip(): - raise UpdateError( - "local_changes", "插件目录有未提交的代码修改。", - "请先保存、提交或暂存修改;更新不会覆盖这些文件。", - ) - old_commit = _git("rev-parse", "HEAD").stdout.strip() - old_requirements = _git("show", "HEAD:requirements.txt", check=False).stdout fetched = _git("fetch", "--no-tags", RELEASE_REPOSITORY_URL, "main", timeout=90, check=False) if fetched.returncode: raise UpdateError( @@ -80,7 +73,7 @@ def update_package(): ) new_commit = _git("rev-parse", "FETCH_HEAD").stdout.strip() if old_commit == new_commit: - return {"updated": False, "version": old_commit[:7], "requirements_changed": False} + return old_commit, new_commit if _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", check=False).returncode: raise UpdateError( @@ -88,6 +81,28 @@ def update_package(): "请手动比较两个分支并合并,不要强制重置本地文件。", ) + if _git("status", "--porcelain", "--untracked-files=no").stdout.strip(): + raise UpdateError( + "local_changes", "插件目录有未提交的代码修改。", + "请先保存、提交或暂存修改;更新不会覆盖这些文件。", + ) + + return old_commit, new_commit + + +def check_for_update(): + """Check whether the installed version can be updated without changing files.""" + old_commit, new_commit = _check_release() + return {"update_available": old_commit != new_commit} + + +def update_package(): + """Fetch the release main branch and fast-forward only a clean local main.""" + old_commit, new_commit = _check_release() + if old_commit == new_commit: + return {"updated": False, "version": old_commit[:7], "requirements_changed": False} + + old_requirements = _git("show", "HEAD:requirements.txt", check=False).stdout new_requirements = _git("show", "FETCH_HEAD:requirements.txt", check=False).stdout merged = _git("merge", "--ff-only", "FETCH_HEAD", check=False) if merged.returncode: diff --git a/web/js/o1keyUpdateButton.js b/web/js/o1keyUpdateButton.js index 2d6d8dc..5732846 100644 --- a/web/js/o1keyUpdateButton.js +++ b/web/js/o1keyUpdateButton.js @@ -1,100 +1,476 @@ import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; -const RELEASE_URL = "https://git.o1key.com/publisher/comfyui_o1key"; -let registered = false; +const BUTTON_ID = "o1key-update-button"; +const DIALOG_ID = "o1key-update-dialog"; +const STYLE_ID = "o1key-update-styles"; +let mountObserver = null; + +const UPDATE_ERRORS = { + local_changes: ["当前安装包含自定义修改,暂时无法自动更新。", "请联系技术支持处理,以免丢失现有内容。"], + not_git: ["当前安装方式暂不支持在线更新。", "请联系技术支持。"], + git_missing: ["当前环境暂不支持在线更新。", "请联系技术支持。"], + wrong_branch: ["当前版本暂时无法自动更新。", "请联系技术支持。"], + diverged: ["当前版本暂时无法自动更新。", "请联系技术支持。"], + merge_blocked: ["更新被现有文件阻止。", "现有文件已保留,请联系技术支持。"], + fetch_failed: ["暂时无法检查新版本。", "请检查网络连接,稍后重试。"], + timeout: ["检查新版本超时。", "请检查网络连接,稍后重试。"], + update_in_progress: ["更新正在进行中。", "请稍候再试。"], +}; + +function ensureStyles() { + if (document.querySelector(`#${STYLE_ID}`)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${DIALOG_ID} { + box-sizing: border-box; + width: min(480px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + padding: 0; + overflow: auto; + border: 1px solid var(--border-color, #454545); + border-radius: 12px; + color: var(--fg-color, #eee); + background: var(--comfy-menu-bg, #202020); + box-shadow: 0 24px 72px rgba(0, 0, 0, .48); + font-family: inherit; + } + #${DIALOG_ID}::backdrop { background: rgba(0, 0, 0, .62); } + #${DIALOG_ID} .o1key-update-header { + display: flex; + align-items: center; + gap: 13px; + padding: 21px 24px; + border-bottom: 1px solid var(--border-color, #3b3b3b); + } + #${DIALOG_ID} .o1key-update-brand { + display: grid; + place-items: center; + flex: none; + width: 38px; + height: 38px; + border: 1px solid rgba(96, 165, 250, .35); + border-radius: 10px; + color: var(--p-primary-color, #60a5fa); + background: rgba(96, 165, 250, .12); + font-size: 17px; + } + #${DIALOG_ID} .o1key-update-heading { flex: 1; min-width: 0; } + #${DIALOG_ID} .o1key-update-eyebrow { + margin: 0 0 3px; + color: var(--p-primary-color, #60a5fa); + font-size: 10px; + font-weight: 700; + letter-spacing: .12em; + } + #${DIALOG_ID} h2 { + margin: 0; + color: var(--p-primary-color, #60a5fa); + font-size: 17px; + font-weight: 700; + line-height: 1.35; + } + #${DIALOG_ID} button { font: inherit; } + #${DIALOG_ID} .o1key-update-close { + display: grid; + place-items: center; + flex: none; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 7px; + color: var(--fg-color, #ccc); + background: transparent; + cursor: pointer; + opacity: .72; + font-size: 22px; + line-height: 1; + } + #${DIALOG_ID} .o1key-update-close:hover { background: rgba(127, 127, 127, .16); opacity: 1; } + #${DIALOG_ID} .o1key-update-body { padding: 22px 24px 24px; } + #${DIALOG_ID} .o1key-update-description { + margin: 0 0 17px; + color: var(--fg-color, #ddd); + font-size: 13px; + line-height: 1.6; + opacity: .75; + } + #${DIALOG_ID} .o1key-update-result { + display: flex; + gap: 13px; + min-height: 72px; + padding: 15px; + border: 1px solid var(--border-color, #454545); + border-radius: 9px; + background: var(--comfy-input-bg, #292929); + } + #${DIALOG_ID} .o1key-update-result[data-kind="error"] { + border-color: rgba(239, 119, 119, .42); + background: rgba(239, 119, 119, .08); + } + #${DIALOG_ID} .o1key-update-result[data-kind="success"], + #${DIALOG_ID} .o1key-update-result[data-kind="available"] { + border-color: rgba(96, 165, 250, .4); + background: rgba(96, 165, 250, .08); + } + #${DIALOG_ID} .o1key-update-state-icon { + display: grid; + place-items: center; + box-sizing: border-box; + flex: 0 0 18px; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--fg-color, #bbb); + font-size: 17px; + opacity: .72; + } + #${DIALOG_ID} [data-kind="error"] .o1key-update-state-icon { color: #ef7777; opacity: 1; } + #${DIALOG_ID} [data-kind="success"] .o1key-update-state-icon, + #${DIALOG_ID} [data-kind="available"] .o1key-update-state-icon { + color: var(--p-primary-color, #60a5fa); + opacity: 1; + } + #${DIALOG_ID} .o1key-update-state-icon.is-loading { opacity: 1; } + #${DIALOG_ID} .o1key-update-state-icon.is-loading::before { + content: ""; + box-sizing: border-box; + width: 16px; + height: 16px; + border: 2px solid rgba(96, 165, 250, .25); + border-top-color: var(--p-primary-color, #60a5fa); + border-radius: 50%; + animation: o1key-update-spin .8s linear infinite; + } + @keyframes o1key-update-spin { to { transform: rotate(360deg); } } + #${DIALOG_ID} .o1key-update-result-copy { min-width: 0; } + #${DIALOG_ID} .o1key-update-status { + margin: 0; + font-size: 14px; + font-weight: 650; + line-height: 1.5; + } + #${DIALOG_ID} [data-kind="busy"] .o1key-update-status, + #${DIALOG_ID} [data-kind="success"] .o1key-update-status, + #${DIALOG_ID} [data-kind="available"] .o1key-update-status { + color: var(--p-primary-color, #60a5fa); + } + #${DIALOG_ID} .o1key-update-suggestion { + margin: 5px 0 0; + font-size: 12px; + line-height: 1.55; + opacity: .72; + } + #${DIALOG_ID} .o1key-update-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + padding: 16px 24px 20px; + border-top: 1px solid var(--border-color, #3b3b3b); + } + #${DIALOG_ID} .o1key-update-actions button { + min-width: 96px; + min-height: 36px; + padding: 8px 16px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s, transform .15s; + } + #${DIALOG_ID} .o1key-update-secondary { + border: 1px solid var(--border-color, #555); + color: var(--p-primary-color, #60a5fa); + background: var(--comfy-input-bg, #292929); + } + #${DIALOG_ID} .o1key-update-secondary:hover { + border-color: var(--p-primary-color, #60a5fa); + background: rgba(96, 165, 250, .1); + } + #${DIALOG_ID} .o1key-update-primary { + border: 1px solid var(--p-primary-500, #3b82f6); + color: #fff; + background: var(--p-primary-500, #3b82f6); + } + #${DIALOG_ID} .o1key-update-primary:hover { + border-color: var(--p-primary-600, #2563eb); + background: var(--p-primary-600, #2563eb); + } + #${DIALOG_ID} button:focus-visible { + outline: 2px solid var(--p-primary-color, #60a5fa); + outline-offset: 2px; + } + #${DIALOG_ID} button:disabled { cursor: default; opacity: .55; } + #${DIALOG_ID} button:disabled:hover { transform: none; } + @media (max-width: 420px) { + #${DIALOG_ID} .o1key-update-header { padding: 18px; } + #${DIALOG_ID} .o1key-update-body { padding: 18px; } + #${DIALOG_ID} .o1key-update-actions { padding: 14px 18px 18px; } + #${DIALOG_ID} .o1key-update-actions button { flex: 1; } + } + `; + document.head.appendChild(style); +} + +function createUpdateDialog() { + const dialog = document.createElement("dialog"); + dialog.id = DIALOG_ID; + dialog.setAttribute("aria-labelledby", "o1key-update-title"); + + const root = document.createElement("div"); + + const header = document.createElement("div"); + header.className = "o1key-update-header"; + + const brand = document.createElement("span"); + brand.className = "pi pi-download o1key-update-brand"; + brand.setAttribute("aria-hidden", "true"); + + const heading = document.createElement("div"); + heading.className = "o1key-update-heading"; + + const eyebrow = document.createElement("p"); + eyebrow.className = "o1key-update-eyebrow"; + eyebrow.textContent = "O1KEY"; + + const title = document.createElement("h2"); + title.id = "o1key-update-title"; + title.textContent = "更新 O1Key 节点包"; + heading.append(eyebrow, title); + + const close = document.createElement("button"); + close.type = "button"; + close.className = "o1key-update-close"; + close.textContent = "×"; + close.setAttribute("aria-label", "关闭"); + close.addEventListener("click", () => dialog.close()); + header.append(brand, heading, close); + + const body = document.createElement("div"); + body.className = "o1key-update-body"; + + const description = document.createElement("p"); + description.className = "o1key-update-description"; + description.textContent = "检查是否有新版本可用。"; + + const resultCard = document.createElement("div"); + resultCard.className = "o1key-update-result"; + + const statusIcon = document.createElement("span"); + statusIcon.className = "pi pi-info-circle o1key-update-state-icon"; + statusIcon.setAttribute("aria-hidden", "true"); + + const resultCopy = document.createElement("div"); + resultCopy.className = "o1key-update-result-copy"; + + const status = document.createElement("p"); + status.className = "o1key-update-status"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + + const suggestion = document.createElement("p"); + suggestion.className = "o1key-update-suggestion"; + resultCopy.append(status, suggestion); + resultCard.append(statusIcon, resultCopy); + body.append(description, resultCard); + + const actions = document.createElement("div"); + actions.className = "o1key-update-actions"; + + const secondary = document.createElement("button"); + secondary.type = "button"; + secondary.className = "o1key-update-secondary"; + secondary.textContent = "关闭"; + secondary.addEventListener("click", () => dialog.close()); + + const primary = document.createElement("button"); + primary.type = "button"; + primary.className = "o1key-update-primary"; + actions.append(secondary, primary); + + function show(message, nextStep = "", kind = "info") { + status.textContent = message; + suggestion.textContent = nextStep; + resultCard.dataset.kind = kind; + statusIcon.className = kind === "busy" + ? "o1key-update-state-icon is-loading" + : `pi ${{ + error: "pi-exclamation-circle", + success: "pi-check-circle", + available: "pi-arrow-circle-down", + }[kind] || "pi-info-circle"} o1key-update-state-icon`; + } + + function setState(state) { + dialog.dataset.state = state; + const busy = state === "checking" || state === "updating"; + primary.disabled = busy; + close.disabled = state === "updating"; + secondary.disabled = state === "updating"; + primary.textContent = { + checking: "检查中…", + available: "立即更新", + updating: "更新中…", + success: "完成", + }[state] || "重新检查"; + secondary.textContent = state === "available" ? "稍后再说" : "关闭"; + } + + function showError(response, result) { + setState("error"); + if (response.status === 404) { + show("更新功能尚未就绪。", "请重启 ComfyUI 并刷新页面后重试。", "error"); + return; + } + const [message, nextStep] = UPDATE_ERRORS[result?.code] || + ["暂时无法完成更新。", "请稍后重试;如果问题持续,请联系技术支持。"]; + show(message, nextStep, "error"); + } + + async function checkForUpdate() { + if (dialog.dataset.state === "checking" || dialog.dataset.state === "updating") return; + setState("checking"); + show("正在检查新版本…", "请稍候。", "busy"); + try { + const checkResponse = await api.fetchApi("/o1key/update/check", { cache: "no-store" }); + if (checkResponse.status === 404) return showError(checkResponse); + let checkResult; + try { + checkResult = await checkResponse.json(); + } catch { + setState("error"); + show("暂时无法读取检查结果。", "请刷新页面后重试。", "error"); + return; + } + if (!checkResponse.ok) { + showError(checkResponse, checkResult); + return; + } + if (typeof checkResult.update_available !== "boolean") { + setState("error"); + show("暂时无法读取检查结果。", "请刷新页面后重试。", "error"); + return; + } + if (!checkResult.update_available) { + setState("current"); + show("已是最新版本。", "无需更新。", "success"); + return; + } + setState("available"); + show("发现新版本。", "现在更新即可使用最新功能。", "available"); + } catch { + setState("error"); + show("暂时无法完成操作。", "请确认 ComfyUI 正在运行,然后重试。", "error"); + } + } + + async function startUpdate() { + if (dialog.dataset.state !== "available") return; + setState("updating"); + show("正在更新…", "请保持 ComfyUI 运行,等待完成。", "busy"); + try { + const response = await api.fetchApi("/o1key/update", { + method: "POST", + headers: { "X-O1Key-Update": "1" }, + }); + if (response.status === 404) return showError(response); + let result; + try { + result = await response.json(); + } catch { + setState("error"); + show("暂时无法读取更新结果。", "请刷新页面后重试。", "error"); + return; + } + if (!response.ok) return showError(response, result); + if (typeof result.updated !== "boolean") { + setState("error"); + show("暂时无法读取更新结果。", "请刷新页面后重试。", "error"); + return; + } + if (result.updated) { + setState("success"); + show( + "更新完成。", + result.requirements_changed + ? "新版本还需要维护人员完成配置,请联系技术支持后重启 ComfyUI。" + : "请重启 ComfyUI,并刷新页面以使用新版本。", + "success", + ); + } else { + setState("current"); + show("已是最新版本。", "无需更新。", "success"); + } + } catch { + setState("error"); + show("暂时无法完成操作。", "请确认 ComfyUI 正在运行,然后重试。", "error"); + } + } + primary.addEventListener("click", () => { + if (dialog.dataset.state === "available") return startUpdate(); + if (dialog.dataset.state === "success") return dialog.close(); + return checkForUpdate(); + }); + dialog.addEventListener("cancel", (event) => { + if (dialog.dataset.state === "updating") event.preventDefault(); + }); + dialog.checkForUpdate = checkForUpdate; + + root.append(header, body, actions); + dialog.append(root); + document.body.appendChild(dialog); + setState("idle"); + show("准备检查更新。", "", "info"); + return dialog; +} + +function openUpdateDialog() { + ensureStyles(); + const dialog = document.querySelector(`#${DIALOG_ID}`) || createUpdateDialog(); + if (!dialog.open) { + dialog.showModal(); + void dialog.checkForUpdate(); + } +} + +function mountUpdateButton() { + const tokenButton = document.querySelector("#o1key-token-manager-button"); + if (!tokenButton?.parentElement) return false; + + let updateButton = document.querySelector(`#${BUTTON_ID}`); + if (!updateButton) { + updateButton = tokenButton.cloneNode(true); + updateButton.id = BUTTON_ID; + updateButton.type = "button"; + updateButton.classList.remove("side-bar-button-selected", "o1key-token-manager-button", "o1key-restart-button"); + updateButton.classList.add("o1key-update-button"); + updateButton.setAttribute("aria-label", "更新"); + updateButton.setAttribute("title", "更新 O1Key 节点包"); + updateButton.removeAttribute("aria-pressed"); + + const icon = updateButton.querySelector(".side-bar-button-icon"); + if (icon) icon.className = "pi pi-download side-bar-button-icon"; + const label = updateButton.querySelector(".side-bar-button-label"); + if (label) label.textContent = "更新"; + + updateButton.addEventListener("click", openUpdateDialog); + } + + if (tokenButton.nextElementSibling !== updateButton) tokenButton.after(updateButton); + return true; +} app.registerExtension({ name: "o1key.updateButton", setup() { - if (registered || app.extensionManager.getSidebarTabs?.().some((tab) => tab.id === "o1key-update")) return; - registered = true; - - app.extensionManager.registerSidebarTab({ - id: "o1key-update", - title: "更新", - tooltip: "更新 O1Key 节点包", - icon: "pi pi-download", - type: "custom", - render(container) { - const root = document.createElement("div"); - root.id = "o1key-update-panel"; - root.style.padding = "16px"; - - const title = document.createElement("h3"); - title.textContent = "更新 O1Key 节点包"; - - const description = document.createElement("p"); - description.textContent = "从 O1Key 发布仓库的 main 分支获取更新。只允许安全快进,不会清理或覆盖本地文件。"; - - const source = document.createElement("p"); - source.textContent = `发布地址:${RELEASE_URL}`; - - const button = document.createElement("button"); - button.type = "button"; - button.textContent = "检查并更新"; - button.className = "p-button"; - - const status = document.createElement("p"); - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - - const suggestion = document.createElement("p"); - - function show(message, nextStep = "", error = false) { - status.textContent = message; - status.style.color = error ? "var(--error-color, #ef7777)" : ""; - suggestion.textContent = nextStep; - } - - button.addEventListener("click", async () => { - if (button.disabled || !window.confirm(`从 ${RELEASE_URL} 的 main 分支检查并更新?本地修改不会被覆盖。`)) return; - button.disabled = true; - show("正在检查本地文件并连接发布仓库…", "请保持 ComfyUI 运行,等待结果。"); - try { - const response = await api.fetchApi("/o1key/update", { - method: "POST", - headers: { "X-O1Key-Update": "1" }, - }); - if (response.status === 404) { - show("更新接口尚未加载。", "重启 ComfyUI 并刷新浏览器后再试。", true); - return; - } - let result; - try { - result = await response.json(); - } catch { - show("无法读取更新结果。", "确认 ComfyUI 正常运行,刷新页面后重试。", true); - return; - } - if (!response.ok) { - show(result.error || "更新未完成。", result.suggestion || "检查 ComfyUI 日志后重试。", true); - return; - } - if (typeof result.updated !== "boolean" || typeof result.version !== "string") { - show("更新接口返回了无效结果。", "重启 ComfyUI 并刷新浏览器后重试。", true); - return; - } - if (result.updated) { - show( - `已更新到 ${result.version}。`, - result.requirements_changed - ? "依赖列表已变化:先在 ComfyUI 使用的 Python 环境中安装 requirements.txt,再重启 ComfyUI。" - : "请重启 ComfyUI,并刷新浏览器以加载新版本。", - ); - } else { - show(`已是最新版本(${result.version})。`, "无需重启。"); - } - } catch { - show("无法连接本地 ComfyUI 更新接口。", "确认 ComfyUI 正在运行,刷新页面后重试。", true); - } finally { - button.disabled = false; - } - }); - - root.append(title, description, source, button, status, suggestion); - container.replaceChildren(root); - }, + if (mountUpdateButton() || mountObserver) return; + mountObserver = new MutationObserver(() => { + if (!mountUpdateButton()) return; + mountObserver.disconnect(); + mountObserver = null; }); + mountObserver.observe(document.body, { childList: true, subtree: true }); }, });