From 1ce3a917a5ac397629ea417479508bbf743385e5 Mon Sep 17 00:00:00 2001 From: HomeAgent Agent Date: Mon, 14 Sep 2026 09:05:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(status+webui):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=80=81=E5=9B=BE=E5=BD=A2=E5=8C=96=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=8E=92=E9=98=9F/=E5=9B=9B=E7=BA=A7=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E9=98=9F=E5=88=97/=E4=B8=AD=E6=96=AD=E6=A0=88/=E9=A9=BB?= =?UTF-8?q?=E7=95=99=E5=AD=90/=E9=80=9A=E9=81=93=E6=8B=93=E6=89=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断 排队与中断栈、驻留子 agent 数量;这些要向**内部 SDK 暴露接口**,供 WebUI 等应用 展示;通道划分也要能画出来。 ## 一、内核状态面(internal/sdk,内部 SDK,不受公开 SDK 冻结约束) * SchedulerStatus 补: - interrupt_queues[5]:**四级中断队列各自的深度**(下标即级别 1..4,下标 0 恒 0, 这样 level 能直接当数组下标用)。此前只有 pending_interrupts 总数, 看不出"堵在 L1 还是 L4"——四级是抢占优先级,堵在哪级是完全不同的运行状态。 - immediate:刚抢占成功、下一个安全点立即运行的那个中断(此前完全不可见)。 - suspend_frames:中断栈的帧(栈底→栈顶,只给任务标识),depth 之外还能看出 "谁被谁打断"。 - interrupts_by_level / preempts_by_level:各级累计登记数与抢占成功数。 * KernelStatus 补 residents(驻留子运行时视图:状态/轮次/上下文已满/输入通道/允许输出)。 刻意**不带**每个驻留子的 inputch 登记明细——状态面会被反复轮询,明细会让 每次 /status 背上几十 KB;只给表大小,要明细走专门接口。 * ChannelInfo 补 direction(in/out/io)、description、tools、output_caps、caps_text。 此前 collectKernelStatus 只透传 Name/Type,把描述/工具/能力**全丢了**, 前端只能画出一排光秃秃的名字。 ## 二、WebUI * 新增只读 `/api/v1/runtime`:只回运行态三件事(scheduler/residents/channels), 实测 **1.0KB**(/kernel 是 30KB 级)——所以能 3 秒轮询做"实时"感, 而不必反复拉全量状态。 * 首页新增「运行态」面板(纯 CSS + 内联 SVG,前端仍无构建链): - 四个数字块:排队任务 / 待处理中断 / 中断栈(深度/上限) / 驻留子 Agent,带占比条; - 四级中断队列条形图:每级"深度 · 登记/抢占",四级语义**照抄内核** (L4 内核独占 / L3 交互 / L2 消息 / L1 后台),不自己起名字; - 中断栈层叠图(栈顶在上)+ ⚡立即运行项; - 通道拓扑:输入通道 → 内核 → 输出通道,双向通道两侧都出现,能力以胶囊标签显示。 * 3 秒轮询只在总览页可见时才发请求;切回总览时 renderAll 会立刻补一次。 ## 验证 * 新增 TestSchedulerStatusExposesLevelsAndStack(四级队列/立即项/栈帧/各级计数映射, 并断言"未使用的级别必须为 0"与"下标 0 恒 0")、TestChannelInfoCarriesTopology、 TestRuntimeEndpoint(形状 + 不携带 tools/plugins + 无状态源时 503)。 * go build / vet / agent+core+sdk+plugin+webui 全量测试绿。 * 真实浏览器实测(CDP 驱动,注入运行态样本走真实渲染路径): 数字块 [3, 5, 2/4, 2];四级条 L4=1/L3=2/L2=1/L1=1 与数据一致; 栈帧按"栈底→栈顶"渲染且标出栈顶;通道左右分列、io 通道两侧都出现。 --- internal/agent/core/scheduler.go | 22 +++ .../agent/core/scheduler_critical_test.go | 93 +++++++++ internal/agent/core/status.go | 57 +++++- internal/plugins/webui/dashboard.css | 187 ++++++++++++++++++ internal/plugins/webui/dashboard.js | 172 ++++++++++++++++ internal/plugins/webui/handler.go | 3 + internal/plugins/webui/handler_agents.go | 29 +++ internal/plugins/webui/handler_test.go | 72 +++++++ internal/sdk/status.go | 56 +++++- 9 files changed, 685 insertions(+), 6 deletions(-) diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index e899636..8f1988e 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -279,6 +279,8 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus { PendingInterrupts: len(snap.PendingInterrupts), SuspendStack: len(snap.SuspendStack), MaxSuspendDepth: snap.MaxInterruptFrames, + InterruptsByLevel: snap.Stats.InterruptsByLevel, + PreemptsByLevel: snap.Stats.PreemptsByLevel, Enqueued: snap.Stats.Enqueued, Executed: snap.Stats.Executed, Rejected: snap.Stats.Rejected, @@ -291,6 +293,26 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus { ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(), } } + if snap.Immediate != nil { + out.Immediate = &sdk.SchedulerTask{ + ID: snap.Immediate.ID, Level: int(snap.Immediate.Level), Kind: snap.Immediate.Kind.String(), + } + } + // 四级队列深度:下标即级别(1..4),下标 0 留 0。 + for lv := LevelBackground; lv <= LevelCritical; lv++ { + out.InterruptQueues[lv] = len(snap.InterruptQueues[lv]) + } + // 中断栈帧:栈底 → 栈顶(谁先被压进去、谁又打断了它)。 + for _, f := range snap.SuspendStack { + if f == nil || f.Task == nil { + continue + } + out.SuspendFrames = append(out.SuspendFrames, sdk.SchedulerFrame{ + Task: sdk.SchedulerTask{ + ID: f.Task.ID, Level: int(f.Task.Level), Kind: f.Task.Kind.String(), + }, + }) + } return out } diff --git a/internal/agent/core/scheduler_critical_test.go b/internal/agent/core/scheduler_critical_test.go index 13a6ca9..5aade55 100644 --- a/internal/agent/core/scheduler_critical_test.go +++ b/internal/agent/core/scheduler_critical_test.go @@ -165,3 +165,96 @@ func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) { t.Fatalf("Executed=%d,期望 1", snap.Stats.Executed) } } + +// TestSchedulerStatusExposesLevelsAndStack 钉住调度器状态面**按级别可读**: +// 四级队列深度、各级累计计数、立即抢占项、中断栈帧。 +// +// 起因:此前 SchedulerStatus 只给 total(pending_interrupts / suspend_stack), +// 看不出"堵在 L1 还是 L4"、"栈里压的是谁"。WebUI 想画运行态就无数据可用。 +// +// 这个用例直接摆好调度器内部状态、只验**映射**(同包白盒):调度行为本身 +// 由 scheduler_e2e_test / scheduler_critical_test 覆盖,这里不该重复它们的时序。 +func TestSchedulerStatusExposesLevelsAndStack(t *testing.T) { + a := New(AgentConfig{ID: "rt", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()}) + if a.sched == nil { + t.Fatal("agent 应带调度器") + } + a.sched.mu.Lock() + a.sched.queue = append(a.sched.queue, &Task{ID: 1, Class: TaskQueued, Kind: TaskKindInput}) + a.sched.interruptQueues[LevelCritical] = append(a.sched.interruptQueues[LevelCritical], + &Task{ID: 2, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput}, + &Task{ID: 3, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput}) + a.sched.interruptQueues[LevelBackground] = append(a.sched.interruptQueues[LevelBackground], + &Task{ID: 4, Class: TaskInterrupt, Level: LevelBackground, Kind: TaskKindSelf}) + a.sched.immediate = &Task{ID: 5, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput} + a.sched.suspendStack = append(a.sched.suspendStack, &suspendedTask{ + Task: &Task{ID: 6, Class: TaskInterrupt, Level: LevelInteractive, Kind: TaskKindInput}, + Frame: &TaskFrame{}, + }) + a.sched.stats.InterruptsByLevel[LevelCritical] = 7 + a.sched.stats.PreemptsByLevel[LevelCritical] = 3 + a.sched.mu.Unlock() + + got := a.schedulerStatus() + if got.ReadyQueueDepth != 1 { + t.Fatalf("ready_queue_depth 应为 1,实际 %d", got.ReadyQueueDepth) + } + // 按级别的队列深度:下标即级别,下标 0 恒为 0 + if got.InterruptQueues[LevelCritical] != 2 || got.InterruptQueues[LevelBackground] != 1 { + t.Fatalf("按级别队列深度不对:%v", got.InterruptQueues) + } + if got.InterruptQueues[0] != 0 { + t.Fatalf("下标 0(无级别)必须恒为 0,实际 %v", got.InterruptQueues) + } + // pending = 两条队列 + immediate + if got.PendingInterrupts != 4 { + t.Fatalf("pending_interrupts 应为 4,实际 %d", got.PendingInterrupts) + } + // 立即抢占项要能单独看到 + if got.Immediate == nil || got.Immediate.ID != 5 || got.Immediate.Level != int(LevelCritical) { + t.Fatalf("immediate 未正确映射:%+v", got.Immediate) + } + // 中断栈帧(栈底→栈顶)要带任务标识 + if len(got.SuspendFrames) != 1 || got.SuspendFrames[0].Task.ID != 6 || + got.SuspendFrames[0].Task.Level != int(LevelInteractive) { + t.Fatalf("中断栈帧未正确映射:%+v", got.SuspendFrames) + } + // 累计计数按级别透传 + if got.InterruptsByLevel[LevelCritical] != 7 || got.PreemptsByLevel[LevelCritical] != 3 { + t.Fatalf("各级计数未透传:interrupts=%v preempts=%v", got.InterruptsByLevel, got.PreemptsByLevel) + } + // 未使用的级别必须是 0(不能把别的级别串进来) + if got.InterruptQueues[LevelMessage] != 0 || got.InterruptQueues[LevelInteractive] != 0 { + t.Fatalf("未使用的级别应为 0:%v", got.InterruptQueues) + } +} + +// TestChannelInfoCarriesTopology 钉住通道状态面不再丢信息: +// 方向、描述、工具名、输出能力都要能被前端画出来。 +func TestChannelInfoCarriesTopology(t *testing.T) { + ch := channelInfoFromIO(agentIO.ChannelInfo{ + Name: "webui", + Type: agentIO.DeviceOutput, + Description: "Web 控制台", + Tools: []agentIO.ToolDef{{Name: "output_send"}}, + OutputCaps: agentIO.CapText | agentIO.CapImage, + }) + if ch.Direction != "out" { + t.Fatalf("方向应为 out,实际 %q", ch.Direction) + } + if ch.Description != "Web 控制台" { + t.Fatalf("描述丢失:%q", ch.Description) + } + if len(ch.Tools) != 1 || ch.Tools[0] != "output_send" { + t.Fatalf("工具名丢失:%v", ch.Tools) + } + if ch.OutputCaps != int(agentIO.CapText|agentIO.CapImage) || ch.CapsText == "" { + t.Fatalf("输出能力丢失:caps=%d text=%q", ch.OutputCaps, ch.CapsText) + } + if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "mic", Type: agentIO.DeviceInput}).Direction; d != "in" { + t.Fatalf("输入通道方向应为 in,实际 %q", d) + } + if d := channelInfoFromIO(agentIO.ChannelInfo{Name: "both", Type: agentIO.DeviceIO}).Direction; d != "io" { + t.Fatalf("双向通道方向应为 io,实际 %q", d) + } +} diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index 0b36fa1..1d03079 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -25,6 +25,9 @@ type StatusProvider interface { type KernelStatus = sdk.KernelStatus type PluginInfo = sdk.PluginInfo type ChannelInfo = sdk.ChannelInfo + +// ResidentStatus 是驻留式子 agent 的运行时视图(见 internal/sdk/status.go)。 +type ResidentStatus = sdk.ResidentStatus type MemoryStatus = sdk.MemoryStatus type KnowledgeStatus = sdk.KnowledgeStatus type DocumentStatus = sdk.DocumentStatus @@ -37,10 +40,32 @@ type TrackerStatus = sdk.TrackerStatus type BuildStatus = sdk.BuildStatus func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo { + tools := make([]string, 0, len(ch.Tools)) + for _, t := range ch.Tools { + tools = append(tools, t.Name) + } return ChannelInfo{ - Name: ch.Name, - Type: fmt.Sprintf("%d", ch.Type), - Ready: true, + Name: ch.Name, + Type: fmt.Sprintf("%d", ch.Type), + Direction: channelDirection(ch.Type), + Ready: true, + Description: ch.Description, + Tools: tools, + OutputCaps: int(ch.OutputCaps), + CapsText: ch.OutputCaps.String(), + } +} + +// channelDirection 把 DeviceType 翻成可读方向(前端画拓扑用)。 +// 未知取值落到 "io":宁可当作双向,也不要把它画成只进或只出。 +func channelDirection(t agentIO.DeviceType) string { + switch t { + case agentIO.DeviceInput: + return "in" + case agentIO.DeviceOutput: + return "out" + default: + return "io" } } @@ -221,6 +246,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus { ) ks.ONNX = a.onnxStatus() ks.Scheduler = a.schedulerStatus() + ks.Residents = residentStatuses(a.Residents()) return ks } @@ -254,3 +280,28 @@ func (a *Agent) onnxStatus() sdk.ONNXStatus { var _ StatusProvider = (*Agent)(nil) var _ sdk.StatusAPI = (*Agent)(nil) + +// residentStatuses 把内核的驻留子视图映射成 SDK DTO。 +// +// 刻意**不带** ResidentInfo.Table(那是每个驻留子的 inputch 登记明细):状态面 +// 是给所有应用轮询的,把它塞进来会让每次 /status 都背上几十 KB。 +// 只给表的大小(InputChTable),要看明细走专门的接口。 +func residentStatuses(list []ResidentInfo) []ResidentStatus { + out := make([]ResidentStatus, 0, len(list)) + for _, r := range list { + st := ResidentStatus{ + ID: r.ID, + State: r.State, + Rounds: r.Rounds, + ContextFull: r.ContextFull, + InputChs: r.InputChs, + AllowedOutputs: r.AllowedOutputs, + InputChTable: r.TableSize, + } + if !r.CreatedAt.IsZero() { + st.CreatedAt = r.CreatedAt.Format(time.RFC3339) + } + out = append(out, st) + } + return out +} diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 38cb0e4..ac56f9c 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -2097,3 +2097,190 @@ scrollbar-width: thin; scrollbar-color: rgba(255, 127, 172, 0.35) transparent; } + + /* ===== 运行态面板(图形化总览)===== + 目标是"一眼看懂内核现在在干什么":排队/中断/中断栈/驻留子四个数字, + 四级中断队列的条形,中断栈的层叠图,以及通道拓扑。全部用纯 CSS + 内联 SVG, + 不引入任何依赖(前端刻意没有构建链)。 */ + .rt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; + margin: 4px 0 14px; + } + .rt-tile { + position: relative; + padding: 12px 14px 10px; + border-radius: var(--radius-md); + background: var(--bg-input); + border: 1px solid var(--border-color); + overflow: hidden; + } + .rt-tile .rt-num { + font-size: 28px; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.5px; + } + .rt-tile .rt-label { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + } + .rt-tile .rt-sub { + font-size: 10px; + color: var(--text-muted); + opacity: 0.85; + } + .rt-tile.rt-active { + border-color: var(--sakura-400); + box-shadow: var(--shadow-glow); + } + .rt-tile.rt-warn .rt-num { + color: #ffb86b; + } + /* 底部细条:把"占了多少"用长度表达,比纯数字更快读懂 */ + .rt-bar { + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + margin-top: 8px; + overflow: hidden; + } + .rt-bar > i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--sakura-400), var(--frost-300)); + transition: width 0.35s var(--ease-out); + } + .rt-levels { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 6px; + } + .rt-level { + display: grid; + grid-template-columns: 74px 1fr 96px; + align-items: center; + gap: 8px; + font-size: 11px; + } + .rt-level .rt-lv-name { + 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; + display: flex; + } + .rt-level .rt-lv-track > i { + display: block; + height: 100%; + transition: width 0.35s var(--ease-out); + } + .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); } + /* 中断栈:栈顶在上,做出"层叠现场"的观感 */ + .rt-stack { + display: flex; + flex-direction: column-reverse; + gap: 4px; + margin: 2px 0 6px; + } + .rt-frame { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 9px; + border-radius: 8px; + font-size: 11px; + background: rgba(255, 255, 255, 0.05); + border-left: 3px solid var(--sakura-400); + } + .rt-frame .rt-frame-top { + margin-left: auto; + font-size: 10px; + color: var(--text-muted); + } + .rt-empty { + font-size: 11px; + color: var(--text-muted); + padding: 4px 0; + } + /* 通道拓扑:输入 → 内核 → 输出 */ + .rt-topo { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 10px; + align-items: center; + margin-top: 6px; + } + .rt-topo-col { + display: flex; + flex-direction: column; + gap: 6px; + } + .rt-topo-col.rt-right { + text-align: right; + } + .rt-chan { + padding: 6px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color); + font-size: 11px; + cursor: default; + } + .rt-chan .rt-chan-name { + font-weight: 600; + } + .rt-chan .rt-chan-sub { + font-size: 10px; + color: var(--text-muted); + } + .rt-chan .rt-caps { + display: inline-flex; + gap: 3px; + margin-left: 6px; + vertical-align: middle; + } + .rt-cap { + font-size: 9px; + padding: 1px 5px; + border-radius: 999px; + background: rgba(136, 192, 208, 0.16); + color: var(--frost-300); + } + .rt-core { + width: 74px; + height: 74px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + text-align: center; + line-height: 1.2; + background: radial-gradient(circle at 50% 40%, rgba(243, 59, 124, 0.35), rgba(24, 24, 38, 0.9)); + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-glow); + } + .rt-section-title { + font-size: 11px; + color: var(--text-muted); + letter-spacing: 0.4px; + margin: 12px 0 6px; + text-transform: uppercase; + } diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index fb87d6f..99d77e8 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -4,6 +4,7 @@ kernel: null, settings: {}, meta: {}, + runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道) pluginMeta: {}, disabledPlugins: [], settingsPlugins: ["core"], @@ -464,6 +465,11 @@ } catch (e) { console.error("renderOverview", e); } + try { + await loadRuntime(); + } catch (e) { + console.error("loadRuntime", e); + } try { renderChat(); } catch (e) { @@ -559,10 +565,175 @@ // ===== Overview ===== + // ===== 运行态面板:把内核的调度器/驻留子/通道画出来 ===== + // + // 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), + // 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 + // + // 四个数字块回答"现在忙不忙":排队任务、待处理中断、中断栈深度、驻留子数量; + // 下面的四级条形回答"堵在哪一级";中断栈图回答"谁打断了谁"; + // 通道拓扑回答"消息从哪儿进、能往哪儿出"。 + // 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), + // 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 + var RT_LEVELS = [ + { lv: 4, name: "L4 内核独占", cls: "rt-lv-4" }, + { lv: 3, name: "L3 交互", cls: "rt-lv-3" }, + { lv: 2, name: "L2 消息", cls: "rt-lv-2" }, + { lv: 1, name: "L1 后台", cls: "rt-lv-1" }, + ]; + var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" }; + + function rtTile(num, label, sub, pct, active, warn) { + return ( + '
' + + num + + '
' + + label + + "
" + + (sub ? '
' + sub + "
" : "") + + '
' + ); + } + + function rtCapsHtml(caps) { + var out = ""; + Object.keys(RT_CAP_NAMES).forEach(function (bit) { + if (caps & Number(bit)) { + out += '' + RT_CAP_NAMES[bit] + ""; + } + }); + return out ? '' + out + "" : ""; + } + + function renderRuntime() { + var el = document.getElementById("rt-panel"); + if (!el) return; + var rt = state.runtime; + if (!rt) { + el.innerHTML = '
' + __("运行态数据不可用", "runtime unavailable") + "
"; + return; + } + var sc = rt.scheduler || {}; + var residents = rt.residents || []; + var channels = rt.channels || []; + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var pending = sc.pending_interrupts || 0; + var ready = sc.ready_queue_depth || 0; + var stack = sc.suspend_stack || 0; + var maxStack = sc.max_suspend_depth || 4; + var frames = sc.suspend_frames || []; + var byLv = sc.interrupts_by_level || []; + var preLv = sc.preempts_by_level || []; + + var html = '

' + __("运行态", "Runtime") + "

"; + // 四个数字块 + html += '
'; + html += rtTile(ready, __("排队任务", "Ready queue"), __("等待执行的输入", "inputs waiting"), ready ? Math.min(100, ready * 20) : 0, ready > 0); + html += rtTile(pending, __("待处理中断", "Pending interrupts"), __("四级队列 + 立即抢占", "queued + immediate"), pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0); + html += rtTile(stack + "/" + maxStack, __("中断栈", "Interrupt stack"), __("嵌套抢占的现场", "nested frames"), (stack / (maxStack || 4)) * 100, stack > 0); + var rFull = residents.filter(function (r) { return r.context_full; }).length; + html += rtTile(residents.length, __("驻留子 Agent", "Resident agents"), rFull ? rFull + __(" 个上下文已满", " context-full") : __("常驻子任务", "long-lived children"), residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0); + html += "
"; + + // 四级中断队列 + html += '
' + __("中断队列(按级别)", "Interrupt queues by level") + "
"; + var maxQ = Math.max(1, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); + html += '
'; + RT_LEVELS.forEach(function (L) { + var depth = q[L.lv] || 0; + var reg = byLv[L.lv] || 0; + var pre = preLv[L.lv] || 0; + html += + '
' + L.name + "" + + '' + + '' + depth + " · " + + __("登记", "reg") + " " + reg + " / " + __("抢占", "pre") + " " + pre + + "
"; + }); + html += "
"; + if (sc.immediate) { + html += '
⚡ ' + __("立即运行", "immediate") + ":" + + escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id + + 'L' + (sc.immediate.level || 0) + "
"; + } + + // 中断栈(栈顶在上 → 用 column-reverse) + html += '
' + __("中断栈(栈顶在上)", "Interrupt stack (top first)") + "
"; + if (frames.length) { + html += '
'; + frames.forEach(function (f, i) { + var t = (f && f.task) || {}; + html += '
' + escHtml(t.kind || "") + " #" + (t.id || "?") + + 'L' + (t.level || 0) + + (i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + "
"; + }); + html += "
"; + } else { + html += '
' + __("中断栈为空(当前无被抢占的现场)", "stack empty (nothing preempted)") + "
"; + } + if (sc.running) { + html += '
' + __("正在运行", "running") + ":" + + escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")
"; + } + + // 通道拓扑 + 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"; }); + function chanHtml(c) { + return '
' + + '' + escHtml(c.name) + "" + + rtCapsHtml(c.output_caps || 0) + + '
' + + escHtml((c.tools || []).length ? (c.tools || []).length + " " + __("个工具", "tools") : (c.description || "").slice(0, 26)) + + "
"; + } + html += '
'; + html += '
' + (ins.length ? ins.map(chanHtml).join("") : '
' + __("无输入通道", "no input channel") + "
") + "
"; + html += '
' + __("内核", "Kernel") + "
"; + html += '
' + (outs.length ? outs.map(chanHtml).join("") : '
' + __("无输出通道", "no output channel") + "
") + "
"; + html += "
"; + + html += "
"; + el.innerHTML = html; + } + + + // loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 + async function loadRuntime() { + try { + var rt = await api("/runtime"); + state.runtime = rt; + renderRuntime(); + } catch (e) { + state.runtime = null; + } + } + + // startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义, + // 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。 + function startRuntimeTicker() { + if (state._runtimeTicker) return; + state._runtimeTicker = setInterval(function () { + var tab = document.querySelector("#tab-overview"); + if (tab && tab.classList.contains("active")) { + loadRuntime(); + } + }, 3000); + } + function renderOverview() { var s = state.status || {}; var k = state.kernel; var html = + '
' + '

' + __("系统概览", "System Overview") + "

" + @@ -4404,6 +4575,7 @@ renderAll(); connectSSE(); startUptimeTicker(); + startRuntimeTicker(); maybeShowPersonaWizard(); })(); setInterval(renderAll, 15000); diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 51315b9..0df10e2 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -437,6 +437,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals)) mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory)) mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) + // 运行态小快照:调度器(排队/四级中断队列/中断栈)+ 驻留子 + 通道拓扑。 + // 单独一条是为了让前端能秒级刷新而不必反复拉 30KB 的 /kernel。 + mux.HandleFunc("/api/v1/runtime", h.requireAPI(h.handleRuntime)) mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona)) mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) diff --git a/internal/plugins/webui/handler_agents.go b/internal/plugins/webui/handler_agents.go index 85e129d..192a3bd 100644 --- a/internal/plugins/webui/handler_agents.go +++ b/internal/plugins/webui/handler_agents.go @@ -238,3 +238,32 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen } writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)}) } + +// handleRuntime 返回**只含运行态**的小快照:调度器(排队/四级中断队列/中断栈)、 +// 驻留子 agent、通道拓扑。 +// +// 为什么不复用 /api/v1/kernel:那份是 30KB 级的全量状态(工具、插件、记忆、LLM…), +// 拿它做秒级刷新既费带宽也费端上算力。运行态要能「实时看」,所以单开一条 +// 只读通道,字段直接来自内核暴露的 KernelStatus(internal/sdk), +// 这样任何应用(WebUI / GUI / ArkTS)都能拿同一份数据做展示。 +func (h *Handler) handleRuntime(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.status == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status not available"}) + return + } + ks := h.status.GetKernelStatus() + if ks == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status not available"}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "uptime": ks.Uptime, + "scheduler": ks.Scheduler, + "residents": ks.Residents, + "channels": ks.Channels, + }) +} diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 3b75402..2fad3e7 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -1387,3 +1387,75 @@ func TestChatHistoryDefaultIsPaged(t *testing.T) { t.Fatalf("limit=5 应回 5 条,实际 %d", n) } } + +// fakeStatus 是给 /runtime 用的最小内核状态桩。 +type fakeStatus struct{ ks *sdk.KernelStatus } + +func (f fakeStatus) GetKernelStatus() *sdk.KernelStatus { return f.ks } + +// TestRuntimeEndpoint 钉住运行态小接口的形状: +// 只回运行态三件事(调度器/驻留子/通道),且体积远小于 /kernel —— +// 前端靠它做秒级刷新,字段一丢图就画不出来。 +func TestRuntimeEndpoint(t *testing.T) { + ks := &sdk.KernelStatus{ + Uptime: "1m", + Scheduler: sdk.SchedulerStatus{ + ReadyQueueDepth: 2, + PendingInterrupts: 1, + SuspendStack: 1, + MaxSuspendDepth: 4, + InterruptQueues: [5]int{0, 0, 0, 0, 1}, + SuspendFrames: []sdk.SchedulerFrame{{Task: sdk.SchedulerTask{ID: 9, Level: 3, Kind: "input"}}}, + }, + 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]"}}, + } + s := testSDK(sdk.SDKConfig{ + Settings: sdk.NewSettings("webui", internalConfig.NewConfigRegistry("")), + Config: sdk.NewConfig(&types.Config{}), + Status: fakeStatus{ks: ks}, + }) + h := NewHandler(s) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/runtime", nil) + w := httptest.NewRecorder() + h.handleRuntime(w, req) + if w.Code != http.StatusOK { + 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"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Scheduler.ReadyQueueDepth != 2 || out.Scheduler.PendingInterrupts != 1 { + t.Fatalf("调度器字段丢失:%+v", out.Scheduler) + } + if out.Scheduler.InterruptQueues[4] != 1 { + t.Fatalf("四级队列深度丢失:%v", out.Scheduler.InterruptQueues) + } + if len(out.Scheduler.SuspendFrames) != 1 || out.Scheduler.SuspendFrames[0].Task.ID != 9 { + t.Fatalf("中断栈帧丢失:%+v", out.Scheduler.SuspendFrames) + } + if len(out.Residents) != 1 || out.Residents[0].ID != "r1" { + t.Fatalf("驻留子丢失:%+v", out.Residents) + } + if len(out.Channels) != 1 || out.Channels[0].Direction != "out" { + t.Fatalf("通道方向丢失:%+v", out.Channels) + } + // 只回运行态:不该把 tools/plugins 这类大块带上 + if strings.Contains(w.Body.String(), "\"tools\"") || strings.Contains(w.Body.String(), "\"plugins\"") { + t.Fatal("/runtime 不应携带 tools/plugins(那是 /kernel 的内容)") + } + // 没有内核状态时给 503,而不是空对象 + s2 := testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", internalConfig.NewConfigRegistry(""))}) + h2 := NewHandler(s2) + w2 := httptest.NewRecorder() + h2.handleRuntime(w2, httptest.NewRequest(http.MethodGet, "/api/v1/runtime", nil)) + if w2.Code != http.StatusServiceUnavailable { + t.Fatalf("无状态源应回 503,实际 %d", w2.Code) + } +} diff --git a/internal/sdk/status.go b/internal/sdk/status.go index eb71e4f..3f5044a 100644 --- a/internal/sdk/status.go +++ b/internal/sdk/status.go @@ -47,6 +47,9 @@ type KernelStatus struct { Tracker TrackerStatus `json:"tracker"` + // Residents 是驻留式子 agent 的运行时视图(数量 = len(Residents))。 + Residents []ResidentStatus `json:"residents"` + // Scheduler 是输入调度器的运行时快照(可观测性,设计文档 §11 O1/O2)。 // M2 起输入不再直接排队在 channel 上,而是经 readyQueue/pendingInterrupts/ // suspendStack 三集合按优先级调度;这里把这些状态暴露出来。 @@ -57,12 +60,30 @@ type KernelStatus struct { type SchedulerStatus struct { // Running 是当前执行的任务(空表示空闲)。 Running *SchedulerTask `json:"running,omitempty"` + // Immediate 是刚抢占成功、将在下一个安全点立即运行的中断(最多一个)。 + Immediate *SchedulerTask `json:"immediate,omitempty"` // ReadyQueueDepth / PendingInterrupts / SuspendStack 是三个集合的深度。 ReadyQueueDepth int `json:"ready_queue_depth"` PendingInterrupts int `json:"pending_interrupts"` SuspendStack int `json:"suspend_stack"` MaxSuspendDepth int `json:"max_suspend_depth"` + // InterruptQueues 是**四条中断队列各自的深度**,下标即中断级别(1..4); + // 下标 0 恒为 0,这样 level 可以直接当数组下标用,省掉调用方 ±1 的翻译。 + // + // 为什么单列:PendingInterrupts 只是总数,看不清"堵在哪一级"—— + // 四级中断是抢占优先级,堵在 L1 还是 L4 是完全不同的运行状态。 + InterruptQueues [5]int `json:"interrupt_queues"` + + // SuspendFrames 是中断栈的帧,**栈底 → 栈顶**(只暴露任务标识,不含帧内容)。 + // 深度见 SuspendStack;帧的顺序回答了"谁被谁打断"。 + SuspendFrames []SchedulerFrame `json:"suspend_frames,omitempty"` + + // InterruptsByLevel / PreemptsByLevel 是各级中断的累计计数(下标 1..4): + // 前者=被登记次数(含没抢成的),后者=判定可抢占并进入 immediate 的次数。 + InterruptsByLevel [5]uint64 `json:"interrupts_by_level"` + PreemptsByLevel [5]uint64 `json:"preempts_by_level"` + Enqueued uint64 `json:"enqueued"` Executed uint64 `json:"executed"` Rejected uint64 `json:"rejected"` @@ -71,6 +92,26 @@ type SchedulerStatus struct { Preempted uint64 `json:"preempted"` } +// SchedulerFrame 是中断栈里的一帧(供图形化展示"压了几层现场")。 +type SchedulerFrame struct { + Task SchedulerTask `json:"task"` +} + +// ResidentStatus 是驻留式子 agent 的运行时视图。 +// +// 为什么进状态面:驻留子是"常驻的独立 agent",它们的数量、轮次与上下文占用 +// 是运行态里最需要一眼看到的东西(此前只在日志里,WebUI 只能显示文字)。 +type ResidentStatus struct { + ID string `json:"id"` + State string `json:"state"` + Rounds int `json:"rounds"` + ContextFull bool `json:"context_full"` + InputChs []string `json:"input_channels,omitempty"` + AllowedOutputs []string `json:"allowed_outputs,omitempty"` + InputChTable int `json:"input_ch_table"` + CreatedAt string `json:"created_at,omitempty"` +} + // SchedulerTask 是任务的最小标识(不暴露帧内容)。 type SchedulerTask struct { ID uint64 `json:"id"` @@ -97,9 +138,18 @@ type PluginInfo struct { } type ChannelInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Ready bool `json:"ready"` + Name string `json:"name"` + // Type 保持为 DeviceType 的数字字符串(历史字段,别改语义)。 + Type string `json:"type"` + // Direction 是方向的可读名:in(只进)/ out(只出)/ io(双向)。 + Direction string `json:"direction"` + Ready bool `json:"ready"` + // 以下三项供通道拓扑展示:此前 collectKernelStatus 只透传了 Name/Type, + // 把 Description/Tools/OutputCaps 全丢了,前端只能画出一排光秃秃的名字。 + Description string `json:"description,omitempty"` + Tools []string `json:"tools,omitempty"` + OutputCaps int `json:"output_caps"` + CapsText string `json:"caps_text,omitempty"` } type MemoryStatus struct {