mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 需求
WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。
## 设计:复用心跳,不新开通道(用户指出的方向)
guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。
WebUI 点按钮
→ POST /api/v1/kernel/{restart,shutdown}
→ sdk.KernelControlAPI(只投递,立即返回)
→ 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
→ 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
→ systemctl stop → 不再拉起
★ 关机为什么必须由 guard 做:
unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
(用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。
★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
"指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。
## 顺带修的真缺陷:心跳文件无限增长
生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。
## 安全:能力分级 + 白名单(两道)
1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
★ 判据 `TestCapability_AllMethodsClassified` 当场报出
"未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。
## 判据(7 条)
心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛
变异 6/6 咬住。全量 53 包 ok。
## UI
导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
225 lines
8.2 KiB
HTML
225 lines
8.2 KiB
HTML
<!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('starmap')">
|
||
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M12 2l2.2 6.3H21l-5.4 4 2.1 6.4L12 15.2 6.3 18.7l2.1-6.4-5.4-4h6.8z"/></svg>
|
||
<span data-i18n="navStarmap">星图</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="theme-btn"
|
||
id="kernel-restart-btn"
|
||
onclick="kernelRestart()"
|
||
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="M21 12a9 9 0 1 1-2.6-6.4" />
|
||
<path d="M21 3v6h-6" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="theme-btn"
|
||
id="kernel-shutdown-btn"
|
||
onclick="kernelShutdown()"
|
||
title="关闭内核(需人工 systemctl start 才能再启动)"
|
||
aria-label="关闭内核"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||
<path d="M12 3v9" />
|
||
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
|
||
</svg>
|
||
</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-starmap" 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>
|