fix(webui): 运行态面板不再闪、通道分配带归属(含驻留子)、改图形化

三个用户可见问题,逐个说明根因与改法。

1) 首页「一闪一闪」——运行态每 3s 轮询一次,renderRuntime 无条件重建
   #rt-panel 的 innerHTML:数据没变也把整块 DOM(含各级条的 transition)
   推倒重来。改法:缓存数据签名(**不含 uptime**——它每秒都变,带上等于没缓存),
   签名相同直接 return,一个字节都不动。另:renderOverview 会整块重建
   #rt-panel(面板本身是空的),所以那里必须让签名失效,否则空面板填不上。

2) 通道分配只显示内核/根 agent,看不见驻留子——根因是状态面只暴露了设备能力
   (KernelStatus.Channels,来自 iom.ListChannels),而「这条输入归谁」是
   ChannelRegistry 的属性(InputChannel.Owner/Capacity/Output),从未出过内核。
   而登记表本来就是根 agent 与驻留子**共用同一份**,所以数据一直都在,只是没画。
   改法:KernelStatus 新增 InputChannels(+ sdk.InputChannelInfo),
   /api/v1/runtime 带出 input_channels;前端把它按 owner 分进「归属容器」,
   驻留子即使一条 inputch 都没划到也照样出现在图里(否则"子存在但看不见"
   与"子不存在"无法区分),并显示其 allowed_outputs / 轮次 / 上下文满标记。

3) 「这些信息明明可以图形化」——四级中断的登记/抢占由纯文本改成并排迷你条;
   通道分配用归属容器 + 容量滑块(轨道/填充/把手/读数),并把回程通道、
   注册插件做成胶囊标签。设备能力拓扑(原有)保留。

验证:go build/vet 干净;go test ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;node --check dashboard.js 语法通过。
TestRuntimeEndpoint 扩展为同时钉住 input_channels 的归属与「驻留子划走的那条」。
This commit is contained in:
JianFeeeee
2026-09-14 10:55:45 +08:00
parent c321388a21
commit 43536ba326
6 changed files with 326 additions and 5 deletions

View File

@ -583,6 +583,38 @@
];
var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" };
// _rtSig 缓存上一次渲染的数据签名。
//
// 为什么必须缓存:运行态每 3s 轮询一次,数据绝大多数时候是**没变**的;
// 无条件 `innerHTML =` 会把整块 DOM(含各级条的 transition)每 3s 重建一遍,
// 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。
var _rtSig = null;
// rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。
function rtSlider(value, max, label, display) {
var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
return (
'<span class="rt-slider"><span class="rt-slider-label">' +
escHtml(label) +
'</span><span class="rt-slider-track"><i style="width:' + pct + '%"></i><b style="left:' + pct + '%"></b></span>' +
'<span class="rt-slider-val">' +
escHtml(String(display === undefined ? value : display)) +
"</span></span>"
);
}
// rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。
function rtMini(value, max, label) {
var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
return (
'<span class="rt-mini" title="' +
escHtml(label) + " " + value +
'"><span class="rt-mini-track"><i style="width:' + pct + '%"></i></span>' +
value +
"</span>"
);
}
function rtTile(num, label, sub, pct, active, warn) {
return (
'<div class="rt-tile' +
@ -616,11 +648,18 @@
var rt = state.runtime;
if (!rt) {
el.innerHTML = '<div class="rt-empty">' + __("运行态数据不可用", "runtime unavailable") + "</div>";
_rtSig = null;
return;
}
var sc = rt.scheduler || {};
var residents = rt.residents || [];
var channels = rt.channels || [];
var inputs = rt.input_channels || [];
// 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):
// 与上次相同则直接返回,一个字节都不动(“首页一闪一闪”的根因)。
var sig = JSON.stringify([sc, residents, channels, inputs]);
if (sig === _rtSig) return;
_rtSig = sig;
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
var pending = sc.pending_interrupts || 0;
var ready = sc.ready_queue_depth || 0;
@ -643,6 +682,12 @@
// 四级中断队列
html += '<div class="rt-section-title">' + __("中断队列(按级别)", "Interrupt queues by level") + "</div>";
var maxQ = Math.max(1, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
var maxReg = 1;
var maxPre = 1;
RT_LEVELS.forEach(function (L) {
maxReg = Math.max(maxReg, byLv[L.lv] || 0);
maxPre = Math.max(maxPre, preLv[L.lv] || 0);
});
html += '<div class="rt-levels">';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
@ -654,7 +699,8 @@
(depth ? Math.max(4, (depth / maxQ) * 100) : 0) +
'%"></i></span>' +
'<span class="rt-lv-meta">' + depth + " · " +
__("登记", "reg") + " " + reg + " / " + __("抢占", "pre") + " " + pre +
rtMini(reg, maxReg, __("登记", "registered")) +
rtMini(pre, maxPre, __("抢占", "preempted")) +
"</span></div>";
});
html += "</div>";
@ -683,7 +729,82 @@
escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")</div>";
}
// 通道拓扑
// 通道**分配(按归属)**:inputch 是输入路由单位,而登记表由根 agent 与
// 驻留子**共用同一份**——所以“这条输入归谁”必须画出来。只画设备能力
// (下面的拓扑)等于把“路由发生在进内核之前”这条设计事实藏起来,
// 于是驻留子的通道分配在界面上完全不可见。
html += '<div class="rt-section-title">' + __("通道分配(按归属)", "Input channels by owner") + "</div>";
var maxCap = 1;
inputs.forEach(function (c) {
if ((c.capacity || 0) > maxCap) maxCap = c.capacity;
});
var owners = [];
var byOwner = {};
inputs.forEach(function (c) {
var o = c.owner || "";
if (!byOwner[o]) {
byOwner[o] = [];
owners.push(o);
}
byOwner[o].push(c);
});
// 驻留子即使一条 inputch 也没划到,也要出现在分配图里——
// 否则“子存在但界面上看不见”与“子不存在”无法区分。
residents.forEach(function (r) {
var o = r.id || "";
if (o && !byOwner[o]) {
byOwner[o] = [];
owners.push(o);
}
});
owners.sort(function (a, b) {
if (a === "") return -1;
if (b === "") return 1;
return a < b ? -1 : 1;
});
if (!owners.length) {
html += '<div class="rt-empty">' + __("暂无通道登记", "no channel registered") + "</div>";
}
owners.forEach(function (o) {
var list = byOwner[o] || [];
var res = null;
if (o) {
residents.forEach(function (r) {
if (r.id === o) res = r;
});
}
html += '<div class="rt-owner' + (o ? " rt-owner-child" : "") + '">';
html += '<div class="rt-owner-head"><span class="rt-owner-name">' +
(o ? "▸ " : "◆ ") +
(o ? __("驻留子 ", "resident ") + escHtml(o) : __("根 agent / 内核默认", "root agent / kernel default")) +
'</span><span class="rt-owner-meta">' + list.length + " " + __("条通道", "channels") +
(res ? " · " + __("轮次", "rounds") + " " + (res.rounds || 0) : "") +
(res && res.context_full ? ' <span class="rt-badge-warn">' + __("上下文已满", "ctx full") + "</span>" : "") +
"</span></div>";
if (list.length) {
list.forEach(function (c) {
var cap = c.capacity || 0;
html += '<div class="rt-assign">' +
'<span class="rt-chan-name">' + escHtml(c.name) + "</span>" +
(c.plugin ? '<span class="rt-chip">' + escHtml(c.plugin) + "</span>" : "") +
rtSlider(cap, maxCap, __("容量", "cap"), cap ? String(cap) : __("默认", "default")) +
'<span class="rt-chan-sub">' +
(c.output ? __("回程 ", "out ") + escHtml(c.output) : __("回程由来源决定", "out by source")) +
"</span></div>";
});
} else {
var allowed = (res && res.allowed_outputs) || [];
html += '<div class="rt-chan-sub">' +
(allowed.length
? __("可发往输出通道:", "allowed outputs: ") +
allowed.map(function (x) { return '<span class="rt-chip">' + escHtml(x) + "</span>"; }).join("")
: __("未划入任何 inputch", "no input channel assigned")) +
"</div>";
}
html += "</div>";
});
// 通道拓扑(设备能力面:能收什么、能发什么、有哪些工具)
html += '<div class="rt-section-title">' + __("通道拓扑", "Channel topology") + "</div>";
var ins = channels.filter(function (c) { return c.direction === "in" || c.direction === "io"; });
var outs = channels.filter(function (c) { return c.direction === "out" || c.direction === "io"; });
@ -730,6 +851,9 @@
}
function renderOverview() {
// 总览页会整块重建 #rt-panel(面板本身是空的),所以必须让运行态的
// 数据签名失效,否则 renderRuntime 会以为“没变化”而不去填这块空面板。
_rtSig = null;
var s = state.status || {};
var k = state.kernel;
var html =