mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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
165 lines
6.3 KiB
JavaScript
165 lines
6.3 KiB
JavaScript
// 协议对齐判据:在真 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);
|