mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user