mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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:
@ -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"
|
||||
|
||||
164
cmd/gui/protocol-align.test.mjs
Normal file
164
cmd/gui/protocol-align.test.mjs
Normal 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);
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user