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 ( + '