From 43536ba32632114ace5aa87cc76f6df8a06874d6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 10:55:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E8=BF=90=E8=A1=8C=E6=80=81?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=B8=8D=E5=86=8D=E9=97=AA=E3=80=81=E9=80=9A?= =?UTF-8?q?=E9=81=93=E5=88=86=E9=85=8D=E5=B8=A6=E5=BD=92=E5=B1=9E=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E9=A9=BB=E7=95=99=E5=AD=90=EF=BC=89=E3=80=81=E6=94=B9?= =?UTF-8?q?=E5=9B=BE=E5=BD=A2=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个用户可见问题,逐个说明根因与改法。 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 的归属与「驻留子划走的那条」。 --- internal/agent/core/status.go | 15 +++ internal/plugins/webui/dashboard.css | 129 +++++++++++++++++++++++ internal/plugins/webui/dashboard.js | 128 +++++++++++++++++++++- internal/plugins/webui/handler_agents.go | 3 + internal/plugins/webui/handler_test.go | 33 +++++- internal/sdk/status.go | 23 ++++ 6 files changed, 326 insertions(+), 5 deletions(-) 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"`