feat(webui): 总览显示内核身份、图标全 SVG 化、队列改格槽、阶段管道下方按阶段列事件

四个问题一起改(都出在总览/内核页的展示层,不动内核逻辑):

1) 内核版本不再"看不见"
   - 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩一个 "v1.4.0",分不清
     是哪个内核、哪次构建。现在 KPI 值给版本号,下面补一行副行
     「HomeAgent · <commit>」(.ov-sub)。
   - 内核页此前**完全没有构建信息**,现在补一张「构建」卡:内核名/内核版本/
     Commit/构建时间/SDK 兼容/源码链接(AGPL §13 的入口页)。

2) 任何位置都不再用 emoji/符号字符充当图标
   - 新增 RT_ICO(纯内联 SVG,24x24 / currentColor),替换:阶段节点的循环
     标记(原 ↻)、轨迹 chip 的工具/输出标记(原 ⚙/⇥)、"立即运行"(原 )、
     工具卡与思考卡的下拉箭头(原 ▾)。
   - CSS 注释里的同类字符一并去掉。

3) 队列不再"空着只有文字"
   - 原来画的是宽度百分比进度条:深度为 0 时宽度就是 0,五行只剩文字。
     改成 rtSlots 的「车位」式格槽(至少 5 格、最多 16 格,按全场最大深度
     缩放),0 时仍有可见形状,占用多少一眼可数;超出格数时给 +N。
   - 修掉一个真实的 DOM 结构错误:第五条「排队」队列被写在 .rt-levels 闭合
     **之后**,且后面多一个 </div>,多出来的闭合标签会提前关掉祖先节点、
     把整块布局撞歪。现在它回到容器内。

4) 每一步管道的事件显示在管道下方对应阶段列里
   - 原来是一条拍平的 chip 序列,看不出"这件事发生在哪个阶段"。
     现在 .rt-pipe-cols 与上面的阶段节点共用 5 等分栅格,事件按 g(阶段组)
     分列落位;实测列中心与节点中心偏差 ≤ 2px。
   - 轨迹覆盖全部阶段(输入/思考/工具/输出/完成),同阶段重复的同一条
     累加 ×N 而不是刷屏(rtTrailPush)。空列显示一个弱化的「无」。

验证(现网 1400x920,CDP 实测):
- 版本 KPI = v1.4.0+hotfix.0fd4fb1 / HomeAgent · 0fd4fb1;内核页构建卡齐全
- 注入一轮轨迹:col0=输入 col1=思考x2 col2=qq_get_message x3/qq/cmd_run
  col3=生成x2 col4=完成,active 节点=工具,×N 计数正常,3 个 chip SVG
- 队列 L3=3/5、排队=2/5 点亮,L3 取到琥珀色 rgb(255,166,87)
- 页面无横向溢出;总览/侧栏/顶栏/内核页渲染文本无 emoji
- go vet 干净,internal/plugins/webui 测试通过
This commit is contained in:
JianFeeeee
2026-09-14 17:40:54 +08:00
parent 21db84e8dc
commit bead5746c3
3 changed files with 258 additions and 75 deletions

View File

@ -1596,7 +1596,8 @@
}
.reasoning-card .rc-chev {
margin-left: auto;
font-size: 10px;
display: inline-flex;
align-items: center;
color: var(--text-muted);
transition: transform 0.2s var(--ease-out);
}
@ -1725,7 +1726,8 @@
display: inline-block;
}
.tool-card .tc-caret {
font-size: 10px;
display: inline-flex;
align-items: center;
color: var(--text-muted);
transition: transform 0.15s var(--ease-out);
}
@ -2362,27 +2364,45 @@
color: var(--text-secondary);
white-space: nowrap;
}
.rt-level .rt-lv-track {
height: 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
此前是宽度百分比进度条 —— 队列为 0 时宽度为 0整行只剩文字
看上去就是「这块空着」。 */
.rt-level .rt-slots {
display: flex;
align-items: stretch;
gap: 3px;
height: 14px;
min-width: 0;
}
.rt-level .rt-lv-track > i {
display: block;
height: 100%;
transition: width 0.35s var(--ease-out);
.rt-level .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-level .rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
.rt-level .rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
.rt-level .rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.4); }
.rt-level .rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
.rt-level .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: 400;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-level .rt-lv-meta {
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.rt-lv-4 > i { background: #ff5c7a; }
.rt-lv-3 > i { background: #ffa657; }
.rt-lv-2 > i { background: var(--sakura-400); }
.rt-lv-1 > i { background: var(--frost-300); }
/* 拓扑图:整张图为 SVG文字降到最少其余信息用图形编码
(归属=容器/配色、容量=细条、输出能力=圆点、路由=连线)。 */
.rt-svg-wrap {
@ -2417,9 +2437,6 @@
margin-top: 5px;
padding-top: 7px;
}
.rt-level .rt-lv-track.rt-lv-q > i {
background: #a3be8c;
}
/* 中断栈:栈顶在上,做出"层叠现场"的观感 */
.rt-stack {
display: flex;
@ -2666,15 +2683,15 @@
}
.rt-pipe-nodes {
position: relative;
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.rt-pipe-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 44px;
min-width: 0;
}
.rt-pipe-node > i {
width: 11px;
@ -2690,11 +2707,20 @@
box-shadow 0.25s var(--ease-out);
}
.rt-pipe-node > b {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
max-width: 100%;
font-size: 9px;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
}
/* 图标自带 stroke标签文字不能用下划线<u> 只为分组) */
.rt-pipe-node > b > u {
text-decoration: none;
}
.rt-pipe-node.done > i {
background: var(--frost-300);
border-color: var(--frost-300);
@ -2796,6 +2822,16 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* 副行:内核名 + commit。总览只给版本号会分不清是哪个内核、哪次构建。 */
.ov-sub {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 12px;
font-variant-numeric: tabular-nums;
}
.ov-lab {
font-size: 10px;
letter-spacing: 0.05em;
@ -2825,20 +2861,49 @@
color: var(--text-muted);
}
/* 阶段管道的循环标记与「本轮轨迹」chip 序列。
轨迹用不同配色区分:普通工具(⚙)与 output_* 输出通道调用(⇥)。 */
/* 阶段管道下方的「每阶段事件」分列。
与上面的节点同为 5 等分栅格,所以每一列正好落在它对应阶段的下面;
列内 chip 用不同配色区分普通工具与 output_* 输出通道。 */
.rt-pipe-cols {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
margin: 4px 0 10px;
padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.09);
}
.rt-pipe-col {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 4px;
min-width: 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-ico-sm {
width: 11px;
height: 11px;
}
.rt-loop {
font-style: normal;
display: inline-flex;
color: var(--accent);
font-weight: 700;
}
.rt-trail {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 2px 0 8px;
}
.rt-chip {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 170px;
overflow: hidden;
text-overflow: ellipsis;
@ -2861,3 +2926,12 @@
.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;
}

View File

@ -53,7 +53,7 @@
导航项:图标 + 标签。
为什么把 data-i18n 挪到内层 span切语言时那段代码是 `el.textContent = …`
挂在 <a> 上会把图标一起抹掉 —— 只有把标签单独包起来才两全。
图标一律 24×24 strokecurrentColor跟随 active/hover 变色,不额外加一套资源。
图标一律 24x24 strokecurrentColor跟随 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>

View File

@ -782,12 +782,25 @@
// 输入 → 行动 ⇄(工具) → 输出 → 结束
// (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**
// 普通工具与 output_* 输出通道调用用不同配色区分开来。
// 页面上**任何位置都不用 emoji/符号字符充当图标** —— 一律内联 SVG
// 24x24 viewBox + currentColor stroke随主题与状态变色不额外引资源。
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>',
bolt: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M13 2 4.5 13.5H11l-1 8.5L18.5 10H12z"/></svg>',
caret: '<svg class="rt-ico rt-ico-sm" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>',
};
var RT_PIPE_GROUPS = [
{ zh: "输入", en: "in" },
{ zh: "行动", en: "act" },
{ zh: "工具", en: "tool" },
{ zh: "输出", en: "out" },
{ zh: "结束", en: "done" },
{ 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" },
];
function rtPhaseGroup(phase) {
switch (phase) {
@ -813,25 +826,60 @@
if (i >= 0) n = n.slice(i + 2);
return n.length > 14 ? n.slice(0, 13) + "…" : n;
}
// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。
//
// 为什么不用进度条:队列为 0 时进度条宽度就是 0整行只剩一串文字
// 看上去就是「这块空着」。格槽在 0 时仍有形状,占用多少一眼可数,
// 也不用为了「看得见」而给 0 画一条假进度。
function rtSlots(depth, slots, extraCls) {
var n = Math.max(5, Math.min(16, slots || 5));
var d = depth || 0;
var out = '<span class="rt-slots ' + (extraCls || "") + '">';
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
// 溢出计数必须留在 .rt-slots 内:.rt-level 是 3 列栅格,
// 多一个兄弟节点会被挤到下一行,整块队列就串了。
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
return out + "</span>";
}
function rtPipelineHtml(phase, trail) {
var g = rtPhaseGroup(phase);
var nodes = RT_PIPE_GROUPS.map(function (s, i) {
var cls = "rt-pipe-node" + (i === g ? " active" : "");
return (
'<span class="' + cls + '"><i></i><b>' +
__(s.zh, s.en) +
(i === 2 ? ' <em class="rt-loop" title="' +
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") + '">↻</em>' : "") +
RT_ICO[s.ico] +
"<u>" + __(s.zh, s.en) + "</u>" +
(s.loop
? ' <em class="rt-loop" title="' +
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
'">' + RT_ICO.loop + "</em>"
: "") +
"</b></span>"
);
}).join("");
var chips = (trail || []).map(function (t) {
var kind = t.kind || "stage";
return (
'<span class="rt-chip rt-chip-' + kind + '" title="' + escHtml(t.label) + '">' +
(kind === "output" ? "⇥ " : kind === "tool" ? "⚙ " : "") +
escHtml(t.short || t.label) + "</span>"
);
// 事件按**发生它的阶段**分列,列的横向位置与上面的阶段节点对齐
// (同为 5 等分栅格),所以「这一步发生了什么」不需要靠颜色去猜。
var cols = RT_PIPE_GROUPS.map(function (s, i) {
var items = (trail || []).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-col">' + body + "</div>";
}).join("");
return (
'<div class="rt-section-title">' +
@ -841,10 +889,24 @@
'<div class="rt-pipe' + (g < 0 ? " rt-pipe-idle" : "") + '">' +
'<div class="rt-pipe-nodes">' + nodes + "</div>" +
"</div>" +
(chips ? '<div class="rt-trail">' + chips + "</div>" : "")
'<div class="rt-pipe-cols">' + cols + "</div>"
);
}
// 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();
}
// ---- per-agent 负载 ----
//
// 负载 = 该 agent **自己的**调度器积压折算成的百分比。
@ -1096,7 +1158,7 @@
// 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。
var sig = JSON.stringify([
sc, residents, channels, inputs, state.pipelinePhase,
(state.stageTrail || []).map(function (t) { return t.kind + ":" + (t.short || t.label); }).join(","),
(state.stageTrail || []).map(function (t) { return t.g + ":" + t.kind + ":" + (t.short || t.label) + "x" + (t.n || 1); }).join(","),
]);
if (sig === _rtSig) return;
_rtSig = sig;
@ -1161,6 +1223,8 @@
maxReg = Math.max(maxReg, byLv[L.lv] || 0);
maxPre = Math.max(maxPre, preLv[L.lv] || 0);
});
// 槽位数按全场最大深度缩放(且至少 5 格0 时也有可见形状,不空着。
var qSlots = Math.max(5, Math.min(16, maxQ));
h += '<div class="rt-levels">';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
@ -1168,24 +1232,23 @@
var pre = preLv[L.lv] || 0;
h +=
'<div class="rt-level" title="' + escHtml(L.desc) + '"><span class="rt-lv-name">' + L.name + "</span>" +
'<span class="rt-lv-track ' + L.cls + '"><i style="width:' +
(depth ? Math.max(4, (depth / maxQ) * 100) : 0) +
'%"></i></span>' +
rtSlots(depth, qSlots, L.cls) +
'<span class="rt-lv-meta">' + depth + " · " +
rtMini(reg, maxReg, __("登记", "registered")) +
rtMini(pre, maxPre, __("抢占", "preempted")) +
"</span></div>";
});
h += "</div>";
// 第五条:排队队列(无级别,纯 FIFO
h += '<div class="rt-level rt-level-queued" title="' + __("排队(无级别,纯 FIFO", "queued (no priority, FIFO)") + '"><span class="rt-lv-name">' +
// 第五条:排队队列(无级别,纯 FIFO
// 注意:它必须在 .rt-levels **之内**:早前它被写在容器闭合之后,下面又多一个
// </div>,多出来的闭合标签会把祖先节点提前关掉,整块布局被撞歪。
h +=
'<div class="rt-level rt-level-queued" title="' + __("排队(无级别,纯 FIFO", "queued (no priority, FIFO)") + '"><span class="rt-lv-name">' +
__("排队", "queued") + "</span>" +
'<span class="rt-lv-track rt-lv-q"><i style="width:' +
(ready ? Math.max(4, (ready / maxQ) * 100) : 0) + '%"></i></span>' +
rtSlots(ready, qSlots, "rt-lv-q") +
'<span class="rt-lv-meta">' + ready + " · FIFO</span></div>";
h += "</div>";
if (sc.immediate) {
h += '<div class="rt-frame">' + __("立即运行", "immediate") + "" +
h += '<div class="rt-frame">' + RT_ICO.bolt + " " + __("立即运行", "immediate") + "" +
escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id +
'<span class="rt-frame-top">L' + (sc.immediate.level || 0) + "</span></div>";
}
@ -1290,7 +1353,9 @@
icon +
'</span><span class="ov-val">' +
(dot ? '<i class="ov-dot" id="ov-' + id + '-dot"></i>' : "") +
'<b id="ov-' + id + '">—</b></span><span class="ov-lab">' +
'<b id="ov-' + id + '">—</b></span>' +
'<span class="ov-sub" id="ov-' + id + '-sub"></span>' +
'<span class="ov-lab">' +
label +
"</span></div>"
);
@ -1336,13 +1401,19 @@
ovDot("ov-status-dot", "ov-dot " + (running ? "ok" : "warn"));
ovSet("ov-uptime", state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-");
ovSet("ov-plugins", ((k && k.plugins) || []).length || 0);
// 内核身份:光有版本号分不清是哪个内核、哪次构建 —— 补上内核名与 commit。
// 之前 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩余 "v1.4.0"。
var kb = (k && k.build) || {};
var kv = kb.version || s.version || "";
ovSet("ov-version", kv ? "v" + kv : "—");
ovSet(
"ov-version",
k && k.build && k.build.version
? "v" + k.build.version
: s.version
? "v" + s.version
: "",
"ov-version-sub",
kv
? (kb.kernel_name || "HomeAgent") +
(kb.commit && kb.commit !== "unknown"
? " · " + String(kb.commit).slice(0, 7)
: "")
: "",
);
var llm = (k && k.llm) || {};
ovDot("ov-llm-dot", "ov-dot " + (llm.available ? "ok" : "bad"));
@ -1850,7 +1921,7 @@
"</span>" +
pluginHtml +
statusHtml +
'<span class="tc-caret"></span></div>' +
'<span class="tc-caret">' + RT_ICO.caret + "</span></div>" +
'<div class="tc-detail" style="display:none">' +
(argsStr && argsStr !== "{}"
? '<div class="tc-args"><div class="tc-detail-label">' +
@ -2077,7 +2148,7 @@
? __("思考中...", "Thinking...")
: __("思考", "Thinking")) +
"</span>" +
'<span class="rc-chev"></span></div>' +
'<span class="rc-chev">' + RT_ICO.caret + "</span></div>" +
'<div class="reasoning-body" style="display:' +
(isStreaming ? "block" : "none") +
'">' +
@ -3567,17 +3638,17 @@
// 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。
// 工具调用会反复出现(多轮 toolcalloutput_* 单独配色。
if (phase === "on_input") {
state.stageTrail = [{ kind: "stage", label: __("输入", "input"), short: __("输入", "input") }];
state.stageTrail = [];
rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input"));
} else if (phase === "pre_action") {
rtTrailPush(1, "stage", __("组装上下文并思考", "assemble context and think"), __("思考", "think"));
} else if (phase === "before_toolcall" && tool) {
state.stageTrail.push({
kind: tool.indexOf("output_") === 0 ? "output" : "tool",
label: tool,
short: rtShortTool(tool),
});
if (state.stageTrail.length > 24) state.stageTrail.shift();
var isOut = tool.indexOf("output_") === 0;
rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool));
} else if (phase === "before_output") {
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
} else if (phase === "after_output") {
state.stageTrail.push({ kind: "stage", label: __("完成", "done"), short: __("完成", "done") });
if (state.stageTrail.length > 24) state.stageTrail.shift();
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
}
state.pipelinePhase = phase;
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
@ -4171,7 +4242,45 @@
"</p></div>";
return;
}
var b = k.build || {};
var html =
'<div class="card"><h2>' +
__("构建", "Build") +
"</h2>" +
'<div class="kv-row"><span class="key">' +
__("内核", "Kernel") +
'</span><span class="val">' +
escHtml(b.kernel_name || "HomeAgent") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("内核版本", "Kernel version") +
'</span><span class="val">' +
escHtml(b.version ? "v" + b.version : "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">Commit</span><span class="val">' +
escHtml(b.commit || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("构建时间", "Build time") +
'</span><span class="val">' +
escHtml(b.build_time || "-") +
"</span></div>" +
'<div class="kv-row"><span class="key">' +
__("SDK 兼容", "SDK compat") +
'</span><span class="val">' +
escHtml(b.sdk_compatible || "-") +
"</span></div>" +
(b.source_url
? '<div class="kv-row"><span class="key">' +
__("源码", "Source") +
'</span><span class="val"><a href="' +
escHtml(b.source_url) +
'" target="_blank" rel="noopener noreferrer">' +
escHtml(b.source_url) +
"</a></span></div>"
: "") +
"</div>";
html +=
'<div class="card"><h2>' +
__("运行时", "Runtime") +
"</h2>" +