Files
HomeAgent/internal/agent/core/status.go
HomeAgent Agent 1ce3a917a5 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 通道两侧都出现。
2026-09-14 09:05:14 +08:00

308 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"runtime"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
)
// StatusProvider 内核状态查询接口。插件通过此接口查看内核运行动态。
type StatusProvider interface {
GetKernelStatus() *KernelStatus
}
// 状态 DTO 使用内置 SDK 的中立类型,保证与插件层解耦。
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
type TextMemoryStatus = sdk.TextMemoryStatus
type SocialStatus = sdk.SocialStatus
type LLMStatus = sdk.LLMStatus
type ContextStatus = sdk.ContextStatus
type RuntimeStatus = sdk.RuntimeStatus
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),
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"
}
}
// collectKernelStatus 聚合内核各子系统状态快照。
// 接收所有子系统引用均为可选——nil 表示不可用),返回统一的状态报告。
func collectKernelStatus(
startTime time.Time,
agentID string,
providerName string,
sourceCount int,
stageHost *StageHost,
iom *agentIO.IOManager,
pluginReg *plugin.Registry,
memDB *memory.GraphDB,
ks interface{ List() []string },
docStore *document.Store,
textMem *text.Memory,
socialStore *social.SocialStore,
trk *tracker.Tracker,
) *KernelStatus {
status := &KernelStatus{
Uptime: time.Since(startTime).Round(time.Second).String(),
StartTime: startTime.Format(time.RFC3339),
AgentID: agentID,
// 构建身份取自内核自己的 meta-ldflags 注入点),
// 而非 SDK 仓的硬编码版本。
Build: BuildStatus{
Version: meta.Version,
Commit: meta.Commit,
BuildTime: meta.BuildTime,
SDKCompatible: meta.SDKCompatibleVersion,
KernelName: meta.KernelName,
// AGPL-3.0 §13状态页向网络使用者展示取得源码的入口。
SourceURL: meta.SourceURL,
},
Runtime: RuntimeStatus{
Goroutines: runtime.NumGoroutine(),
GoVersion: runtime.Version(),
},
LLM: LLMStatus{
Available: providerName != "",
Provider: providerName,
Sources: sourceCount,
},
}
// Plugins
if pluginReg != nil {
names := pluginReg.List()
for _, n := range names {
status.Plugins = append(status.Plugins, PluginInfo{Name: n, Loaded: true})
}
}
// Tools
if stageHost != nil {
status.Tools = stageHost.GetToolDefs()
}
// Channels
if iom != nil {
for _, ch := range iom.ListChannels() {
status.Channels = append(status.Channels, channelInfoFromIO(ch))
}
}
// Graph memory
if memDB != nil {
status.Memory.Available = true
if info, err := memDB.Introspect(); err == nil {
if ec, ok := info["entity_count"].(int); ok {
status.Memory.EntityCount = ec
}
if rc, ok := info["relation_count"].(int); ok {
status.Memory.RelationCount = rc
}
if et, ok := info["entity_type_count"].(int); ok {
status.Memory.EntityTypes = et
}
}
}
// Knowledge
if ks != nil {
status.Knowledge.Available = true
status.Knowledge.Items = ks.List()
status.Knowledge.ItemCount = len(status.Knowledge.Items)
}
// Documents
if docStore != nil {
status.Documents.Available = true
stats := docStore.Stats()
if dc, ok := stats["doc_count"].(int); ok {
status.Documents.DocCount = dc
}
if vc, ok := stats["vector_count"].(int); ok {
status.Documents.VectorCount = vc
}
}
// Text memory
if textMem != nil {
status.TextMemory.Available = true
status.TextMemory.FileCount = textMem.FileCount()
}
// Social
if socialStore != nil {
status.Social.Available = true
if persons, err := socialStore.ListPersons(); err == nil {
status.Social.PersonCount = len(persons)
}
}
// Tracker
if trk != nil {
status.Tracker.Available = true
status.Tracker.Dir = trk.MergeDir()
}
// Memory
var m runtime.MemStats
runtime.ReadMemStats(&m)
status.Runtime.MemoryMB = int64(m.Alloc / 1024 / 1024)
return status
}
// GetKernelStatus 返回 Agent 驱动的内核状态快照。
func (a *Agent) GetKernelStatus() *KernelStatus {
providerName := ""
sourceCount := 0
if a.providerManager != nil {
sourceCount = len(a.providerManager.List())
}
if a.provider != nil {
providerName = a.provider.Name()
}
var textMem *text.Memory
if a.textMem != nil {
textMem = a.textMem
}
var socialStore *social.SocialStore
if a.social != nil {
socialStore = a.social
}
var trk *tracker.Tracker
if a.tracker != nil {
trk = a.tracker
}
// 注意knowledge 在 collectKernelStatus 里是**接口**参数,
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
// 所以这里必须先判具体指针再进行接口赋值healthcheck_kernel 会走到这条路径)。
var knowledgeLister interface{ List() []string }
if a.knowledge != nil {
knowledgeLister = a.knowledge
}
ks := collectKernelStatus(
a.startTime,
string(a.id),
providerName,
sourceCount,
a.stageHost,
a.io,
a.pluginReg,
a.memory,
knowledgeLister,
a.docStore,
textMem,
socialStore,
trk,
)
ks.ONNX = a.onnxStatus()
ks.Scheduler = a.schedulerStatus()
ks.Residents = residentStatuses(a.Residents())
return ks
}
// onnxStatus 汇总统一多模态向量空间ONNX 模型)的启用状态。
//
// 判据是 Loaded()provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
func (a *Agent) onnxStatus() sdk.ONNXStatus {
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
st.Enabled = true
st.Dim = a.multimodalSpace.Dim()
st.Fingerprint = a.multimodalSpace.Fingerprint()
// 模态是可选能力:只有底层 provider 报出来时才带出。
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
st.Modalities = mr.Modalities()
}
return st
}
switch {
case a.embeddingError != "":
st.Reason = "打开失败: " + a.embeddingError
case a.embeddingProvider == "":
st.Reason = "未配置统一向量空间 provider走词嵌入/TF-IDF 回退路径)"
default:
st.Reason = "provider 未加载"
}
return st
}
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
}