Polish customer update flow and prepare ZIP installation
This commit is contained in:
@@ -42,6 +42,16 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
然后重启 ComfyUI。
|
然后重启 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 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。
|
Windows 用户也可以在 ComfyUI 左侧侧栏打开“更新”面板更新插件;使用前请阅读下方关于本地修改的提示。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -94,12 +104,11 @@ O1KEY_ASYNC_API_BASE_URL=https://your-async-api-domain.com
|
|||||||
|
|
||||||
### 方法一:ComfyUI 侧栏
|
### 方法一:ComfyUI 侧栏
|
||||||
|
|
||||||
1. 点击 ComfyUI 左侧侧栏的“更新”图标。
|
1. 点击 ComfyUI 左侧工具栏中“令牌管理”下方的“更新”按钮。
|
||||||
2. 在面板中点击“检查并更新”,确认从 `git.o1key.com/publisher/comfyui_o1key` 获取后等待结果。
|
2. 等待版本检查。如果发现新版本,确认后等待更新完成;需要时可在面板中重新检查。
|
||||||
3. 如果提示依赖变化,在 ComfyUI 使用的 Python 环境中安装 `requirements.txt`。
|
3. 按完成提示重启 ComfyUI。如果面板提示需要技术支持,请联系维护人员。
|
||||||
4. 重启 ComfyUI。
|
|
||||||
|
|
||||||
> 面板只对没有已跟踪文件修改的 Git `main` 分支执行快进更新。本地代码有修改、历史分叉、网络故障或新版本与未跟踪文件冲突时会给出原因和处理建议,不会强制重置或清理本地文件。已有安装的 `origin` 地址不会被自动改写。
|
> 如果当前安装包含自定义修改,面板会停止更新并保留现有内容。此时请联系维护人员处理。
|
||||||
|
|
||||||
### 方法二:手动更新
|
### 方法二:手动更新
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -240,7 +240,7 @@ try:
|
|||||||
from .utils.o1key_image_jobs import register_o1key_image_job_routes
|
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_image_thumbnail import register_o1key_image_thumbnail_route
|
||||||
from .utils.o1key_video_jobs import register_o1key_video_job_routes
|
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(
|
_O1KEY_IMAGE_JOB_MANAGER = register_o1key_image_job_routes(
|
||||||
PromptServer,
|
PromptServer,
|
||||||
@@ -266,6 +266,25 @@ try:
|
|||||||
_o1key_restart_pending = _restart_threading.Event()
|
_o1key_restart_pending = _restart_threading.Event()
|
||||||
_o1key_update_lock = _restart_threading.Lock()
|
_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")
|
@PromptServer.instance.routes.post("/o1key/update")
|
||||||
async def update_o1key_package(request):
|
async def update_o1key_package(request):
|
||||||
if request.headers.get("X-O1Key-Update") != "1":
|
if request.headers.get("X-O1Key-Update") != "1":
|
||||||
|
|||||||
@@ -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.
|
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.
|
Because the directory is auto-loaded, unused or experimental JavaScript must not be left here.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
## Add or change a node
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
|
||||||
const sourcePath = new URL("../web/js/o1keyUpdateButton.js", import.meta.url);
|
const sourcePath = new URL("../web/js/o1keyUpdateButton.js", import.meta.url);
|
||||||
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
|
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
|
||||||
|
|
||||||
@@ -11,82 +10,227 @@ class Element {
|
|||||||
this.tagName = tagName;
|
this.tagName = tagName;
|
||||||
this.children = [];
|
this.children = [];
|
||||||
this.listeners = new Map();
|
this.listeners = new Map();
|
||||||
|
this.attributes = new Map();
|
||||||
this.style = {};
|
this.style = {};
|
||||||
this.disabled = false;
|
this.dataset = {};
|
||||||
|
this.className = "";
|
||||||
this.textContent = "";
|
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); }
|
updateClasses(add, remove) {
|
||||||
replaceChildren(...children) { this.children = [...children]; }
|
const names = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||||
setAttribute(name, value) { this[name] = value; }
|
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); }
|
addEventListener(name, listener) { this.listeners.set(name, listener); }
|
||||||
|
showModal() { this.open = true; }
|
||||||
|
close() { this.open = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
let extension;
|
const body = new Element("body");
|
||||||
let tab;
|
const head = new Element("head");
|
||||||
let requests = 0;
|
const toolbar = new Element("div");
|
||||||
let fetchFailure = false;
|
body.append(toolbar);
|
||||||
let response = { ok: true, async json() { return { updated: true, version: "abc1234", requirements_changed: false }; } };
|
const document = {
|
||||||
const app = {
|
body,
|
||||||
registerExtension(value) { extension = value; },
|
head,
|
||||||
extensionManager: {
|
createElement: (tag) => new Element(tag),
|
||||||
getSidebarTabs() { return tab ? [tab] : []; },
|
querySelector: (selector) => body.querySelector(selector) || head.querySelector(selector),
|
||||||
registerSidebarTab(value) { tab = value; },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
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 = {
|
const api = {
|
||||||
async fetchApi(path, options) {
|
async fetchApi(path, options) {
|
||||||
requests++;
|
requests.push({ path, options });
|
||||||
assert.equal(path, "/o1key/update");
|
|
||||||
assert.equal(options.method, "POST");
|
|
||||||
assert.equal(options.headers["X-O1Key-Update"], "1");
|
|
||||||
if (fetchFailure) throw new Error("offline");
|
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();
|
extension.setup();
|
||||||
assert.equal(tab.id, "o1key-update");
|
assert.ok(observer);
|
||||||
assert.equal(tab.title, "更新");
|
|
||||||
const firstTab = tab;
|
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();
|
extension.setup();
|
||||||
assert.equal(tab, firstTab);
|
assert.equal(toolbar.children.length, 3);
|
||||||
|
|
||||||
const container = new Element("div");
|
responses.push(reply({ update_available: false }));
|
||||||
tab.render(container);
|
update.listeners.get("click")();
|
||||||
const [, description, sourceLabel, button, status, suggestion] = container.children[0].children;
|
const checkingDialog = document.querySelector("#o1key-update-dialog");
|
||||||
assert.match(description.textContent, /安全快进/);
|
const checkingIcon = checkingDialog.children[0].children[1].children[1].children[0];
|
||||||
assert.match(sourceLabel.textContent, /git\.o1key\.com\/publisher\/comfyui_o1key/);
|
assert.equal(checkingDialog.dataset.state, "checking");
|
||||||
await button.listeners.get("click")();
|
assert.match(checkingIcon.className, /is-loading/);
|
||||||
assert.equal(requests, 1);
|
assert.doesNotMatch(checkingIcon.className, /pi-spin/);
|
||||||
assert.match(status.textContent, /abc1234/);
|
assert.match(head.children[0].textContent, /flex: 0 0 18px/);
|
||||||
assert.match(suggestion.textContent, /重启 ComfyUI/);
|
assert.match(head.children[0].textContent, /animation: o1key-update-spin/);
|
||||||
|
assert.match(head.children[0].textContent, /--p-primary-500/);
|
||||||
response = { ok: true, async json() { return { updated: true, version: "def5678", requirements_changed: true }; } };
|
await new Promise(setImmediate);
|
||||||
await button.listeners.get("click")();
|
const dialog = document.querySelector("#o1key-update-dialog");
|
||||||
assert.equal(requests, 2);
|
assert.equal(dialog.open, true);
|
||||||
assert.match(suggestion.textContent, /安装 requirements.txt/);
|
const [header, content, actions] = dialog.children[0].children;
|
||||||
|
const [description, resultCard] = content.children;
|
||||||
response = { ok: true, async json() { return { updated: false, version: "def5678" }; } };
|
const [status, suggestion] = resultCard.children[1].children;
|
||||||
await button.listeners.get("click")();
|
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(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: "请先保存修改" }; } };
|
responses.push(reply({ update_available: false }));
|
||||||
await button.listeners.get("click")();
|
await primary.listeners.get("click")();
|
||||||
assert.match(status.textContent, /本地修改/);
|
assert.equal(requests[1].path, "/o1key/update/check");
|
||||||
assert.match(suggestion.textContent, /请先保存修改/);
|
assert.match(status.textContent, /已是最新版本/);
|
||||||
|
|
||||||
response = { ok: false, status: 404 };
|
responses.push(reply({ update_available: true }));
|
||||||
await button.listeners.get("click")();
|
await primary.listeners.get("click")();
|
||||||
assert.match(status.textContent, /尚未加载/);
|
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.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"); } };
|
responses.push(reply({ update_available: true }));
|
||||||
await button.listeners.get("click")();
|
await dialog.checkForUpdate();
|
||||||
assert.match(status.textContent, /无法读取/);
|
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;
|
fetchFailure = true;
|
||||||
await button.listeners.get("click")();
|
await primary.listeners.get("click")();
|
||||||
assert.match(status.textContent, /无法连接本地 ComfyUI/);
|
assert.match(status.textContent, /暂时无法完成操作/);
|
||||||
assert.equal(button.disabled, false);
|
assert.equal(primary.disabled, false);
|
||||||
|
assert.equal(head.children.length, 1);
|
||||||
|
header.children[2].listeners.get("click")();
|
||||||
|
assert.equal(dialog.open, false);
|
||||||
|
|||||||
@@ -48,11 +48,14 @@ class UpdaterTests(unittest.TestCase):
|
|||||||
self.git(self.author, "push", "origin", "main")
|
self.git(self.author, "push", "origin", "main")
|
||||||
|
|
||||||
def test_fast_forward_and_requirements_change(self):
|
def test_fast_forward_and_requirements_change(self):
|
||||||
|
self.assertFalse(updater.check_for_update()["update_available"])
|
||||||
self.assertFalse(updater.update_package()["updated"])
|
self.assertFalse(updater.update_package()["updated"])
|
||||||
# The release URL, not the user's origin, determines the update source.
|
# 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.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.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8")
|
||||||
self.commit_and_push()
|
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()
|
result = updater.update_package()
|
||||||
self.assertTrue(result["updated"])
|
self.assertTrue(result["updated"])
|
||||||
self.assertTrue(result["requirements_changed"])
|
self.assertTrue(result["requirements_changed"])
|
||||||
@@ -60,6 +63,13 @@ class UpdaterTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_local_modification_is_preserved(self):
|
def test_local_modification_is_preserved(self):
|
||||||
(self.install / "version.txt").write_text("local work\n", encoding="utf-8")
|
(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:
|
with self.assertRaises(updater.UpdateError) as caught:
|
||||||
updater.update_package()
|
updater.update_package()
|
||||||
self.assertEqual(caught.exception.code, "local_changes")
|
self.assertEqual(caught.exception.code, "local_changes")
|
||||||
|
|||||||
+25
-10
@@ -49,8 +49,8 @@ def _git(*args, timeout=60, check=True):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def update_package():
|
def _check_release():
|
||||||
"""Fetch the release main branch and fast-forward only a clean local main."""
|
"""Inspect the release before considering any local file changes."""
|
||||||
if not (PLUGIN_DIR / ".git").exists():
|
if not (PLUGIN_DIR / ".git").exists():
|
||||||
raise UpdateError(
|
raise UpdateError(
|
||||||
"not_git", "当前插件不是 Git 安装。",
|
"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_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)
|
fetched = _git("fetch", "--no-tags", RELEASE_REPOSITORY_URL, "main", timeout=90, check=False)
|
||||||
if fetched.returncode:
|
if fetched.returncode:
|
||||||
raise UpdateError(
|
raise UpdateError(
|
||||||
@@ -80,7 +73,7 @@ def update_package():
|
|||||||
)
|
)
|
||||||
new_commit = _git("rev-parse", "FETCH_HEAD").stdout.strip()
|
new_commit = _git("rev-parse", "FETCH_HEAD").stdout.strip()
|
||||||
if old_commit == new_commit:
|
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:
|
if _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", check=False).returncode:
|
||||||
raise UpdateError(
|
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
|
new_requirements = _git("show", "FETCH_HEAD:requirements.txt", check=False).stdout
|
||||||
merged = _git("merge", "--ff-only", "FETCH_HEAD", check=False)
|
merged = _git("merge", "--ff-only", "FETCH_HEAD", check=False)
|
||||||
if merged.returncode:
|
if merged.returncode:
|
||||||
|
|||||||
+427
-51
@@ -1,100 +1,476 @@
|
|||||||
import { app } from "../../../scripts/app.js";
|
import { app } from "../../../scripts/app.js";
|
||||||
import { api } from "../../../scripts/api.js";
|
import { api } from "../../../scripts/api.js";
|
||||||
|
|
||||||
const RELEASE_URL = "https://git.o1key.com/publisher/comfyui_o1key";
|
const BUTTON_ID = "o1key-update-button";
|
||||||
let registered = false;
|
const DIALOG_ID = "o1key-update-dialog";
|
||||||
|
const STYLE_ID = "o1key-update-styles";
|
||||||
|
let mountObserver = null;
|
||||||
|
|
||||||
app.registerExtension({
|
const UPDATE_ERRORS = {
|
||||||
name: "o1key.updateButton",
|
local_changes: ["当前安装包含自定义修改,暂时无法自动更新。", "请联系技术支持处理,以免丢失现有内容。"],
|
||||||
setup() {
|
not_git: ["当前安装方式暂不支持在线更新。", "请联系技术支持。"],
|
||||||
if (registered || app.extensionManager.getSidebarTabs?.().some((tab) => tab.id === "o1key-update")) return;
|
git_missing: ["当前环境暂不支持在线更新。", "请联系技术支持。"],
|
||||||
registered = true;
|
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");
|
||||||
|
|
||||||
app.extensionManager.registerSidebarTab({
|
|
||||||
id: "o1key-update",
|
|
||||||
title: "更新",
|
|
||||||
tooltip: "更新 O1Key 节点包",
|
|
||||||
icon: "pi pi-download",
|
|
||||||
type: "custom",
|
|
||||||
render(container) {
|
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.id = "o1key-update-panel";
|
|
||||||
root.style.padding = "16px";
|
|
||||||
|
|
||||||
const title = document.createElement("h3");
|
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 节点包";
|
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");
|
const description = document.createElement("p");
|
||||||
description.textContent = "从 O1Key 发布仓库的 main 分支获取更新。只允许安全快进,不会清理或覆盖本地文件。";
|
description.className = "o1key-update-description";
|
||||||
|
description.textContent = "检查是否有新版本可用。";
|
||||||
|
|
||||||
const source = document.createElement("p");
|
const resultCard = document.createElement("div");
|
||||||
source.textContent = `发布地址:${RELEASE_URL}`;
|
resultCard.className = "o1key-update-result";
|
||||||
|
|
||||||
const button = document.createElement("button");
|
const statusIcon = document.createElement("span");
|
||||||
button.type = "button";
|
statusIcon.className = "pi pi-info-circle o1key-update-state-icon";
|
||||||
button.textContent = "检查并更新";
|
statusIcon.setAttribute("aria-hidden", "true");
|
||||||
button.className = "p-button";
|
|
||||||
|
const resultCopy = document.createElement("div");
|
||||||
|
resultCopy.className = "o1key-update-result-copy";
|
||||||
|
|
||||||
const status = document.createElement("p");
|
const status = document.createElement("p");
|
||||||
|
status.className = "o1key-update-status";
|
||||||
status.setAttribute("role", "status");
|
status.setAttribute("role", "status");
|
||||||
status.setAttribute("aria-live", "polite");
|
status.setAttribute("aria-live", "polite");
|
||||||
|
|
||||||
const suggestion = document.createElement("p");
|
const suggestion = document.createElement("p");
|
||||||
|
suggestion.className = "o1key-update-suggestion";
|
||||||
|
resultCopy.append(status, suggestion);
|
||||||
|
resultCard.append(statusIcon, resultCopy);
|
||||||
|
body.append(description, resultCard);
|
||||||
|
|
||||||
function show(message, nextStep = "", error = false) {
|
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;
|
status.textContent = message;
|
||||||
status.style.color = error ? "var(--error-color, #ef7777)" : "";
|
|
||||||
suggestion.textContent = nextStep;
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
function setState(state) {
|
||||||
if (button.disabled || !window.confirm(`从 ${RELEASE_URL} 的 main 分支检查并更新?本地修改不会被覆盖。`)) return;
|
dialog.dataset.state = state;
|
||||||
button.disabled = true;
|
const busy = state === "checking" || state === "updating";
|
||||||
show("正在检查本地文件并连接发布仓库…", "请保持 ComfyUI 运行,等待结果。");
|
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 {
|
try {
|
||||||
const response = await api.fetchApi("/o1key/update", {
|
const response = await api.fetchApi("/o1key/update", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "X-O1Key-Update": "1" },
|
headers: { "X-O1Key-Update": "1" },
|
||||||
});
|
});
|
||||||
if (response.status === 404) {
|
if (response.status === 404) return showError(response);
|
||||||
show("更新接口尚未加载。", "重启 ComfyUI 并刷新浏览器后再试。", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await response.json();
|
result = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
show("无法读取更新结果。", "确认 ComfyUI 正常运行,刷新页面后重试。", true);
|
setState("error");
|
||||||
|
show("暂时无法读取更新结果。", "请刷新页面后重试。", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) return showError(response, result);
|
||||||
show(result.error || "更新未完成。", result.suggestion || "检查 ComfyUI 日志后重试。", true);
|
if (typeof result.updated !== "boolean") {
|
||||||
return;
|
setState("error");
|
||||||
}
|
show("暂时无法读取更新结果。", "请刷新页面后重试。", "error");
|
||||||
if (typeof result.updated !== "boolean" || typeof result.version !== "string") {
|
|
||||||
show("更新接口返回了无效结果。", "重启 ComfyUI 并刷新浏览器后重试。", true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.updated) {
|
if (result.updated) {
|
||||||
|
setState("success");
|
||||||
show(
|
show(
|
||||||
`已更新到 ${result.version}。`,
|
"更新完成。",
|
||||||
result.requirements_changed
|
result.requirements_changed
|
||||||
? "依赖列表已变化:先在 ComfyUI 使用的 Python 环境中安装 requirements.txt,再重启 ComfyUI。"
|
? "新版本还需要维护人员完成配置,请联系技术支持后重启 ComfyUI。"
|
||||||
: "请重启 ComfyUI,并刷新浏览器以加载新版本。",
|
: "请重启 ComfyUI,并刷新页面以使用新版本。",
|
||||||
|
"success",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
show(`已是最新版本(${result.version})。`, "无需重启。");
|
setState("current");
|
||||||
|
show("已是最新版本。", "无需更新。", "success");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
show("无法连接本地 ComfyUI 更新接口。", "确认 ComfyUI 正在运行,刷新页面后重试。", true);
|
setState("error");
|
||||||
} finally {
|
show("暂时无法完成操作。", "请确认 ComfyUI 正在运行,然后重试。", "error");
|
||||||
button.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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(title, description, source, button, status, suggestion);
|
root.append(header, body, actions);
|
||||||
container.replaceChildren(root);
|
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 (mountUpdateButton() || mountObserver) return;
|
||||||
|
mountObserver = new MutationObserver(() => {
|
||||||
|
if (!mountUpdateButton()) return;
|
||||||
|
mountObserver.disconnect();
|
||||||
|
mountObserver = null;
|
||||||
});
|
});
|
||||||
|
mountObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user