feat(gui): 协议对齐 —— 补齐 6 个只读诊断端点 + 人设/反代面板

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
This commit is contained in:
JianFeeeee
2026-09-28 10:45:54 +08:00
parent e94cf880ac
commit a9cbdacc13
4 changed files with 385 additions and 19 deletions

View File

@ -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"

View File

@ -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);

View File

@ -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() {
'<div class="rt-section-title">' + __("阶段管道", "Stage pipeline") +
(g < 0 ? " " + __("(空闲)", "(idle)") : "") + "</div>";
html += '<div class="rt-pipe-row' + (g < 0 ? " rt-pipe-idle" : "") + '">';
html += RT_PIPE_GROUPS.map(function (s, i) {
var items = (state.stageTrail || []).filter(function (t) {
return (t.g | 0) === i;
});
html += RT_PIPE_GROUPS.map((s, i) => {
var items = (state.stageTrail || []).filter((t) => (t.g | 0) === i);
// 「工具」是循环格:一轮里可能调几十次工具/输出通道,全部追加会把这一格
// 撑成长条,反而看不出「现在在调什么」。只留**最新一条**,右侧给本轮累计
// 次数(与 WebUI 同一口径,见 internal/plugins/webui/dashboard.js)。
@ -1213,7 +1254,7 @@ function renderRuntimePanel() {
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
} else {
body = items
.map(function (t) {
.map((t) => {
var kind = t.kind || "stage";
var ico =
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
@ -1244,7 +1285,7 @@ function renderRuntimePanel() {
// ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)----
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
html += '<div class="rt-queues">';
RT_LEVELS.forEach(function (L) {
RT_LEVELS.forEach((L) => {
var depth = q[L.lv] || 0;
var reg = byLv[L.lv] || 0;
var pre = preLv[L.lv] || 0;
@ -1323,7 +1364,7 @@ function renderOverview() {
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
statCard(
__("版本", "Version"),
(function () {
(() => {
// 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
// 拿不到数据时会向用户展示一个不存在的版本号。
@ -1416,10 +1457,148 @@ function renderOverview() {
) +
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
"</div></div>";
html += renderDiagPanel();
html += renderLegalCard();
document.getElementById("view-overview").innerHTML = html;
}
// renderDiagPanel 协议对齐补齐的诊断卡片(agents / network / tracker / config)。
//
// 全部走 `(x && x.y)` 的安全取值:任一端点没取到(老内核无该路由、
// 连接断开)都只显示 "-",不抛错 —— 与既有卡片一致。
function renderDiagPanel() {
var a = state.agents || {};
var list = a.agents || [];
var main0 = list[0] || {};
var nw = state.network || {};
var tk = state.tracker || {};
var cfg = state.runConfig || {};
var llmOk = main0.network && main0.network.llm_api_reachable;
return (
'<div class="card"><h2>' +
__("诊断", "Diagnostics") +
'</h2><div class="grid-4">' +
statCard(
__("Agent 健康", "Agent health"),
main0.health === 1
? __("正常", "healthy")
: main0.health === 0
? "-"
: __("异常", "unhealthy"),
main0.health === 1 ? "running" : "",
) +
statCard(
__("LLM 可达", "LLM reachable"),
llmOk === true
? __("是", "yes")
: llmOk === false
? __("否", "no")
: "-",
llmOk === false ? "denied" : "",
) +
statCard(
__("网络端点", "Endpoints"),
Array.isArray(nw.endpoints) ? String(nw.endpoints.length) : "-",
"",
) +
statCard(
__("文件变更集", "Changesets"),
tk.changesets !== undefined ? String(tk.changesets) : "-",
tk.has_changes ? "warn" : "",
) +
"</div>" +
'<div class="grid-2" style="margin-top:10px">' +
'<div><b>' +
__("数据目录", "Data dir") +
"</b>: " +
escHtml(cfg?.daemon?.data_dir || "-") +
"</div>" +
'<div><b>' +
__("心跳间隔", "Heartbeat") +
"</b>: " +
(cfg?.daemon?.heartbeat_interval
? Math.round(cfg.daemon.heartbeat_interval / 1e6) + " ms"
: "-") +
"</div>" +
"</div></div>"
);
}
// renderPersona 人设面板:展示 /persona 返回的人格设定。
//
// 只读展示。当前先不做编辑 —— 编辑要处理保存、失败回滚、并发覆盖,
// 与"协议对齐"是两件事,混在一起容易做半。
function renderPersona() {
var el = document.getElementById("view-persona");
if (!el) return;
var p = state.persona || {};
var prompt = p.current_prompt || "";
// ★ 实测结构是 {current_prompt, file_override, initialized},
// 不是键值对 —— 我第一版按 map 遍历,结果只会显示三个字段名。
el.innerHTML =
'<div class="card"><h2>' +
__("人设", "Persona") +
'</h2><div class="grid-3">' +
statCard(
__("已初始化", "Initialized"),
p.initialized ? __("是", "yes") : __("否", "no"),
p.initialized ? "running" : "",
) +
statCard(
__("文件覆盖", "File override"),
p.file_override ? __("开", "on") : __("关", "off"),
"",
) +
statCard(__("提示词长度", "Prompt length"), String(prompt.length), "") +
"</div>" +
'<h2 style="margin-top:12px">' +
__("当前提示词", "Current prompt") +
'</h2><pre style="white-space:pre-wrap;word-break:break-word">' +
escHtml(prompt || __("(空)", "(empty)")) +
"</pre></div>";
}
// renderProxy 反代面板:展示 /proxy 的挂载与模式。
function renderProxy() {
var el = document.getElementById("view-proxy");
if (!el) return;
var px = state.proxy || {};
var svc = state.proxyServices || null;
var head =
'<div class="card"><h2>' +
__("反代", "Reverse Proxy") +
'</h2><div class="grid-4">' +
statCard(__("基础域名", "Base domain"), px.base_domain || "-", "") +
statCard(__("模式", "Mode"), px.mode || "-", "") +
statCard(__("总数", "Total"), px.total !== undefined ? String(px.total) : "-", "") +
statCard(__("手动", "Manual"), px.manual !== undefined ? String(px.manual) : "-", "") +
"</div>";
var body = "";
var list = svc && Array.isArray(svc.services) ? svc.services : null; // 实测:services 是数组
if (list) {
body =
'<div class="card"><h2>' +
__("服务", "Services") +
"</h2><pre style=\"white-space:pre-wrap;word-break:break-word\">" +
escHtml(
list
.map((s) => {
// ★ 实测字段:{name, host, path, url, target, ok, auth, websocket}
return (
(s.ok === false ? "✗ " : "✓ ") +
(s.name || "?") +
" → " +
(s.target || s.url || "?")
);
})
.join("\n"),
) +
"</pre></div>";
}
el.innerHTML = head + body;
}
// ===== Chat =====
var _chatLayoutBuilt = false;
@ -2014,9 +2193,7 @@ function applyIncrementalChatRender(msgsEl, html) {
while ((m = re.exec(html)) !== null) want.push(m[1]);
var kids = Array.prototype.slice.call(msgsEl.children);
var have = kids.map(function (n) {
return n.getAttribute("data-msgkey") || "";
});
var have = kids.map((n) => n.getAttribute("data-msgkey") || "");
// 没带 key(老结构 / 空列表)⇒ 只能整棵重建
if (want.length === 0 || have.length === 0) {
@ -2866,7 +3043,7 @@ async function loadOlderChat() {
// 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底(跨渠道消息补偿)。
function syncChatFromHistory() {
return api("/chat/history?limit=" + CHAT_PAGE_SIZE)
.then(function (data) {
.then((data) => {
if (!data || !data.messages || data.messages.length === 0) return;
var serverMsgs = data.messages;
var localMsgs = state.messages;
@ -2915,7 +3092,7 @@ function syncChatFromHistory() {
rerenderChatIfActive();
}
})
.catch(function () {});
.catch(() => {});
}
async function loadTerminals() {
@ -5692,7 +5869,7 @@ async function connectFetchSSE(url) {
state.pipelinePhase = phase;
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
state.pipelineTimer = setTimeout(function () {
state.pipelineTimer = setTimeout(() => {
state.pipelinePhase = "";
if (state.currentView === "overview") renderOverview();
}, 2500);
@ -5722,13 +5899,13 @@ async function connectFetchSSE(url) {
}
}
// 断连后先增量同步历史(补偿断连窗口期丢失的事件),再重连
syncChatFromHistory().catch(function () {});
syncChatFromHistory().catch(() => {});
// ★ 此处也清零:本次连接曾成功建立(上面已清),断流是运行期事件,
// 不该把「建连失败」的累计次数带进下一次退避。
state._sseRetryAttempts = 0;
reconnectTimer = setTimeout(() => {
connectSSE();
}, Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 32000));
}, Math.min(1000 * 2 ** Math.min((state._sseRetryAttempts || 0), 5), 32000));
}
pump();
} catch (e) {
@ -5736,7 +5913,7 @@ async function connectFetchSSE(url) {
state._sseRetryAttempts = attempts;
setTimeout(() => {
connectSSE();
}, Math.min(1000 * Math.pow(2, Math.min(attempts - 1, 5)), 60000));
}, Math.min(1000 * 2 ** Math.min(attempts - 1, 5), 60000));
}
}

View File

@ -208,6 +208,29 @@
</svg>
</button>
<div class="rail-spacer"></div>
<button
class="rail-btn"
id="rail-persona"
onclick="switchView('persona')"
title="人设"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" />
</svg>
</button>
<button
class="rail-btn"
id="rail-proxy"
onclick="switchView('proxy')"
title="反代"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<path d="M4 8h16M4 16h16" />
<circle cx="8" cy="8" r="2" />
<circle cx="16" cy="16" r="2" />
</svg>
</button>
<button
class="rail-btn"
id="rail-settings"
@ -307,6 +330,8 @@
<div id="view-kernel" class="view"></div>
<div id="view-devices" class="view"></div>
<div id="view-settings" class="view"></div>
<div id="view-persona" class="view"></div>
<div id="view-proxy" class="view"></div>
</div>
</div>
</div>