diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go
index 1d03079..ff612b3 100644
--- a/internal/agent/core/status.go
+++ b/internal/agent/core/status.go
@@ -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 与驻留子共用的,所以驻留子
+ // 划走的 inputch(owner=子 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
diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css
index ac56f9c..2947c09 100644
--- a/internal/plugins/webui/dashboard.css
+++ b/internal/plugins/webui/dashboard.css
@@ -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;
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js
index 99d77e8..5c228b9 100644
--- a/internal/plugins/webui/dashboard.js
+++ b/internal/plugins/webui/dashboard.js
@@ -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 (
+ '' +
+ escHtml(label) +
+ '' +
+ '' +
+ escHtml(String(display === undefined ? value : display)) +
+ ""
+ );
+ }
+
+ // rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。
+ function rtMini(value, max, label) {
+ var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
+ return (
+ '' +
+ value +
+ ""
+ );
+ }
+
function rtTile(num, label, sub, pct, active, warn) {
return (
'
' + __("运行态数据不可用", "runtime unavailable") + "
";
+ _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 += '' + __("中断队列(按级别)", "Interrupt queues by level") + "
";
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 += '';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
@@ -654,7 +699,8 @@
(depth ? Math.max(4, (depth / maxQ) * 100) : 0) +
'%">' +
'' + depth + " · " +
- __("登记", "reg") + " " + reg + " / " + __("抢占", "pre") + " " + pre +
+ rtMini(reg, maxReg, __("登记", "registered")) +
+ rtMini(pre, maxPre, __("抢占", "preempted")) +
"
";
});
html += "";
@@ -683,7 +729,82 @@
escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")";
}
- // 通道拓扑
+ // 通道**分配(按归属)**:inputch 是输入路由单位,而登记表由根 agent 与
+ // 驻留子**共用同一份**——所以“这条输入归谁”必须画出来。只画设备能力
+ // (下面的拓扑)等于把“路由发生在进内核之前”这条设计事实藏起来,
+ // 于是驻留子的通道分配在界面上完全不可见。
+ html += '' + __("通道分配(按归属)", "Input channels by owner") + "
";
+ 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 += '' + __("暂无通道登记", "no channel registered") + "
";
+ }
+ owners.forEach(function (o) {
+ var list = byOwner[o] || [];
+ var res = null;
+ if (o) {
+ residents.forEach(function (r) {
+ if (r.id === o) res = r;
+ });
+ }
+ html += '';
+ html += '
' +
+ (o ? "▸ " : "◆ ") +
+ (o ? __("驻留子 ", "resident ") + escHtml(o) : __("根 agent / 内核默认", "root agent / kernel default")) +
+ '' + list.length + " " + __("条通道", "channels") +
+ (res ? " · " + __("轮次", "rounds") + " " + (res.rounds || 0) : "") +
+ (res && res.context_full ? ' ' + __("上下文已满", "ctx full") + "" : "") +
+ "
";
+ if (list.length) {
+ list.forEach(function (c) {
+ var cap = c.capacity || 0;
+ html += '
' +
+ '' + escHtml(c.name) + "" +
+ (c.plugin ? '' + escHtml(c.plugin) + "" : "") +
+ rtSlider(cap, maxCap, __("容量", "cap"), cap ? String(cap) : __("默认", "default")) +
+ '' +
+ (c.output ? __("回程 ", "out ") + escHtml(c.output) : __("回程由来源决定", "out by source")) +
+ "
";
+ });
+ } else {
+ var allowed = (res && res.allowed_outputs) || [];
+ html += '
' +
+ (allowed.length
+ ? __("可发往输出通道:", "allowed outputs: ") +
+ allowed.map(function (x) { return '' + escHtml(x) + ""; }).join("")
+ : __("未划入任何 inputch", "no input channel assigned")) +
+ "
";
+ }
+ html += "
";
+ });
+
+ // 通道拓扑(设备能力面:能收什么、能发什么、有哪些工具)
html += '' + __("通道拓扑", "Channel topology") + "
";
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 =
diff --git a/internal/plugins/webui/handler_agents.go b/internal/plugins/webui/handler_agents.go
index 192a3bd..f676f26 100644
--- a/internal/plugins/webui/handler_agents.go
+++ b/internal/plugins/webui/handler_agents.go
@@ -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,
})
}
diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go
index 2fad3e7..e694e8a 100644
--- a/internal/plugins/webui/handler_test.go
+++ b/internal/plugins/webui/handler_test.go
@@ -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 的内容)")
diff --git a/internal/sdk/status.go b/internal/sdk/status.go
index 8737ab2..79d31f5 100644
--- a/internal/sdk/status.go
+++ b/internal/sdk/status.go
@@ -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"`