feat(gui+ohos): 同步 WebUI 总览改版 —— 桌面版补运行态面板,两端补内核身份与开源许可

WebUI 那边这几轮改完,桌面 app(Electron)与鸿蒙 app(ArkTS)要跟上。
先摸了底:**两个 app 此前都没有运行态面板**(阶段管道 / 中断队列),
所以这不是"移植",是新做;emoji 图标两端本来就没有,无需处理。

## 桌面 app(cmd/gui/renderer)

1) 运行态面板(新)—— 与 WebUI 同一套设计语言:**等大表框**
   - 数据源 /api/v1/runtime(此前只拉 /status 与 /kernel)。
   - 四个数字块沿用本 app 的 statCard(排队/中断/栈/子代理)。
   - 阶段管道:5 个等大框,框内是本阶段本轮发生的事件 chip;
     当前阶段整框点亮。图标一律内联 SVG(含「工具会循环」标记)。
   - 中断队列:5 个等大框(L4/L3/L2/L1 + 排队)一行排开,
     级别名 16px/800、深度 26px/800、可见格槽(0 时也有形状);
     有积压整框描边点亮;排队队列虚线框区分(另一类别,不是另一优先级)。
   - stage SSE 事件接上轨迹(rtTrailPush,同阶段同一条累加 xN),
     2.5s 无新事件回空闲。
   - 列宽 repeat(auto-fit, minmax(100px,1fr)):窄容器也保证 5 框一行,
     不出「4 个 + 1 个」的孤行。

2) 版本身份(修一个真缺陷)
   旧实现是 `s.version || "0.1.0"`:拿不到数据时**向用户展示一个不存在的
   版本号** 0.1.0。改为取 /kernel 的 build(-ldflags 注入的真实版本/commit),
   并在版本号下补一行「内核名 · commit」。

3) 开源许可卡(新)
   协议标识 + 协议全文 + 源码仓库 + §13 说明;网络条款按标识是否含 AGPL
   决定是否渲染,不硬写协议名。

## 鸿蒙 app(cmd/ohos/HomeAgent)

4) StatusStore 解析 /kernel 的 build:补 内核版本 / Commit / SDK 兼容 /
   构建时间 到「内核」分组;K_VERSION 统一成 v<版本>,新增 K_BUILD 广播
   「内核名 · commit」给摘要卡(版本号本身没有内核身份)。

5) 新增「开源许可」分组:许可协议 / 协议全文 / 源码仓库 / 网络条款说明。

## 验证

- 桌面:共享浏览器加载 renderer(桩掉 preload 桥)后喂真实形状数据渲染 ——
  版本卡 `v1.4.0+hotfix.f89e57a` + `HomeAgent · f89e57a`;管道
  `输入=输入 | 行动=思考 | 工具=qq_get_message x3 qq* | 输出=生成 | 结束=完成`;
  队列 `L4:0 | L3:3 on=3[active] | L2:2 on=2[active] | L1:0 | 排队:2 on=2[active]`;
  许可卡两个链接均为 target=_blank + rel=noopener noreferrer;无 emoji。
  node --check 通过。
- 鸿蒙:/opt/huawei/command-line-tools/bin/hvigorw assembleHap **BUILD SUCCESSFUL**。

## 未做(下一条腿)

鸿蒙端的运行态面板(阶段管道 + 中断队列)**还没有**。它比桌面端贵:
需要新组件(5 框管道 + 5 框队列)、把 /runtime 快照接入 StatusStore,
以及把 SSE 的 stage 事件从 ChatSse 的 sink 引到状态侧——后者是接口改动。
「状态」Tab 此前已被有意删除(并入设置页 + 二级明细),面板落点也要定
(建议放二级明细页顶部)。桌面的实现可直接作参照。
This commit is contained in:
JianFeeeee
2026-09-14 18:53:25 +08:00
parent 0faf9fb4e8
commit 2163a7a092
4 changed files with 601 additions and 13 deletions

View File

@ -696,6 +696,10 @@ async function refreshDataOnly() {
try {
state.kernel = await api("/kernel");
} catch (e) {}
try {
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
state.runtime = await api("/runtime");
} catch (e) {}
try {
var s = await api("/settings");
state.settings = s.settings || {};
@ -775,6 +779,10 @@ async function refreshAll() {
try {
state.kernel = await api("/kernel");
} catch (e) {}
try {
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
state.runtime = await api("/runtime");
} catch (e) {}
try {
var s = await api("/settings");
state.settings = s.settings || {};
@ -1004,6 +1012,223 @@ function statCard(l, v) {
);
}
// ===== 运行态面板:阶段管道 + 中断队列 =====
//
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。
var RT_LEVELS = [
{ lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" },
{ lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" },
{ lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" },
{ lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" },
];
// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
var RT_PIPE_GROUPS = [
{ zh: "输入", en: "in", ico: "in" },
{ zh: "行动", en: "act", ico: "act" },
{ zh: "工具", en: "tool", ico: "tool", loop: true },
{ zh: "输出", en: "out", ico: "out" },
{ zh: "结束", en: "done", ico: "done" },
];
// 图标一律内联 SVG(24x24 / currentColor),不用 emoji/符号字符充当图标。
var RT_ICO = {
in: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M21 12H8"/><path d="M13 6l-6 6 6 6"/></svg>',
act: '<svg class="rt-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"/></svg>',
tool: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M14.5 6.5a3.8 3.8 0 0 1 5 5L10 21l-5-5z"/><path d="M14.5 6.5 17.5 9.5"/></svg>',
out: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg>',
done: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
loop: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M21 3.5V9h-5.5"/></svg>',
};
function rtPhaseGroup(phase) {
switch (phase) {
case "on_input":
return 0;
case "pre_action":
case "post_action":
return 1;
case "before_toolcall":
case "after_toolcall":
return 2;
case "before_output":
return 3;
case "after_output":
return 4;
}
return -1;
}
function rtShortTool(name) {
var n = String(name || "");
var i = n.lastIndexOf("__");
if (i >= 0) n = n.slice(i + 2);
return n.length > 14 ? n.slice(0, 13) + "…" : n;
}
// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。
// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩文字,看上去就是「这块空着」。
function rtSlots(depth, slots, cls) {
var n = Math.max(5, Math.min(16, slots || 5));
var d = depth || 0;
var out = '<span class="rt-slots ' + (cls || "") + '">';
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
// 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
return out + "</span>";
}
// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里;
// 同一阶段重复的同一条(如同一工具连调 3 次)只累加计数,不刷屏。
function rtTrailPush(g, kind, label, short) {
if (!state.stageTrail) state.stageTrail = [];
var arr = state.stageTrail;
var last = arr.length ? arr[arr.length - 1] : null;
if (last && last.g === g && last.kind === kind && last.short === short) {
last.n = (last.n || 1) + 1;
return;
}
arr.push({ g: g, kind: kind, label: label, short: short, n: 1 });
if (arr.length > 24) arr.shift();
}
function renderRuntimePanel() {
var title = __("运行态", "Runtime");
var rt = state.runtime;
if (!rt) {
return (
'<div class="card"><h2>' + title + '</h2><p class="rt-empty">' +
__("运行态数据不可用", "runtime unavailable") + "</p></div>"
);
}
var sc = rt.scheduler || {};
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
var pending = sc.pending_interrupts || 0;
var ready = sc.ready_queue_depth || 0;
var stack = sc.suspend_stack || 0;
var maxStack = sc.max_suspend_depth || 4;
var residents = rt.residents || [];
var byLv = sc.interrupts_by_level || [];
var preLv = sc.preempts_by_level || [];
var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
// 至少 5 格:0 时也有可见形状
var qSlots = Math.max(5, Math.min(16, maxQ));
var html = '<div class="card"><h2>' + title + "</h2>";
// 四个数字块(沿用本 app 的 statCard 风格)
html +=
'<div class="grid-4">' +
statCard(__("排队", "Ready"), ready, "") +
statCard(__("中断", "Pending"), pending, "") +
statCard(__("栈", "Stack"), stack + "/" + maxStack, "") +
statCard(__("子代理", "Subagents"), residents.length, "") +
"</div>";
// ---- 阶段管道:五个等大表框 ----
var g = rtPhaseGroup(state.pipelinePhase || "");
html +=
'<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;
});
var body = items.length
? items
.map(function (t) {
var kind = t.kind || "stage";
var ico =
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
return (
'<span class="rt-chip rt-chip-' + kind + '" title="' +
escHtml(t.label) + '">' + ico +
escHtml(t.short || t.label) +
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
"</span>"
);
})
.join("")
: '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
return (
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
"<b>" + __(s.zh, s.en) + "</b>" +
(s.loop
? '<em class="rt-loop" title="' +
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
'">' + RT_ICO.loop + "</em>"
: "") +
'</div><div class="rt-pipe-events">' + body + "</div></div>"
);
}).join("");
html += "</div>";
// ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)----
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
html += '<div class="rt-queues">';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
var reg = byLv[L.lv] || 0;
var pre = preLv[L.lv] || 0;
var desc = __(L.zh, L.en);
html +=
'<div class="rt-qcell ' + L.cls + (depth ? " rt-active" : "") +
'" title="' + escHtml(desc) + '">' +
'<div class="rt-qhead"><b>' + L.name + "</b><span>" + escHtml(desc) + "</span></div>" +
'<div class="rt-qnum">' + depth + "</div>" +
rtSlots(depth, qSlots, L.cls) +
'<div class="rt-qmeta">' + reg + " " + __("登记", "reg") + " · " +
pre + " " + __("抢占", "pre") + "</div></div>";
});
// 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级)
html +=
'<div class="rt-qcell rt-qcell-queued rt-lv-q' + (ready ? " rt-active" : "") +
'" title="' + __("排队(无级别,纯 FIFO)", "queued (no priority, FIFO)") + '">' +
'<div class="rt-qhead"><b>' + __("排队", "queued") + "</b><span>FIFO</span></div>" +
'<div class="rt-qnum">' + ready + "</div>" +
rtSlots(ready, qSlots, "rt-lv-q") +
'<div class="rt-qmeta">' + __("无级别", "no priority") + "</div></div>";
html += "</div></div>";
return html;
}
// 开源许可卡:协议标识 + 协议全文 + 源码仓库。
// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」——
// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。
function renderLegalCard() {
var b = ((state.kernel || {}).build) || {};
var src = b.source_url || "";
var lic = b.license || "";
var licURL = b.license_url || "";
if (!lic && !src) return "";
function row(key, val) {
return (
'<div class="kv-row"><span class="key">' + escHtml(key) +
'</span><span class="val">' + val + "</span></div>"
);
}
function a(href, text) {
return (
'<a href="' + escHtml(href) +
'" target="_blank" rel="noopener noreferrer">' + escHtml(text) + "</a>"
);
}
var rows = "";
if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic));
if (src) rows += row(__("源码仓库", "Source"), a(src, src));
// 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。
var note =
lic && lic.toUpperCase().indexOf("AGPL") >= 0
? '<p class="rt-empty">' +
__(
"网络服务条款(§13):把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径。",
"Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.",
) +
"</p>"
: "";
return '<div class="card"><h2>' + __("开源许可", "License") + "</h2>" + rows + note + "</div>";
}
function renderOverview() {
var s = state.status || {};
var k = state.kernel;
@ -1018,7 +1243,25 @@ function renderOverview() {
"uptime",
) +
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
statCard(
__("版本", "Version"),
(function () {
// 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
// 拿不到数据时会向用户展示一个不存在的版本号。
var b = (k && k.build) || {};
var v = b.version || s.version || "";
if (!v) return "-";
var sha =
b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : "";
return (
"v" + escHtml(v) +
'<div class="stat-sub">' + escHtml(b.kernel_name || "HomeAgent") +
(sha ? " · " + escHtml(sha) : "") + "</div>"
);
})(),
"version",
) +
"</div>";
if (k) {
html +=
@ -1082,6 +1325,7 @@ function renderOverview() {
"</span></div>" +
"</div></div>";
}
html += renderRuntimePanel();
html +=
'<div class="card"><h2>' +
__("运行时", "Runtime") +
@ -1094,6 +1338,7 @@ function renderOverview() {
) +
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
"</div></div>";
html += renderLegalCard();
document.getElementById("view-overview").innerHTML = html;
}
@ -5211,17 +5456,47 @@ async function connectFetchSSE(url) {
var phase = p.phase || "";
var tool = p.tool || "";
if (p.channel !== "_consolidation_") {
if (phase === "pre_action")
// 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
// 与 WebUI 同一套 g(阶段组)编号,见 rtPhaseGroup。
if (phase === "on_input") {
state.stageTrail = [];
rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input"));
} else if (phase === "pre_action") {
rtTrailPush(
1,
"stage",
__("组装上下文并思考", "assemble context and think"),
__("思考", "think"),
);
state.chatStage = __("AI 思考中...", "AI thinking...");
else if (phase === "before_toolcall") {
} else if (phase === "before_toolcall") {
if (tool)
rtTrailPush(
2,
tool.indexOf("output_") === 0 ? "output" : "tool",
tool,
rtShortTool(tool),
);
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
if (!state.pendingTools) state.pendingTools = [];
state.pendingTools.push(tool);
rerenderChatIfActive();
}
} else if (phase === "before_output")
} else if (phase === "before_output") {
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
state.chatStage = __("生成回复中...", "Generating response...");
} else if (phase === "after_output") {
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
}
state.pipelinePhase = phase;
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
state.pipelineTimer = setTimeout(function () {
state.pipelinePhase = "";
if (state.currentView === "overview") renderOverview();
}, 2500);
if (state.currentView === "overview") renderOverview();
}
var badge = document.getElementById("chat-stage");
if (badge) {

View File

@ -2191,3 +2191,243 @@ td .switch {
.kv-row .val .switch {
margin-right: 4px;
}
/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)=====
两块都统一成**等大表框**,与 KPI 卡同一种骨架。
此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」,
也看不到四级中断队列的积压。 */
.stat-card .stat-sub {
margin-top: 4px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.3px;
margin: 14px 0 8px;
text-transform: uppercase;
}
.rt-empty {
font-size: 12px;
color: var(--text-muted);
padding: 4px 0;
}
/* 行内小图标:一律 SVG,不使用 emoji/符号字符。 */
.rt-ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: -1.5px;
}
.rt-pipe-row {
display: grid;
/* 100px 下限:容器再窄也要保证 5 个框一行,不留「4 个 + 1 个」的孤行 */
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin: 2px 0 8px;
transition: opacity 0.3s var(--ease-out);
}
.rt-pipe-idle {
opacity: 0.55;
}
.rt-pipe-cell {
display: flex;
flex-direction: column;
gap: 8px;
padding: 11px 12px 10px;
border-radius: var(--radius-md);
background: var(--bg-input);
border: 1px solid var(--border-color);
min-width: 0;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out),
background 0.25s var(--ease-out);
}
.rt-pipe-cell.active {
border-color: var(--accent);
background: var(--accent-bg);
box-shadow: var(--shadow-md);
}
.rt-pipe-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-pipe-head > b {
font-size: 13.5px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rt-pipe-head .rt-ico {
width: 15px;
height: 15px;
color: var(--text-secondary);
}
.rt-pipe-cell.active .rt-pipe-head > b,
.rt-pipe-cell.active .rt-pipe-head .rt-ico {
color: var(--accent);
}
.rt-pipe-events {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 23px;
}
.rt-loop {
display: inline-flex;
color: var(--accent);
font-weight: 700;
}
.rt-chip {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
max-width: 170px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--bg-hover);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.rt-chip-tool {
border-color: rgba(136, 192, 208, 0.42);
color: var(--frost-300);
}
.rt-chip-output {
border-color: var(--accent);
color: var(--accent);
}
.rt-chip-stage {
opacity: 0.7;
}
.rt-chip-none {
opacity: 0.35;
border-style: dashed;
}
.rt-chip-n {
font-style: normal;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
/* 队列:5 个等大框(L4/L3/L2/L1 + 排队),一行排开 */
.rt-queues {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin-bottom: 4px;
}
.rt-qcell {
display: flex;
flex-direction: column;
gap: 7px;
padding: 11px 12px 10px;
border-radius: var(--radius-md);
background: var(--bg-input);
border: 1px solid var(--border-color);
min-width: 0;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
.rt-qhead {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.rt-qhead > b {
font-size: 16px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
.rt-qhead > span {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rt-qnum {
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.rt-qmeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 10.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; }
.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; }
.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); }
.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); }
.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; }
/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */
.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); }
.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); }
.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); }
.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); }
.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; }
/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */
.rt-qcell.rt-qcell-queued {
border-style: dashed;
}
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
用进度条时队列为 0 宽度就是 0,整行只剩文字,看上去就是「这块空着」。 */
.rt-slots {
display: flex;
align-items: stretch;
gap: 3px;
height: 16px;
min-width: 0;
}
.rt-slots > i {
flex: 1 1 0;
min-width: 3px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.05);
transition:
background 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); }
.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); }
.rt-slots-more {
flex: 0 0 auto;
align-self: center;
margin-left: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}

View File

@ -37,6 +37,8 @@ export const K_TOOLS: string = 'statTools';
export const K_ERR: string = 'statErr';
export const K_LOADING: string = 'statLoading';
export const K_REV: string = 'statRev';
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
export const K_BUILD: string = 'statBuildSub';
class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
@ -52,6 +54,7 @@ class StatusStore {
AppStorage.setOrCreate<string>(K_ERR, '');
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.setOrCreate<number>(K_REV, 0);
AppStorage.setOrCreate<string>(K_BUILD, '');
}
getGroups(): StatGroup[] {
@ -133,8 +136,66 @@ class StatusStore {
if (startTime.length > 0) {
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
}
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。
// 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、
// 哪次构建;许可标识更是完全没有。
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
let lic: string = '';
let licURL: string = '';
let srcURL: string = '';
if (build !== undefined && build !== null) {
const bVer: string = build['version'] as string ?? '';
const bCommit: string = build['commit'] as string ?? '';
const bName: string = build['kernel_name'] as string ?? 'HomeAgent';
const bSdk: string = build['sdk_compatible'] as string ?? '';
const bTime: string = build['build_time'] as string ?? '';
lic = build['license'] as string ?? '';
licURL = build['license_url'] as string ?? '';
srcURL = build['source_url'] as string ?? '';
if (bVer.length > 0) {
AppStorage.set<string>(K_VERSION, 'v' + bVer);
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
}
let sub: string = bName;
if (bCommit.length > 0 && bCommit !== 'unknown') {
sub = sub + ' · ' + bCommit.substring(0, 7);
}
AppStorage.set<string>(K_BUILD, sub);
if (bCommit.length > 0) {
kernelFields.push({ label: 'Commit', value: bCommit });
}
if (bSdk.length > 0) {
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
}
if (bTime.length > 0 && bTime !== 'unknown') {
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
}
}
groups.push({ title: '内核', fields: kernelFields });
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
// 也看不出网络服务场景下的 §13 义务。
const legalFields: StatField[] = [];
if (lic.length > 0) {
legalFields.push({ label: '许可协议', value: lic });
}
if (licURL.length > 0) {
legalFields.push({ label: '协议全文', value: licURL });
}
if (srcURL.length > 0) {
legalFields.push({ label: '源码仓库', value: srcURL });
}
if (legalFields.length > 0) {
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
legalFields.push({
label: '网络条款',
value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)',
});
}
groups.push({ title: '开源许可', fields: legalFields });
}
// LLM:provider / 可用源 / 是否可用
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
if (llm !== undefined && llm !== null) {

View File

@ -4,7 +4,7 @@ import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/
import { MotionBase } from './MotionBase';
import {
statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
} from '../common/StatusStore';
/**
@ -19,6 +19,8 @@ export struct StatusSummaryCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_VERSION) private version: string = '-';
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
@StorageProp(K_BUILD) private buildSub: string = '';
@StorageProp(K_STARTED) private startedAt: string = '';
@StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0;
@ -130,14 +132,24 @@ export struct StatusSummaryCard {
Text('版本')
.fontSize(12)
.fontColor(this.palette().textMuted)
Text(this.version)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 10 })
Column() {
Text(this.version)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
Text(this.buildSub)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('明细')
.fontSize(12)
.fontColor(this.palette().accent)