mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
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:
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user