From 7151f1db851a896b9229fbcc7e0360a447c38ebc Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:45:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui):=20=E5=8D=8F=E8=AE=AE=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20=E2=80=94=E2=80=94=20=E8=A1=A5=E9=BD=90=206=20?= =?UTF-8?q?=E4=B8=AA=E5=8F=AA=E8=AF=BB=E8=AF=8A=E6=96=AD=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=20+=20=E4=BA=BA=E8=AE=BE/=E5=8F=8D=E4=BB=A3=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI 原来只用 22 个端点,服务端有 47 个。补齐**只读诊断类**: agents / network / tracker / config / persona / proxy(+services)。 ## 刻意不接 /login 与 /logout 那是 cookie 会话认证流程,而 GUI 走 `X-API-Key` 头(见 `api()`)。 接了不是"对齐",是接错。 ## 三处新增 **总览诊断卡片**(renderDiagPanel):Agent 健康、LLM 可达、网络端点数、 文件变更集、数据目录、心跳间隔。全部走 `(x && x.y)` 安全取值 —— 任一端点没取到(老内核无该路由、连接断开)只显示 "-",不抛错。 **人设面板**(renderPersona):实测结构是 `{current_prompt, file_override, initialized}`。我第一版按 map 遍历, 结果只会显示三个字段名 —— 真机验证时才发现。改成展示提示词全文 + 两个状态卡。**只读**,编辑涉及保存/回滚/并发覆盖,与"协议对齐"是两件事。 **反代面板**(renderProxy):base_domain / mode / total / manual + 服务列表(`✓ gateway → 127.0.0.1:9890`)。实测字段是 `{name, host, path, url, target, ok, auth, websocket}`,不是我第一版假设的 `subdomain`。 ## 加载策略 只加进 `refreshAll`,**不加** `refreshDataOnly`(后者每 15 秒一轮, 诊断数据不必高频轮询)。7 个新端点实测只 +3ms。 ## 判据 protocol-align.test.mjs(10 条,真 Electron) - 7 个 state 槽都取到真实数据 - 总览出现诊断卡片 - 人设面板**真的显示提示词内容**(不只查元素存在) - 反代面板**真的列出服务**(查 `→` 出现) ### 判据踩的三个坑 1. **默认假 key 导致 9 项全红** —— webui 对错误凭据返回 200 + 登录页 HTML (`looks_like_login_page` 能识别),于是所有取数失败。看起来像 「代码坏了」,实际只是认证缺失。改为默认从 `config.db` 读真 key。 2. **判据绕过了应用路径** —— 表达式里直接调 `refreshDiagData()`, 于是把应用里的 `await refreshDiagData()` 注释掉,判据**仍全绿**。 改为走应用自己的 `refreshAll()`。 3. **变异后 GUI 仍加载旧代码** —— `ensure()` 看到端口有页面就复用, 注入变异后没重启 GUI ⇒ 又一次假绿。**变异测试必须先杀掉 GUI 进程。** 变异测试(注释掉调用点)⇒ 9 项变红,坐实判据验的是真路径。 ## 门禁 - `npm run test-live`:22/22 通过(12 性能+滚动 + 10 协议对齐) - `npm test`、`make test-gui`:全通过 - `go test ./...`:43 包 ok、0 FAIL --- cmd/gui/package.json | 2 +- cmd/gui/protocol-align.test.mjs | 164 ++++++++++++++++++++++++ cmd/gui/renderer/app.js | 213 +++++++++++++++++++++++++++++--- cmd/gui/renderer/index.html | 25 ++++ 4 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 cmd/gui/protocol-align.test.mjs diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 29f337a0..9399facf 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -9,7 +9,7 @@ "start": "electron . --no-sandbox", "dev": "electron . --no-sandbox --dev", "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs", - "test-live": "node chat-perf.test.mjs" + "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs" }, "dependencies": { "koffi": "^3.1.6" diff --git a/cmd/gui/protocol-align.test.mjs b/cmd/gui/protocol-align.test.mjs new file mode 100644 index 00000000..909b64b4 --- /dev/null +++ b/cmd/gui/protocol-align.test.mjs @@ -0,0 +1,164 @@ +// 协议对齐判据:在真 Electron 里验证补齐的端点真的取到数据、 +// 且三个面板渲染出**可见内容**(不是空壳)。 +// +// ## 背景 +// +// 2026-09-28 用户要求对齐 WebAPI 协议。核实发现 GUI 只用 22 个端点, +// 服务端有 47 个。补齐的是**只读诊断类**:agents / network / tracker / +// config / persona / proxy(+services)。 +// +// ★ 刻意**不接** /login 与 /logout:那是 cookie 会话认证流程, +// 而 GUI 走 `X-API-Key` 头(见 api())。接了反而是错的对齐。 +// +// ★ 只加进 refreshAll、**不加** refreshDataOnly:后者每 15 秒一轮, +// 诊断数据不必高频轮询。 +// +// ## 为什么必须真浏览器 +// +// 判据要验的是「面板里真的有内容」,依赖 DOM 渲染 —— node 里测不了。 +// +// 运行:node cmd/gui/protocol-align.test.mjs + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.GUI_CDP_PORT || 9370); + +// apiKey 从哪来:环境变量优先,否则读生产 config.db。 +// +// ★ 默认**不能**用假 key:webui 对错误凭据返回 200 + 登录页 HTML +// (looks_like_login_page 会识别),于是所有取数都失败 ⇒ 判据全红, +// 看起来像「代码坏了」,实际只是认证缺失。踩过一次。 +function resolveApiKey() { + if (process.env.GUI_API_KEY) return process.env.GUI_API_KEY; + const db = "/home/newqqagent/config.db"; + if (!existsSync(db)) return ""; + try { + const out = spawnSync("sqlite3", [db, + "select value from config_webui where key='api_key';"], + { encoding: "utf8" }); + return (out.stdout || "").trim(); + } catch { + return ""; + } +} + +const API_KEY = resolveApiKey(); +if (!API_KEY) { + console.log(" ✗ 拿不到 webui api_key:设 GUI_API_KEY 或确认 /home/newqqagent/config.db 可读"); + process.exit(1); +} + +let failures = 0; +const check = (n, ok, d) => { + if (ok) console.log(` ✓ ${n}`); + else { + failures++; + console.log(` ✗ ${n}${d ? " — " + d : ""}`); + } +}; + +async function targets() { + try { + return await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + } catch { + return []; + } +} + +async function ensure() { + const t = await targets(); + if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return; + const bin = join(here, "node_modules/.bin/electron"); + if (!existsSync(bin)) throw new Error("electron 未安装,先 cd cmd/gui && npm i"); + // ★ setsid:detached 只脱离进程组,脚本退出仍会带走刚起的 GUI + // (症状:[tray] READY 打了,判据却报「找不到 GUI 页面」)。 + spawn( + "setsid", + ["xvfb-run", "-a", "--server-args=-screen 0 1280x900x24", bin, "--no-sandbox", + `--remote-debugging-port=${PORT}`, "."], + { cwd: here, detached: true, stdio: "ignore" }, + ).unref(); + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const t2 = await targets(); + if (t2.some((x) => x.type === "page" && x.url.includes("index.html"))) return; + } + throw new Error(`GUI 启动超时(${PORT})`); +} + +async function ev(expr) { + const tabs = await targets(); + const page = tabs.find((t) => t.type === "page" && t.url.includes("index.html")); + if (!page) throw new Error("找不到 GUI 页面"); + const sock = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((r) => (sock.onopen = r)); + const id = Math.floor(Math.random() * 1e6); + sock.send(JSON.stringify({ + id, method: "Runtime.evaluate", + params: { expression: expr, awaitPromise: true, returnByValue: true }, + })); + // ★ JSON.parse 必须包 try:CDP 也会发非 JSON 帧,裸 parse 抛在回调里 + // 既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。 + const res = await new Promise((r) => { + sock.onmessage = (e) => { + let m; + try { + m = JSON.parse(e.data); + } catch { + return; + } + if (m && m.id === id) r(m); + }; + }); + sock.close(); + const v = res.result?.result?.value; + if (v === undefined) throw new Error(res.result?.exceptionDetails?.text || "求值失败"); + return v; +} + +await ensure(); + +const r = await ev(`(async () => { + const K = ${JSON.stringify(API_KEY)}; + state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}]; + state.currentConn = state.connections[0]; + // ★ 走应用**自己的**入口 refreshAll(),不直接调 refreshDiagData()。 + // 早先直接调 refreshDiagData ⇒ 判据绕过了应用里的调用点 ⇒ + // 把「await refreshDiagData()」注释掉,判据仍全绿(实测踩过)。 + // 变异测试的意义就在于抓这种「判据没测到真路径」。 + await refreshAll(); + renderOverview(); renderPersona(); renderProxy(); + const txt = (id) => { const e = document.getElementById(id); return e ? e.innerText : ''; }; + return { + slots: { + agents: !!(state.agents && Array.isArray(state.agents.agents)), + network: !!(state.network && Array.isArray(state.network.endpoints)), + tracker: !!(state.tracker && typeof state.tracker.changesets === 'number'), + runConfig: !!(state.runConfig && !!state.runConfig.daemon), + persona: !!(state.persona && state.persona.current_prompt), + proxy: !!(state.proxy && typeof state.proxy.total === 'number'), + proxyServices: !!(state.proxyServices && Array.isArray(state.proxyServices.services)), + }, + overviewHasDiag: txt('view-overview').includes('诊断') || txt('view-overview').includes('Diagnostic'), + personaText: txt('view-persona').slice(0, 120), + proxyText: txt('view-proxy').replace(/\\n+/g, ' | ').slice(0, 200), + }; +})()`); + +for (const [k, v] of Object.entries(r.slots)) { + check(`端点取到数据:${k}`, v, "state 槽为空 ⇒ 该端点没接上或没取到"); +} +check("总览出现诊断卡片", r.overviewHasDiag); +check( + "人设面板显示提示词", + r.personaText.includes("你是") || r.personaText.length > 40, + `实际文本:${r.personaText.slice(0, 60)}`, +); +check("反代面板列出服务", r.proxyText.includes("→"), `实际文本:${r.proxyText.slice(0, 80)}`); + +console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`); +process.exit(failures === 0 ? 0 : 1); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 57935c76..cee74392 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -968,10 +968,53 @@ async function refreshAll() { } catch (e) { console.error("renderDevices", e); } + // ★ 协议对齐补齐的 6 个端点(2026-09-28)。 + // + // GUI 原来只用 22 个端点,服务端有 47 个。缺的这些**都是只读诊断类**, + // 接上它们的价值是"不用切到别的工具就能看到"。 + // + // ★ 只加进 refreshAll,**不加** refreshDataOnly:后者每 15 秒一轮, + // 6 个端点虽只 +3ms(实测),但没必要为诊断数据高频轮询。 + await refreshDiagData(); + try { + renderOverview(); + renderPersona(); + renderProxy(); + } catch (e) { + console.error("render diag panels", e); + } applyI18n(); applyCardTilt(); } +// refreshDiagData 拉取协议对齐补齐的诊断类端点。 +// +// 刻意每个都独立 try/catch:某个端点挂了(如老版本内核没有该路由), +// 不该拖垮整轮刷新 —— 与既有 6 个 loader 的写法一致。 +async function refreshDiagData() { + try { + state.agents = await api("/agents"); + } catch (e) {} + try { + state.network = await api("/network"); + } catch (e) {} + try { + state.tracker = await api("/tracker"); + } catch (e) {} + try { + state.runConfig = await api("/config"); + } catch (e) {} + try { + state.persona = await api("/persona"); + } catch (e) {} + try { + state.proxy = await api("/proxy"); + } catch (e) {} + try { + state.proxyServices = await api("/proxy/services"); + } catch (e) {} +} + // ===== 动效补齐:卡片 3D tilt + 光标光斑(事件委托,动态渲染后自动生效) ===== function applyCardTilt() { if (!window.matchMedia || window.matchMedia("(hover: none)").matches) return; @@ -1046,9 +1089,9 @@ function startUptimeTicker() { // 丢失的事件(尤其是非 GUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 // syncChatFromHistory 增量同步,不重建已有消息 DOM,无闪烁。 if (_chatSyncTick) clearInterval(_chatSyncTick); - _chatSyncTick = setInterval(function () { + _chatSyncTick = setInterval(() => { if (state.currentConn && state.currentConn.type !== "cli") { - syncChatFromHistory().catch(function () {}); + syncChatFromHistory().catch(() => {}); } }, 30000); } @@ -1185,10 +1228,8 @@ function renderRuntimePanel() { '
' +
+ escHtml(prompt || __("(空)", "(empty)")) +
+ "" +
+ escHtml(
+ list
+ .map((s) => {
+ // ★ 实测字段:{name, host, path, url, target, ok, auth, websocket}
+ return (
+ (s.ok === false ? "✗ " : "✓ ") +
+ (s.name || "?") +
+ " → " +
+ (s.target || s.url || "?")
+ );
+ })
+ .join("\n"),
+ ) +
+ "