Files
HomeAgent/internal/plugins/webui/dashboard.html
JianFeeeee 7212ab4aad fix(webui): 星图依赖本地化 —— 不再从公网 CDN 拉 three.js
用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。

服务端 curl 那两个 URL 都是 **200** ⇒ 不是服务端的问题,是**浏览器**
访问不到公网 CDN(内网 / 出口受限 / 断网)。dashboard.html 从 cdnjs 与
jsdelivr 拉 4 个库:three.js、OrbitControls、marked、DOMPurify。

换 CDN 只是把同一个赌注重下遍。HomeAgent 明确支持离线与内网部署,
前端却有 4 个硬依赖在公网上 ⇒ 断网即坏,且用户无从修复。

顺带两个收益:
- **安全**:DOMPurify 是净化 Markdown 的关键一环,它挂掉前端会退化到
  「不净化」分支(dashboard.js 里有 typeof 检查)—— 那是安全降级,
  比星图坏更值得修。
- **体积**:四个库共 ~700KB,embed 后由本服务同源提供,省掉 4 个跨域握手,
  也不再受第三方可用性影响。二进制 84MB → 84.7MB(+0.8%)。

- `internal/plugins/webui/static/` 放四个库(固定版本,随二进制走)
- `//go:embed static` + 新增 `/static/` 路由
- dashboard.html 的四个 src 改指 `/static/...`
- 加载失败兜底 8s → 3s(本地是毫秒级;仍超时就说明真有问题)

`/static/` **不走 requireWeb**:未登录时页面也要加载这些库才能渲染登录框,
加认证会让用户看到白屏(比 401 更难自查)。这些资源不含用户数据。

- TestDashboardHasNoExternalCDN:页面不得引用任何公网 CDN
- TestStarmapDependenciesAreLocal:星图两个库必须来自本地
- TestVendorFilesExistInSourceTree:vendor 文件必须在(embed 的前提)
- TestStaticVendorRoutesServeRealLibraries:路由**必须真的返回库内容**

★ 最后一条的判据强度是补出来的:初版只判状态码,变异「返回 200 + 空体」
时**仍然绿** —— 而空体在浏览器里的症状与 404 完全一样(都报加载失败)。
改为同时判体积与特征串(REVISION / OrbitControls / marked / DOMPurify)
后,变异「只写前 10 字节」判红。

变异验证 3 条:HTML 改回 CDN → 2 条判红;路由挂回 requireWeb → 503 判红;
截断响应体 → 体积判红。

全量 38 包全绿。
2026-09-26 14:41:32 +08:00

191 lines
6.7 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 Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<script
src="/static/three.min.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="/static/OrbitControls.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="/static/marked.min.js"
onerror="console.warn('marked CDN failed')"
></script>
<script
src="/static/purify.min.js"
onerror="console.warn('DOMPurify CDN failed')"
></script>
<script>
// 兜底:本地库是毫秒级加载,3 秒足够;仍超时说明真的出了问题
// (比如二进制没带上 static/),标记失败好让页面给出明确提示
// 而不是一直转圈。
setTimeout(function () {
if (!window.THREE) window._THREE_FAILED = true;
}, 3000);
</script>
<style>
{{DASHBOARD_CSS}}
</style>
</head>
<body>
<div id="bg-layer"></div>
<div class="palette-pop" id="palette-pop"></div>
<nav id="sidebar">
<h1>
<img
src="/logo.svg"
alt="HomeAgent"
style="
height: 22px;
width: 22px;
vertical-align: middle;
margin-right: 6px;
flex-shrink: 0;
border-radius: 6px;
"
/>HomeAgent
</h1>
<div class="nav-links">
<!--
导航项:图标 + 标签。
为什么把 data-i18n 挪到内层 span:切语言时那段代码是 `el.textContent = …`,
挂在 <a> 上会把图标一起抹掉 —— 只有把标签单独包起来才两全。
图标一律 24x24 stroke(currentColor),跟随 active/hover 变色,不额外加一套资源。
-->
<a class="active" onclick="switchTab('overview')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M3 3h7v7H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 14h7v7H3z"/></svg>
<span data-i18n="navOverview">概览</span>
</a>
<a onclick="switchTab('chat')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.4 9.4 0 0 1-3.3-.6L3 21l1.7-5A8.4 8.4 0 0 1 4 12.5 8.4 8.4 0 0 1 12.5 4h.5a8.4 8.4 0 0 1 8 7.5z"/></svg>
<span data-i18n="navChat">对话</span>
</a>
<a onclick="switchTab('plugins')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5 9-5V8"/></svg>
<span data-i18n="navPlugins">插件</span>
</a>
<a onclick="switchTab('settings')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>
<span data-i18n="navSettings">设置</span>
</a>
<a onclick="switchTab('adapters')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>
<span data-i18n="navAdapters">适配器</span>
</a>
<a onclick="switchTab('kernel')">
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM9.5 9.5h5v5h-5zM9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/></svg>
<span data-i18n="navKernel">内核</span>
</a>
</div>
<div class="nav-footer">
<div style="display: flex; align-items: center; gap: 4px">
<button
class="theme-btn"
onclick="toggleTheme()"
id="theme-btn"
title="切换亮色/暗色模式"
>
<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>
<button
class="theme-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>
<button
class="btn btn-ghost btn-sm"
onclick="toggleLang()"
id="lang-btn"
title="切换语言/Language"
style="font-size: 13px; min-width: 28px"
>
EN
</button>
<button
class="btn btn-ghost btn-sm"
onclick="logout()"
data-i18n="navLogout"
>
退出登录
</button>
</div>
</div>
</nav>
<div class="main-wrap">
<header class="topbar">
<button
class="sidebar-toggle"
onclick="toggleSidebar()"
id="sidebar-toggle"
title="切换侧边栏"
aria-label="切换侧边栏"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="breadcrumb" id="breadcrumb">
<span class="crumb-home">HomeAgent</span>
<span class="crumb-sep">/</span>
<span class="crumb-current" data-i18n="navOverview">概览</span>
</div>
</header>
<div class="container" id="app">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-chat" class="tab-content"></div>
<div id="tab-plugins" class="tab-content"></div>
<div id="tab-settings" class="tab-content"></div>
<div id="tab-adapters" class="tab-content"></div>
<div id="tab-kernel" class="tab-content"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
{{DASHBOARD_JS}}
</script>
</body>
</html>