diff --git a/Agent.md b/Agent.md index 037bbdf..3d7e93b 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -112,8 +112,8 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (687) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (688) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.cn.md b/README.cn.md index 4ac1845..0aebf4e 100644 --- a/README.cn.md +++ b/README.cn.md @@ -144,7 +144,7 @@ EMRG 不只是追赶——它自己追上来。 贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(183 项:43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) +快速检查:`uv run pytest tests/ -v`(当前 688 项)· `cd emrg/gui && npm test`(187 项:43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index 6f21ba0..ba4877f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md). -Quick checks: `uv run pytest tests/ -v` (currently 687 items) · `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) +Quick checks: `uv run pytest tests/ -v` (currently 688 items) · `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) --- diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 676e3b1..3a44a07 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -559,11 +559,17 @@ vision = false return { authenticated: Boolean(frame.authenticated), user: frame.user || null }; }); - ipcMain.handle("emrg:updateCheck", async () => { + ipcMain.handle("emrg:updateCheck", async (_e, { force } = {}) => { // Auto update-check prompt (rant 2026-08-10T07:12:12): query daemon's // cached latest release; display-only, no auto download/install. + // force=true (rant 2026-08-11T09:18:16): settings manual check button + // — daemon runs a fresh GitHub fetch instead of returning the cache. try { - const frame = await requireConn().sendCommandAndWait("update_check", {}, 10000); + const frame = await requireConn().sendCommandAndWait( + "update_check", + { force: Boolean(force) }, + 10000, + ); return { current_version: frame.current_version || "", latest_version: frame.latest_version || "", diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 62b34a9..fd5d9a9 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -33,7 +33,7 @@ const api = { sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload), githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), - updateCheck: () => ipcRenderer.invoke("emrg:updateCheck"), + updateCheck: (payload) => ipcRenderer.invoke("emrg:updateCheck", payload), updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 12aa42f..7c0c886 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -170,6 +170,9 @@

设置

EMRG v0.2.8 · 🌱 已自我进化 0
+
+ +
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index acfc2ec..209194f 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -119,6 +119,11 @@ const App = (() => { // 修复:boot 成功路径必须启用输入框(此前仅 done/cancelled/disconnected/error // 回调会调用 setComposerDisabled(false),形成"需先发消息才能启用输入框"死锁) setComposerDisabled(false); + // rant 2026-08-11T09:18:16:启动主动更新提示——不开设置也能看到新版本 + // (非阻塞系统消息,一次幂等;daemon 未就绪时静默失败) + if (window.EMRG_Dialogs?.promptUpdateAtStartup) { + Dialogs.promptUpdateAtStartup(); + } } catch (e) { Chat.addSystemMessage(_t("app.bootFailed", { msg: e.message })); } diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 834ab4a..46b5265 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -234,6 +234,8 @@ const Dialogs = (() => { await refreshGithubStatus(); // Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows // a one-time non-intrusive line when a newer release exists. + // rant 2026-08-11T09:18:16:手动"检查更新"按钮(force 重新检查) + initUpdateCheckButton(); await refreshUpdateCheck(); } catch (e) { Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message })); @@ -490,19 +492,23 @@ const Dialogs = (() => { // Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one // line in the about area, never a modal — no auto download/install. - async function refreshUpdateCheck() { - const el = $("about-update"); - if (!el) return; // 元素缺失(测试桩)时忽略 + // force (rant 2026-08-11T09:18:16): settings manual check button — run a + // fresh GitHub fetch instead of returning the daemon's cached result. + // ⚠️ 局部变量命名 updEl(勿用 el——会遮蔽模块级 el() 元素工厂,el("a",…) + // 抛 TypeError → catch 吞掉 → 更新行永远 hidden,正是 #602 隐藏缺陷) + async function refreshUpdateCheck({ force = false } = {}) { + const updEl = $("about-update"); + if (!updEl) return; // 元素缺失(测试桩)时忽略 try { - const u = await window.emrg.updateCheck(); + const u = await window.emrg.updateCheck({ force }); if (!u || !u.enabled || !u.has_update || !u.latest_version) { - el.classList.add("hidden"); - el.textContent = ""; + updEl.classList.add("hidden"); + updEl.textContent = ""; return; } if (u.latest_version === u.prompted_version) { - el.classList.add("hidden"); - el.textContent = ""; + updEl.classList.add("hidden"); + updEl.textContent = ""; return; } const link = el("a", { @@ -510,17 +516,51 @@ const Dialogs = (() => { target: "_blank", rel: "noopener", }, _t("settings.updateAvailable", { latest: u.latest_version })); - el.textContent = ""; - el.appendChild(link); - el.classList.remove("hidden"); + updEl.textContent = ""; + updEl.appendChild(link); + updEl.classList.remove("hidden"); // 幂等:同版本只提示一次 try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } } catch { - el.classList.add("hidden"); - el.textContent = ""; + updEl.classList.add("hidden"); + updEl.textContent = ""; } } + // 设置页"检查更新"手动按钮(rant 2026-08-11T09:18:16):点击立即强制 + // 重新检查(不等 TTL 轮询),检查中显示"检查中…",完成后恢复按钮。 + function initUpdateCheckButton() { + const btn = $("about-update-check-btn"); + if (!btn) return; // 元素缺失(测试桩)时忽略 + btn.addEventListener("click", async () => { + const original = btn.textContent; + btn.disabled = true; + btn.textContent = _t("settings.checkingUpdate"); + try { + await refreshUpdateCheck({ force: true }); + } finally { + btn.disabled = false; + btn.textContent = original; + } + }); + } + + // 启动主动更新提示(rant 2026-08-11T09:18:16):GUI 启动成功路径调用, + // 不依赖打开设置对话框——有新版本且未提示过时输出一条非阻塞系统消息 + // (对齐 TUI 启动 system 行)。boot 时 daemon 可能未就绪 → 静默失败。 + async function promptUpdateAtStartup() { + try { + const u = await window.emrg.updateCheck({ force: false }); + if (!u || !u.enabled || !u.has_update || !u.latest_version) return; + if (u.latest_version === u.prompted_version) return; + Chat.addSystemMessage( + _t("app.updateAvailable", { latest: u.latest_version }), + ); + // 幂等:同版本只提示一次 + try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } + } catch { /* boot-time daemon may not be ready — silent */ } + } + // ── 确认对话框(替代 confirm/alert) ──── let confirmCb = null; function showConfirm(title, message, opts = {}) { @@ -730,6 +770,9 @@ const Dialogs = (() => { initGithubSection, initDeviceDialog, refreshGithubStatus, + refreshUpdateCheck, + initUpdateCheckButton, + promptUpdateAtStartup, initOpenSessionDialog, // P5:打开会话对话框初始化 showOpenSessionDialog, // P5:两步打开会话 initNewSessionDialog, // P5 slice 2:新建会话对话框初始化 diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 43c57a7..c90905b 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -92,6 +92,9 @@ const I18N = (() => { "settings.githubNotConnected": "未连接", "settings.githubStatusFailed": "状态获取失败", "settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)", + "settings.checkUpdate": "检查更新", + "settings.checkingUpdate": "检查中…", + "app.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装):https://github.com/argszero/emrg/releases", "settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token", "settings.githubConnecting": "连接中…", "settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)", @@ -419,6 +422,9 @@ const I18N = (() => { "settings.githubNotConnected": "Not connected", "settings.githubStatusFailed": "Failed to load status", "settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)", + "settings.checkUpdate": "Check for updates", + "settings.checkingUpdate": "Checking…", + "app.updateAvailable": "New version v{latest} available — https://github.com/argszero/emrg/releases (no auto-install)", "settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first", "settings.githubConnecting": "Connecting…", "settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index b089a6a..3f1d54c 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -126,6 +126,7 @@ const ELEMENT_IDS = [ "tasks-dialog", "tasks-list", "tasks-close", "result-panel", "result-list", "result-toggle", "growth-card", "growth-count", "about-recent", + "about-update", "about-update-check-btn", "github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss", ]; @@ -1602,3 +1603,87 @@ test("B3: 发送消息清除该会话草稿;新会话从空草稿开始", asyn const newDraft = await vm.runInContext("App.state.drafts.get('s-new')", ctx); assert.strictEqual(newDraft, "", "new session starts with empty draft"); }); + +test("rant 09:18:启动主动更新提示——有新版本且未提示过 → 系统消息一次", async () => { + const { ctx } = makeSandbox({ + updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23" }), + updateCheckPrompted: async () => ({}), + }); + await tick(); + const r = await vm.runInContext(`(async function() { + let prompted = null; + window.emrg.updateCheckPrompted = async (p) => { prompted = p; }; + await EMRG_Dialogs.promptUpdateAtStartup(); + const texts = []; + for (let i = 0; i < $("chat-view").children.length; i++) texts.push($("chat-view").children[i].textContent); + return { n: texts.length, joined: texts.join("|"), prompted: JSON.stringify(prompted) }; + })()`, ctx); + assert.ok(r.n >= 1, "应输出一条系统消息"); + assert.ok(r.joined.includes("0.2.99"), `系统消息应含新版本号,实际 ${r.joined}`); + assert.ok(r.prompted.includes("0.2.99"), "应记录已提示版本(幂等)"); +}); + +test("rant 09:18:启动提示幂等——已提示过/未启用/无更新 → 不提示", async () => { + const { ctx } = makeSandbox({ + updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "0.2.99", current_version: "0.2.23" }), + }); + await tick(); + const r = await vm.runInContext(`(async function() { + await EMRG_Dialogs.promptUpdateAtStartup(); + return { n: $("chat-view").children.length }; + })()`, ctx); + assert.strictEqual(r.n, 0, "已提示过同版本 → 不得重复提示"); +}); + +test("rant 09:18:设置页手动检查按钮——点击强制重新检查并显示检查中", async () => { + const { ctx } = makeSandbox({ + updateCheck: async (payload) => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23", _force: payload && payload.force }), + updateCheckPrompted: async () => ({}), + }); + await tick(); + const r = await vm.runInContext(`(async function() { + const calls = []; + window.emrg.updateCheck = async (payload) => { + calls.push(payload || {}); + return { enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "" }; + }; + const btn = $("about-update-check-btn"); + btn.textContent = "检查更新"; // 模拟 index.html 静态文案(沙箱不加载 HTML) + EMRG_Dialogs.initUpdateCheckButton(); + btn.click(); + const during = { text: btn.textContent, disabled: btn.disabled }; + await new Promise((res) => setTimeout(res, 20)); + return { + calls: JSON.stringify(calls), + duringText: during.text, + afterText: btn.textContent, + afterDisabled: btn.disabled, + updateShown: !$("about-update").classList.contains("hidden"), + }; + })()`, ctx); + const calls = JSON.parse(r.calls); + assert.strictEqual(calls.length, 1, "点击按钮应触发一次 updateCheck"); + assert.strictEqual(calls[0].force, true, "手动检查必须带 force:true(跳过 TTL 缓存)"); + assert.strictEqual(r.duringText, "检查中…", "检查中按钮应显示“检查中…”"); + assert.strictEqual(r.afterText, "检查更新", "完成后按钮文案恢复"); + assert.strictEqual(r.afterDisabled, false, "完成后按钮恢复可用"); + assert.strictEqual(r.updateShown, true, "有新版本应显示 about-update 行"); +}); + +test("rant 09:18:refreshUpdateCheck 透传 force 参数", async () => { + const { ctx } = makeSandbox({ + updateCheck: async (payload) => ({ enabled: false, has_update: false, latest_version: "", prompted_version: "" }), + }); + await tick(); + const r = await vm.runInContext(`(async function() { + const calls = []; + window.emrg.updateCheck = async (payload) => { calls.push(payload || {}); return { enabled: false, has_update: false, latest_version: "", prompted_version: "" }; }; + await EMRG_Dialogs.refreshUpdateCheck({ force: true }); + await EMRG_Dialogs.refreshUpdateCheck(); + return JSON.stringify(calls); + })()`, ctx); + const calls = JSON.parse(r); + assert.strictEqual(calls.length, 2); + assert.strictEqual(calls[0].force, true, "refreshUpdateCheck({force:true}) 应透传 force"); + assert.strictEqual(calls[1].force, false, "默认 refreshUpdateCheck() 不带 force"); +}); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index b973fb2..1042727 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1416,10 +1416,22 @@ async def _process_message( # latest_version (populated at startup + every TTL) plus a # has_update flag computed against the running version. No auto # download/install; the client shows a one-time prompt. + # force:true (rant 2026-08-11T09:18:16) — GUI manual check + # button: run a fresh GitHub fetch first instead of the cache. import emrg from emrg.config import load_update_config - from emrg.update_check import is_newer, load_state, parse_version + from emrg.update_check import ( + is_newer, + load_state, + parse_version, + run_update_check_once, + ) + if msg.get("force"): + try: + await run_update_check_once() + except Exception: + logger.debug("forced update check failed", exc_info=True) current = getattr(emrg, "__version__", "0") state = load_state() latest = state.get("latest_version") or "" diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 592b99f..99a2efd 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1328,3 +1328,43 @@ def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch) assert new_proj["latest_session_at"] == "2026-08-10T12:00:00" none_proj = next(p for p in reply["projects"] if p["name"] == "nosess") assert none_proj["latest_session_at"] == "" + + +def test_update_check_force_runs_fresh_check(monkeypatch): + """update_check with force:true runs a fresh fetch; without force it + returns the cached state (rant 2026-08-11T09:18:16 manual check button).""" + import asyncio + + server = _make_server() + writer = _FakeWriter() + calls = [] + + async def fake_run_once(state=None): + calls.append(state) + return {"checked": True, "latest_version": "9.9.9", "state": {}} + + monkeypatch.setattr("emrg.update_check.run_update_check_once", fake_run_once) + monkeypatch.setattr( + "emrg.update_check.load_state", + lambda: {"latest_version": "9.9.9", "prompted_version": ""}, + ) + monkeypatch.setattr( + "emrg.update_check.is_newer", + lambda latest, current: latest != current, + ) + + # force:true → fresh check runs, reply carries the refreshed latest + asyncio.run(server._process_message({"type": "update_check", "force": True}, writer)) + assert len(calls) == 1, "force:true must trigger one fresh check" + reply = json.loads(writer._frames[-1]) + assert reply["type"] == "update_check" + assert reply["latest_version"] == "9.9.9" + assert reply["has_update"] is True + + # no force → cached path, no fresh check + writer._frames.clear() + calls.clear() + asyncio.run(server._process_message({"type": "update_check"}, writer)) + assert calls == [], "no force → must return cache without a fresh fetch" + reply = json.loads(writer._frames[-1]) + assert reply["type"] == "update_check"