Files
HomeAgent/cmd/gui/renderer/index.html
JianFeeeee 53d9af446c feat(gui): 星图接上 /memory/graph/pulse + 前端依赖本地化 + 端点对齐门禁
GUI 与服务端 WebUI 插件共享同一套 REST 接口,但两边独立演进、没有任何
强制手段。这次核实发现三处真实漂移(服务端 42 路由 / GUI 只用 29)。

1) 星图接上服务端专为它造的轻量活动端点
   服务端 handleMemoryGraphPulse 的注释写了动机:生产实例全量图谱
   408KB / 1151 节点,为了「知道哪些节点是新的」而每 N 秒拉全量是把
   带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+
   mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级。
   WebUI dashboard 接了它,GUI 此前只在初始化拉一次全量且完全不轮询
   ⇒ 星图停在打开那一刻的快照,agent 后来学的东西它永远看不到。
   现接入:/runtime 3s + /memory/graph/pulse 10s,两路轮询幂等、
   切连接时停;新实体标「生长」;SSE 的 tool_call/stage/agent_output
   也会触发脉冲(用工具名与回复前 60 字当 hint,让点亮落到本轮相关实体)。
   pulse 检出「全量图里没有的新实体」时置脏,由下一次 renderChatStarmap
   惰性重拉全量 —— 不在脉冲回调里直接拉,否则会把轻量活动源变成每 10s
   拉一次 408KB 全量,正好是 pulse 端点要消除的浪费。

2) 前端依赖本地化,顺带修掉一处安全边界
   四个库从 internal/plugins/webui/static/ 复制到 renderer/vendor/
   (字节一致,由门禁钉住),index.html 不再引用任何公网 CDN。
   与服务端同一理由(见 webui/starmap_vendor_test.go 的注释):
   HomeAgent 支持离线/内网部署,换 CDN 只是把同一个赌注重下一遍。
   ★ renderMd() 的净化器缺失分支不再回退到手写正则(剥 <script>/on*=/
   javascript:)—— 那不是完备的 HTML sanitizer,漏 <iframe srcdoc>、
   SVG 内联事件、data: URI 等,而它渲染的是模型输出与记忆文本,
   都算不可信输入。现在直接退纯文本:库已本地化,走不到该分支。

3) 新增 endpoint-align.test.mjs(进 npm test / CI)
   扫描 app.js 的 api("...") 与 handler.go 的 mux.HandleFunc 对账,
   7 条判据。与既有判据同一路子:读真实源码,不抄逻辑重写。
   反向差集只报告不门禁(管理面端点接不接是产品决策),
   只硬钉「服务端已明确为其设计」的 pulse —— 即本判据的由来。
   已做变异测试:改错 pulse 路径、改回 CDN 均能判红。
   protocol-align.test.mjs 是真浏览器判据(需 Electron+Xvfb+真后端),
   CI 明确不跑,所以这类漂移此前无人发现。

顺带:connectSSE 由隐式全局赋值改为 function 声明(加 "use strict"
会在文件靠后处 ReferenceError,报错点离调用点很远)。

判据:cmd/gui 下 npm test 全绿(4 个 .mjs / 25 条)。
2026-10-01 18:23:20 +08:00

360 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeAgent</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
<!--
第三方库全部本地化(renderer/vendor/),不引用任何公网 CDN。
为什么(与服务端 webui 插件同一条理由,见
internal/plugins/webui/starmap_vendor_test.go 的注释):
HomeAgent 明确支持离线/内网部署,而前端原本有 4 个硬依赖在公网上
(cdnjs/jsdelivr)。真实现象是「服务端 curl 同一批 URL 返回 200,
但用户点星图看到『3D 星图不可用(CDN 加载失败)』」——不是服务端的问题,
是客户端访问不到公共 CDN(内网/出口受限/断网)。
换 CDN 只是把同一个赌注重下一遍。
★ 版本与 internal/plugins/webui/static/ 下那四个文件一致(同样是
three r128 / marked 4.3.0 / DOMPurify 3.2.4),两端保持一致,
由 vendor-align.test.mjs 钉住。
★ DOMPurify 从 CDN 挪到本地还修掉了一个**安全边界**:
它加载失败时 renderMd() 会回退到手写正则剥 <script>/on*=/javascript:,
那不是完备的 HTML sanitizer,而它渲染的是**模型输出与记忆文本**
(都算不可信输入)。净化器必须可用,不能有降级路径。
-->
<script src="vendor/three.min.js"></script>
<script src="vendor/OrbitControls.js"></script>
<script src="vendor/marked.min.js"></script>
<script src="vendor/purify.min.js"></script>
<script>
// 本地依赖缺失时的可见报错:宁可显式说「本地库缺失」,
// 也不要静默退化(静默退化的症状是星图空白/markdown 变纯文本,
// 用户看不出是打包漏了文件)。
if (!window.THREE) window._THREE_FAILED = true;
if (typeof marked === "undefined")
console.error("[vendor] marked.min.js 缺失:Markdown 将退化为纯文本");
if (typeof DOMPurify === "undefined")
console.error(
"[vendor] purify.min.js 缺失:Markdown 净化不可用(不应发生,库已本地化)",
);
</script>
</head>
<body>
<div id="bg-layer"></div>
<div class="titlebar" id="titlebar">
<div class="titlebar-controls">
<button
class="tb-btn"
id="tb-min"
title="最小化"
onclick="window.homeagent && homeagent.win.minimize()"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<line x1="4" y1="12" x2="20" y2="12" />
</svg>
</button>
<button
class="tb-btn"
id="tb-max"
title="最大化"
onclick="window.homeagent && homeagent.win.toggleMaximize()"
>
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<rect x="5" y="5" width="14" height="14" rx="1.5" />
</svg>
</button>
<button
class="tb-btn tb-close"
id="tb-close"
title="关闭"
onclick="window.homeagent && homeagent.win.close()"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</div>
<div id="app">
<!-- 左侧图标栏:主页=对话,其余为二级页面 -->
<aside class="rail" id="rail">
<div class="rail-logo"><img src="icon.svg" alt="HomeAgent" /></div>
<button
class="rail-btn active"
id="rail-chat"
onclick="switchView('chat')"
title="对话"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-overview"
onclick="switchView('overview')"
title="概览"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</button>
<button
class="rail-btn"
id="rail-plugins"
onclick="switchView('plugins')"
title="插件"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</button>
<button
class="rail-btn"
id="rail-adapters"
onclick="switchView('adapters')"
title="适配器"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-kernel"
onclick="switchView('kernel')"
title="内核"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path
d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4"
/>
</svg>
</button>
<button
class="rail-btn"
id="rail-devices"
onclick="switchView('devices')"
title="设备"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7" cy="7" r="3" />
<circle cx="17" cy="17" r="3" />
<circle cx="17" cy="7" r="3" />
<path d="M7 7h7a3 3 0 0 1 3 3v4" />
</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"
onclick="switchView('settings')"
title="设置"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button
class="rail-btn"
onclick="toggleTheme()"
title="切换亮色/暗色模式"
id="theme-btn"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</button>
<div class="palette-pop" id="palette-pop" title="外观"></div>
<button
class="rail-btn"
onclick="toggleAppearance()"
id="palette-btn"
title="外观:主题色 / 背景图"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-2a3 3 0 0 0-3 3c0 1.1.9 2 2 2 1.4 0 2.5-1.1 3.6-2.1C19.3 18.5 20 20 21.5 20.5A1 1 0 0 0 22 19c0-4-3.6-7-10-7-5.5 0-9 3.8-9 9z"
/>
<circle cx="7.5" cy="10.5" r="1" />
<circle cx="12" cy="7.5" r="1" />
<circle cx="16.5" cy="10.5" r="1" />
</svg>
</button>
<span
class="conn-dot dot-gray"
id="rail-conn-dot"
title="连接状态"
></span>
</aside>
<div class="main">
<div class="topbar">
<h1>HomeAgent</h1>
<span
class="conn-indicator"
id="conn-indicator"
onclick="goSettingsConn()"
title="点击管理后端连接"
>
<span class="status-dot dot-gray" id="conn-dot"></span>
<span id="conn-name-display">未连接</span>
<span style="font-size: 10px; margin-left: 4px; opacity: 0.6"
>▼</span
>
</span>
<div class="spacer"></div>
<button class="lang-btn" onclick="toggleLang()" id="lang-btn">
EN
</button>
</div>
<div class="container">
<div id="view-chat" class="view active"></div>
<div id="view-overview" class="view"></div>
<div id="view-plugins" class="view"></div>
<div id="view-adapters" class="view"></div>
<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>
<div id="toast" class="toast"></div>
<div id="confirm-overlay" class="confirm-overlay"></div>
<script src="app.js"></script>
</body>
</html>