feat(status+webui): 运行态图形化 —— 排队/四级中断队列/中断栈/驻留子/通道拓扑

需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 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 通道两侧都出现。
This commit is contained in:
HomeAgent Agent
2026-09-14 09:05:14 +08:00
parent c0e9dc1818
commit 1ce3a917a5
9 changed files with 685 additions and 6 deletions

View File

@ -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
}

View File

@ -165,3 +165,96 @@ func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
t.Fatalf("Executed=%d期望 1", snap.Stats.Executed)
}
}
// TestSchedulerStatusExposesLevelsAndStack 钉住调度器状态面**按级别可读**
// 四级队列深度、各级累计计数、立即抢占项、中断栈帧。
//
// 起因:此前 SchedulerStatus 只给 totalpending_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)
}
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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 (
'<div class="rt-tile' +
(active ? " rt-active" : "") +
(warn ? " rt-warn" : "") +
'"><div class="rt-num">' +
num +
'</div><div class="rt-label">' +
label +
"</div>" +
(sub ? '<div class="rt-sub">' + sub + "</div>" : "") +
'<div class="rt-bar"><i style="width:' +
Math.max(0, Math.min(100, pct || 0)) +
'%"></i></div></div>'
);
}
function rtCapsHtml(caps) {
var out = "";
Object.keys(RT_CAP_NAMES).forEach(function (bit) {
if (caps & Number(bit)) {
out += '<span class="rt-cap">' + RT_CAP_NAMES[bit] + "</span>";
}
});
return out ? '<span class="rt-caps">' + out + "</span>" : "";
}
function renderRuntime() {
var el = document.getElementById("rt-panel");
if (!el) return;
var rt = state.runtime;
if (!rt) {
el.innerHTML = '<div class="rt-empty">' + __("运行态数据不可用", "runtime unavailable") + "</div>";
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 = '<div class="card"><h2>' + __("运行态", "Runtime") + "</h2>";
// 四个数字块
html += '<div class="rt-grid">';
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 += "</div>";
// 四级中断队列
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);
html += '<div class="rt-levels">';
RT_LEVELS.forEach(function (L) {
var depth = q[L.lv] || 0;
var reg = byLv[L.lv] || 0;
var pre = preLv[L.lv] || 0;
html +=
'<div class="rt-level"><span class="rt-lv-name">' + L.name + "</span>" +
'<span class="rt-lv-track ' + L.cls + '"><i style="width:' +
(depth ? Math.max(4, (depth / maxQ) * 100) : 0) +
'%"></i></span>' +
'<span class="rt-lv-meta">' + depth + " · " +
__("登记", "reg") + " " + reg + " / " + __("抢占", "pre") + " " + pre +
"</span></div>";
});
html += "</div>";
if (sc.immediate) {
html += '<div class="rt-frame">⚡ ' + __("立即运行", "immediate") + "" +
escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id +
'<span class="rt-frame-top">L' + (sc.immediate.level || 0) + "</span></div>";
}
// 中断栈(栈顶在上 → 用 column-reverse
html += '<div class="rt-section-title">' + __("中断栈(栈顶在上)", "Interrupt stack (top first)") + "</div>";
if (frames.length) {
html += '<div class="rt-stack">';
frames.forEach(function (f, i) {
var t = (f && f.task) || {};
html += '<div class="rt-frame">' + escHtml(t.kind || "") + " #" + (t.id || "?") +
'<span class="rt-frame-top">L' + (t.level || 0) +
(i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + "</span></div>";
});
html += "</div>";
} else {
html += '<div class="rt-empty">' + __("中断栈为空(当前无被抢占的现场)", "stack empty (nothing preempted)") + "</div>";
}
if (sc.running) {
html += '<div class="rt-empty">' + __("正在运行", "running") + "" +
escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")</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"; });
function chanHtml(c) {
return '<div class="rt-chan" title="' + escHtml(c.description || "") + '">' +
'<span class="rt-chan-name">' + escHtml(c.name) + "</span>" +
rtCapsHtml(c.output_caps || 0) +
'<div class="rt-chan-sub">' +
escHtml((c.tools || []).length ? (c.tools || []).length + " " + __("个工具", "tools") : (c.description || "").slice(0, 26)) +
"</div></div>";
}
html += '<div class="rt-topo">';
html += '<div class="rt-topo-col">' + (ins.length ? ins.map(chanHtml).join("") : '<div class="rt-empty">' + __("无输入通道", "no input channel") + "</div>") + "</div>";
html += '<div class="rt-core">' + __("内核", "Kernel") + "</div>";
html += '<div class="rt-topo-col rt-right">' + (outs.length ? outs.map(chanHtml).join("") : '<div class="rt-empty">' + __("无输出通道", "no output channel") + "</div>") + "</div>";
html += "</div>";
html += "</div>";
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 =
'<div id="rt-panel"></div>' +
'<div class="card"><h2>' +
__("系统概览", "System Overview") +
"</h2>" +
@ -4404,6 +4575,7 @@
renderAll();
connectSSE();
startUptimeTicker();
startRuntimeTicker();
maybeShowPersonaWizard();
})();
setInterval(renderAll, 15000);

View File

@ -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))

View File

@ -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…
// 拿它做秒级刷新既费带宽也费端上算力。运行态要能「实时看」,所以单开一条
// 只读通道,字段直接来自内核暴露的 KernelStatusinternal/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,
})
}

View File

@ -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)
}
}

View File

@ -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 {