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

@ -26,6 +26,9 @@ type KernelStatus = sdk.KernelStatus
type PluginInfo = sdk.PluginInfo
type ChannelInfo = sdk.ChannelInfo
// InputChannelInfo 是 inputch 的登记与归属视图(见 internal/sdk/status.go
type InputChannelInfo = sdk.InputChannelInfo
// ResidentStatus 是驻留式子 agent 的运行时视图(见 internal/sdk/status.go
type ResidentStatus = sdk.ResidentStatus
type MemoryStatus = sdk.MemoryStatus
@ -130,6 +133,18 @@ func collectKernelStatus(
for _, ch := range iom.ListChannels() {
status.Channels = append(status.Channels, channelInfoFromIO(ch))
}
// inputch 登记与归属:与 Channels设备能力互补——那条回答「能做什么」
// 这条回答「这条输入归谁」。登记表是根 agent 与驻留子共用的,所以驻留子
// 划走的 inputchowner=子 id也会出现在这里。
for _, ic := range iom.InputChannels() {
status.InputChannels = append(status.InputChannels, InputChannelInfo{
Name: ic.Name,
Plugin: ic.Plugin,
Owner: ic.Owner,
Capacity: ic.Capacity,
Output: ic.Output,
})
}
}
// Graph memory

View File

@ -2262,6 +2262,135 @@
background: rgba(136, 192, 208, 0.16);
color: var(--frost-300);
}
/* —— 通道分配(按归属):容器 + 容量滑块 ——
为什么用容器而不是一排文字:归属是“分组”语义,只有把同一 owner 的
inputch 画进同一个盒子,才能一眼看出“这条输入被划给了哪个驻留子”。 */
.rt-owner {
border: 1px solid var(--border-color);
border-left: 3px solid var(--frost-300);
border-radius: 8px;
padding: 7px 9px;
margin-bottom: 7px;
background: rgba(255, 255, 255, 0.03);
}
.rt-owner.rt-owner-child {
border-left-color: #b48ead;
background: rgba(180, 142, 173, 0.07);
}
.rt-owner-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.rt-owner-name {
font-size: 11px;
font-weight: 600;
}
.rt-owner-meta {
font-size: 10px;
color: var(--text-muted);
}
.rt-assign {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-top: 1px dashed rgba(255, 255, 255, 0.06);
font-size: 11px;
}
.rt-assign .rt-chan-name {
font-weight: 600;
min-width: 84px;
}
.rt-assign .rt-chan-sub {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
}
.rt-chip {
font-size: 9px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(163, 190, 140, 0.16);
color: var(--text-secondary, var(--text-muted));
white-space: nowrap;
}
.rt-badge-warn {
font-size: 9px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(191, 97, 106, 0.22);
color: #d08770;
}
/* 容量滑块:轨道 + 填充 + 把手 + 读数。 */
.rt-slider {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
}
.rt-slider-label {
font-size: 9px;
color: var(--text-muted);
}
.rt-slider-track {
position: relative;
display: inline-block;
width: 64px;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.10);
overflow: visible;
}
.rt-slider-track > i {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 999px;
background: var(--frost-300);
}
.rt-slider-track > b {
position: absolute;
top: 50%;
width: 9px;
height: 9px;
margin-left: -4.5px;
margin-top: -4.5px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.rt-slider-val {
font-size: 10px;
min-width: 34px;
color: var(--text-muted);
}
/* 行内迷你条(登记 / 抢占并排) */
.rt-mini {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-size: 10px;
}
.rt-mini-track {
display: inline-block;
width: 30px;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.10);
overflow: hidden;
vertical-align: middle;
}
.rt-mini-track > i {
display: block;
height: 100%;
border-radius: 999px;
background: var(--frost-300);
}
.rt-core {
width: 74px;
height: 74px;

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 =

View File

@ -265,5 +265,8 @@ func (h *Handler) handleRuntime(w http.ResponseWriter, r *http.Request) {
"scheduler": ks.Scheduler,
"residents": ks.Residents,
"channels": ks.Channels,
// inputch 的登记与归属(谁注册、划给了哪个 agent、容量、默认回程
// 只给设备能力channels无法回答「这条输入归谁」——驻留子存在时这就是缺口。
"input_channels": ks.InputChannels,
})
}

View File

@ -1409,6 +1409,13 @@ func TestRuntimeEndpoint(t *testing.T) {
},
Residents: []sdk.ResidentStatus{{ID: "r1", State: "running", Rounds: 3}},
Channels: []sdk.ChannelInfo{{Name: "webui", Type: "1", Direction: "out", Ready: true, OutputCaps: 7, CapsText: "[text file image]"}},
// inputch 的登记与归属:必须带出**驻留子划走的那条**owner=子 id——
// 只回设备能力channels就无法回答「这条输入归谁」驻留子的通道分配
// 在界面上完全不可见(本次修的缺口)。
InputChannels: []sdk.InputChannelInfo{
{Name: "qq", Plugin: "qq", Capacity: 32, Output: "qq"},
{Name: "timer", Plugin: "timer", Owner: "r1", Capacity: 8},
},
}
s := testSDK(sdk.SDKConfig{
Settings: sdk.NewSettings("webui", internalConfig.NewConfigRegistry("")),
@ -1424,9 +1431,10 @@ func TestRuntimeEndpoint(t *testing.T) {
t.Fatalf("expected 200, got %d", w.Code)
}
var out struct {
Scheduler sdk.SchedulerStatus `json:"scheduler"`
Residents []sdk.ResidentStatus `json:"residents"`
Channels []sdk.ChannelInfo `json:"channels"`
Scheduler sdk.SchedulerStatus `json:"scheduler"`
Residents []sdk.ResidentStatus `json:"residents"`
Channels []sdk.ChannelInfo `json:"channels"`
InputChannels []sdk.InputChannelInfo `json:"input_channels"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
@ -1446,6 +1454,25 @@ func TestRuntimeEndpoint(t *testing.T) {
if len(out.Channels) != 1 || out.Channels[0].Direction != "out" {
t.Fatalf("通道方向丢失:%+v", out.Channels)
}
// 通道分配:既要带归属,也要带**驻留子划走的那条**
if len(out.InputChannels) != 2 {
t.Fatalf("input_channels 应回 2 条,实际 %+v", out.InputChannels)
}
var childCh, rootCh *sdk.InputChannelInfo
for i := range out.InputChannels {
switch out.InputChannels[i].Owner {
case "":
rootCh = &out.InputChannels[i]
case "r1":
childCh = &out.InputChannels[i]
}
}
if rootCh == nil || rootCh.Name != "qq" || rootCh.Capacity != 32 || rootCh.Output != "qq" {
t.Fatalf("根 agent 的 inputch 归属/容量/回程丢失:%+v", rootCh)
}
if childCh == nil || childCh.Name != "timer" || childCh.Capacity != 8 {
t.Fatalf("驻留子划走的 inputch 未带出(这正是本次修的缺口):%+v", childCh)
}
// 只回运行态:不该把 tools/plugins 这类大块带上
if strings.Contains(w.Body.String(), "\"tools\"") || strings.Contains(w.Body.String(), "\"plugins\"") {
t.Fatal("/runtime 不应携带 tools/plugins那是 /kernel 的内容)")

View File

@ -25,6 +25,13 @@ type KernelStatus struct {
Plugins []PluginInfo `json:"plugins"`
Tools []ToolDef `json:"tools"`
Channels []ChannelInfo `json:"channels"`
// InputChannels 是 inputch 的**登记与归属**视图(谁注册、划给了哪个 agent、
// 容量、默认回程输出)。
//
// 为何单列Channels 只描述设备侧能力,回答不了「这条输入归谁」。驻留子 agent
// 出现之后,归属才是运行态里最该看见的东西——只画设备能力,等于把
// 「输入路由发生在进内核之前」这条设计事实藏了起来。
InputChannels []InputChannelInfo `json:"input_channels"`
Memory MemoryStatus `json:"memory"`
Knowledge KnowledgeStatus `json:"knowledge"`
@ -156,6 +163,22 @@ type ChannelInfo struct {
CapsText string `json:"caps_text,omitempty"`
}
// InputChannelInfo 是一条 inputch 的登记与归属视图。
//
// 字段取自 agent/io 的 ChannelRegistry根 agent 与驻留子**共用同一份**登记表),
// 因此它天然覆盖驻留子的通道分配。
type InputChannelInfo struct {
Name string `json:"name"`
// Plugin 是注册它的插件名(归属可追溯)。
Plugin string `json:"plugin,omitempty"`
// Owner 是被划给的 agent id"" = 未分配,归根 agent / 内核默认。
Owner string `json:"owner,omitempty"`
// Capacity 是该 inputch 的队列容量0 = 用内核默认值。
Capacity int `json:"capacity,omitempty"`
// Output 是该 inputch 的默认回程输出通道;"" = 由来源决定。
Output string `json:"output,omitempty"`
}
type MemoryStatus struct {
Available bool `json:"available"`
EntityCount int `json:"entity_count"`