From 522beed024974816cb1291b384d231a01239dae7 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 20 Aug 2026 18:49:00 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20upgrade-complete=20notification=20?= =?UTF-8?q?=E2=80=94=20GUI=20banner=20+=20one-click=20restart=20to=20apply?= =?UTF-8?q?=20(rant=202026-08-20T18:30:57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daemon pong now carries current_version (reads ~/.emrg/install/version.txt, raw data, zero judgment) - main.js init + status events pass current_version through; new emrg:restartDaemon IPC sends shutdown (source=gui-restart) - renderer: tracks lastKnownVersion, shows upgrade banner on version change, restart button triggers daemon restart (connManager restart-recovery respawns new code) - i18n keys zh/en; index.html + CSS banner; tests: daemon pong current_version + GUI banner pos/neg + restart wiring --- Agent.md | 2 +- emrg/gui/main.js | 19 ++++++++-- emrg/gui/preload.js | 1 + emrg/gui/renderer/css/layout.css | 23 ++++++++++++ emrg/gui/renderer/index.html | 7 ++++ emrg/gui/renderer/js/app.js | 38 ++++++++++++++++++++ emrg/gui/renderer/js/i18n.js | 6 ++++ emrg/gui/test/renderer.smoke.test.js | 53 ++++++++++++++++++++++++++++ emrg/server/daemon.py | 17 +++++++++ tests/test_daemon.py | 32 +++++++++++++++++ 10 files changed, 195 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index 12c4196c..60c634f0 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (982) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (983) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index bcc56d37..cd5c8245 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -279,6 +279,7 @@ vision = false server_id: pong?.identity?.instance_id || "", model: pong?.model || "", evolution_count: pong?.evolution_count ?? 0, // G19:init 透传演化计数(waitForPong 已消耗 pong) + current_version: pong?.current_version || "", // rant 18:30:57:安装版本(GUI 对比显示升级横幅) version: APP_VERSION, // WorkBuddy P3:版本号随 package.json 走(此前 renderer 硬编码 v0.2.7) sessions, open_sessions: openSessionsList(), @@ -315,6 +316,20 @@ vision = false ipcMain.handle("emrg:listSessions", async () => listSessions()); + // Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让 + // daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新 + // 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。 + ipcMain.handle("emrg:restartDaemon", async () => { + const conn = activeConn(); + if (!conn || !conn.connected) throw new Error("daemon not connected"); + try { + conn.sendCommand("shutdown", { source: "gui-restart" }); + } catch (e) { + throw new Error(`shutdown failed: ${e.message}`); + } + return { ok: true }; + }); + ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); // P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径) @@ -851,7 +866,7 @@ vision = false const sessions = await listSessions(); sendToRenderer("sessions", { sessions }); const pong = await waitForPong(); - sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model }); + sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model, current_version: pong?.current_version || "" }); logger.info("[gui] connManager recovery complete"); } catch (e) { logger.warn(`[gui] post-recovery refresh failed: ${e.message}`); @@ -1076,7 +1091,7 @@ vision = false const sessions = await listSessions(); sendToRenderer("sessions", { sessions }); const pong = await waitForPong(); - sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model }); + sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model, current_version: pong?.current_version || "" }); } }, delay); } diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 2e72bf9b..c09cd058 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -10,6 +10,7 @@ const api = { init: () => ipcRenderer.invoke("emrg:init"), sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload), listSessions: () => ipcRenderer.invoke("emrg:listSessions"), + restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"), switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 407d79d3..e0f97b4f 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -392,6 +392,29 @@ } #github-banner .btn { padding: 4px 12px; min-height: 0; font-size: var(--fs-secondary); } +/* ── 升级完成横幅(rant 2026-08-20T18:30:57:版本变化 → 提示 + 一键重启) ── */ +#upgrade-banner { + position: absolute; + top: var(--sp-3); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--sp-2); + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px var(--sp-3) 6px var(--sp-4); + font-size: var(--fs-secondary); + box-shadow: var(--shadow-md); + z-index: 20; + animation: banner-in var(--dur-med) var(--ease); + white-space: nowrap; + max-width: 90%; +} +#upgrade-banner .btn { padding: 4px 12px; min-height: 0; font-size: var(--fs-secondary); } + /* ── 侧边栏折叠(⌘B / 窄屏) ────────────── */ body.sidebar-collapsed #sidebar { display: none; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index d15ff8f8..d58f10fb 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -57,6 +57,13 @@ + + +
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 53cd7ad2..ba843810 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -76,6 +76,8 @@ const App = (() => { state.serverId = init.server_id || ""; state.model = init.model || ""; state.version = init.version || ""; + state.currentVersion = init.current_version || ""; // rant 18:30:57:安装版本(升级横幅对比基准) + state.lastKnownVersion = state.currentVersion; state.evolutionCount = init.evolution_count ?? null; state.lastKnownEvolutionCount = state.evolutionCount; updateConnectionDot(init.config_exists && init.api_key_configured ? "green" : "gray"); @@ -1335,6 +1337,39 @@ const App = (() => { $("conn-banner").classList.add("hidden"); } + // ── 升级完成横幅(rant 2026-08-20T18:30:57) ───────────── + function maybeShowUpgradeBanner(currentVersion) { + if (!currentVersion) return; // 无版本数据(dev 运行)→ 不显示 + if (currentVersion === state.lastKnownVersion) return; // 版本未变 + const b = $("upgrade-banner"); + if (!b) return; + const msg = $("upgrade-banner-msg"); + if (msg) msg.textContent = _t("app.upgradeBannerMsg", { version: currentVersion }); + b.classList.remove("hidden"); + state.lastKnownVersion = currentVersion; // 已提示,防重复弹 + } + function hideUpgradeBanner() { + const b = $("upgrade-banner"); + if (b) b.classList.add("hidden"); + } + function initUpgradeBanner() { + const restart = $("upgrade-banner-restart"); + if (restart) restart.addEventListener("click", async () => { + restart.disabled = true; + try { + await window.emrg.restartDaemon(); + // shutdown 已发——connManager restart-recovery 会重新 spawn 新 daemon 并重连; + // 恢复后 status 事件里 current_version 已 = 新版本 → 横幅消失。 + hideUpgradeBanner(); + } catch (e) { + restart.disabled = false; + Chat.addSystemMessage(_t("app.upgradeRestartFailed", { msg: e.message })); + } + }); + const dismiss = $("upgrade-banner-dismiss"); + if (dismiss) dismiss.addEventListener("click", hideUpgradeBanner); + } + // ── 事件处理(main 已分类) ───────────── async function handleEvent(evt) { const { type, data } = evt; @@ -1540,6 +1575,8 @@ const App = (() => { if (data.server_id) state.serverId = data.server_id; if (data.model) state.model = data.model; state.evolutionCount = data.evolution_count ?? state.evolutionCount; + // rant 18:30:57:pong 携带 current_version → 对比已知版本,变化则弹升级横幅 + maybeShowUpgradeBanner(data.current_version || state.currentVersion); updateModelSwitcher(); updateGrowthCard(); maybeShowEvolutionToast(); @@ -1746,6 +1783,7 @@ const App = (() => { Dialogs.initTaskManagement(); // rant 18:23:15 P3:定时任务/自定义类型管理 Dialogs.initRantPanel(); // rant 14:10:14 P4:rant 面板(筛选/新建) initGithubBanner(); // Windows GCM rant Stage 2:演化需 GitHub 但未认证时的连接横幅 + initUpgradeBanner(); // rant 18:30:57:升级完成横幅(重启生效按钮) initModelSwitcher(); initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式 ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏) diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index ef60ac08..960c81a0 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -416,6 +416,9 @@ const I18N = (() => { "app.modelSwitchFailed": "切换模型失败了:{msg}", "app.modelListFailed": "读取模型列表失败了:{msg}", "app.askModeNotice": "Ask 模式:我只对话,不执行工具。输入内容问我就好。", + "app.upgradeBannerMsg": "EMRG 已升级到 {version},重启后生效", + "app.upgradeRestartFailed": "重启失败:{msg}", + "app.upgradeRestartBtn": "重启生效", "app.unknownResult": "结果未知——连接中断", "app.error": "出了点问题:{msg}", "app.unknownError": "未知错误", @@ -826,6 +829,9 @@ const I18N = (() => { "app.modelSwitchFailed": "Failed to switch model: {msg}", "app.modelListFailed": "Failed to load models: {msg}", "app.askModeNotice": "Ask mode: I only chat — no tools. Just ask.", + "app.upgradeBannerMsg": "EMRG upgraded to {version} — restart to apply", + "app.upgradeRestartFailed": "Restart failed: {msg}", + "app.upgradeRestartBtn": "Restart to apply", "app.unknownResult": "Result unknown — connection lost", "app.error": "Something went wrong: {msg}", "app.unknownError": "Unknown error", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index c5403ae3..c004c0b4 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -148,6 +148,7 @@ const ELEMENT_IDS = [ "result-tabs", "result-tab-files", "result-tab-artifacts", "result-tabbar", "result-files", "result-viewer", "result-resizer", "growth-card", "growth-count", "about-recent", "github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss", + "upgrade-banner", "upgrade-banner-msg", "upgrade-banner-restart", "upgrade-banner-dismiss", "toast", "toast-msg", // rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) "task-list", "task-add-btn", "task-template-mgr-btn", @@ -733,6 +734,58 @@ test("GCM rant Stage 2:演化增长 + 未认证 → GitHub 连接横幅出现 assert.ok(toastBlock.includes("maybeShowGithubBanner()"), "演化增长应触发 GitHub 横幅检查"); }); +test("rant 18:30:57:版本变化 → 升级横幅出现 + 重启按钮触发 restartDaemon(正反两态)", async () => { + // 正态:status current_version 与已知版本不同 → 横幅出现 + const { ctx } = makeSandbox({ + init: async () => ({ + config_exists: true, + api_key_configured: true, + current_version: "0.2.58", + sessions: [], + }), + }); + await tick(); + await vm.runInContext(`(function() { + document.getElementById("upgrade-banner").classList.add("hidden"); + App.state.lastKnownVersion = "0.2.58"; + App.handleEvent({ type: "status", data: { connected: true, current_version: "0.2.59" } }); + })()`, ctx); + const visible = vm.runInContext( + '!document.getElementById("upgrade-banner").classList.contains("hidden")', + ctx + ); + assert.strictEqual(visible, true, "版本变化 → 升级横幅应出现"); + + // 负态:版本未变 → 横幅保持隐藏 + const { ctx: ctx2 } = makeSandbox({}); + await tick(); + await vm.runInContext(`(function() { + document.getElementById("upgrade-banner").classList.add("hidden"); + App.state.lastKnownVersion = "0.2.58"; + App.handleEvent({ type: "status", data: { connected: true, current_version: "0.2.58" } }); + })()`, ctx2); + const hidden = vm.runInContext( + 'document.getElementById("upgrade-banner").classList.contains("hidden")', + ctx2 + ); + assert.strictEqual(hidden, true, "版本未变 → 横幅应保持隐藏"); + + // 重启按钮 → restartDaemon 调用 + let restarted = false; + const { ctx: ctx3 } = makeSandbox({}); + await tick(); + await vm.runInContext(`(async function() { + App._testRestartDaemon = () => { window.__restartCalled = true; }; + })()`, ctx3); + const appSrc = fs.readFileSync(path.join(RENDERER_JS, "app.js"), "utf8"); + assert.ok(appSrc.includes("restartDaemon"), "重启按钮应调用 window.emrg.restartDaemon"); + const GUI_DIR = path.join(__dirname, ".."); + const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8"); + assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC"); + const preloadSrc = fs.readFileSync(path.join(GUI_DIR, "preload.js"), "utf8"); + assert.ok(preloadSrc.includes("restartDaemon"), "preload 应暴露 restartDaemon"); +}); + test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => { let renamed = null; const { ctx, els } = makeSandbox({ diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 426581fe..b1855063 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -632,6 +632,19 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) -> logger.debug("upgrade session failed (retry next tick)", exc_info=True) self._session_busy[session_id] = False + def _current_installed_version(self) -> str: + """Current installed EMRG version from ~/.emrg/install/version.txt. + + Rant 2026-08-20T18:30:57: raw data only, zero judgment — the GUI + compares it with its last known version and shows the upgrade banner. + Returns "" when the file is missing (dev/standalone runs). + """ + try: + v = (Path.home() / ".emrg" / "install" / "version.txt").read_text(encoding="utf-8").strip() + return v + except (OSError, ValueError): + return "" + def _evolution_count(self) -> int: """Total completed evolution cycles across scheduler handlers + disk. @@ -1397,6 +1410,10 @@ async def _process_message( "started_at": self.start_time.isoformat(), "pid": os.getpid(), "model": self.llm.config.model, + # Rant 2026-08-20T18:30:57:并入当前安装版本——GUI 轮询 pong 时对比 + # 上次已知版本,发现变化 → 弹"已升级,重启生效"横幅。daemon 只回原始数据, + # 零判断逻辑(升级判断由 GUI 负责)。 + "current_version": self._current_installed_version(), }) return diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d4adff51..0c8a2508 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1719,3 +1719,35 @@ def test_shutdown_message_missing_source_degrades(tmp_path, caplog): assert "shutdown requested by client (peer=unknown peer, source=unknown)" in caplog.text assert server._stop_reason == "shutdown_msg" + + +def test_pong_includes_current_version(tmp_path, monkeypatch): + """Pong carries current_version from ~/.emrg/install/version.txt. + + Rant 2026-08-20T18:30:57: the GUI compares the version it last saw with + the daemon's installed version and shows the upgrade banner on change. + Missing file → empty string (dev runs), never an error. + """ + import emrg.server.daemon as daemon_mod + install = tmp_path / ".emrg" / "install" + install.mkdir(parents=True) + (install / "version.txt").write_text("0.2.59\n", encoding="utf-8") + + monkeypatch.setattr(daemon_mod.Path, "home", lambda: tmp_path) + + server = _make_server() + assert server._current_installed_version() == "0.2.59" + + # Missing file → "" (no crash) + empty = tmp_path / "no-install" + monkeypatch.setattr(daemon_mod.Path, "home", lambda: empty) + assert server._current_installed_version() == "" + + # Pong payload includes the field + monkeypatch.setattr(daemon_mod.Path, "home", lambda: tmp_path) + writer = _FakeWriter() + import asyncio + asyncio.run(server._process_message({"type": "ping"}, writer)) + frame = _last_frame(writer) + assert frame["type"] == "pong" + assert frame["current_version"] == "0.2.59"