mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 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 通道两侧都出现。
270 lines
9.9 KiB
Go
270 lines
9.9 KiB
Go
package webui
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"encoding/json"
|
||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||
"net/http"
|
||
"path/filepath"
|
||
)
|
||
|
||
// 内核与代理面:状态、kernel 快照、人格、代理列表/快照/回滚/动作。
|
||
|
||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
agentCount := 0
|
||
if h.supervisor != nil {
|
||
agentCount = len(h.supervisor.ListAgents())
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"status": "running",
|
||
"uptime": time.Since(h.startTime).Round(time.Second).String(),
|
||
"agents": agentCount,
|
||
// 内核版本取 internal/meta(-ldflags 注入点)。
|
||
//
|
||
// 此前这里给的是 `sdk.SDKVersion`,那条链最终指向 **SDK 仓
|
||
// meta.Version 的硬编码值**,与构建时注入的内核版本无关——
|
||
// 两仲版本号碰巧相等时看不出问题,一旦不等就报错。
|
||
// SDK 版本另用 sdk_version 字段并行暴露。
|
||
"version": meta.Version,
|
||
"commit": meta.Commit,
|
||
"build_time": meta.BuildTime,
|
||
"sdk_version": sdk.SDKVersion,
|
||
"startedAt": h.startTime,
|
||
})
|
||
}
|
||
|
||
func (h *Handler) handleKernel(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 provider not available"})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, h.status.GetKernelStatus())
|
||
}
|
||
|
||
// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。
|
||
//
|
||
// 为什么需要向导:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
|
||
// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。
|
||
// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。
|
||
// handlePersona 是首启人格向导的后端(与内核 persona_set 工具共用 internal/config 的实现)。
|
||
//
|
||
// GET → {initialized, current_prompt, file_override}
|
||
// POST → {"mode":"default"|"custom"|"later","content":"..."}
|
||
// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required
|
||
//
|
||
// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词),
|
||
// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。
|
||
// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。
|
||
//
|
||
// 跨通道:这里只是 WebUI 侧的入口;任何通道的消息到来时,内核都会检查同一枚标记,
|
||
// 未确认则在提示词里要求模型主动询问(见 internal/agent/core 的首启门禁)。
|
||
func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) {
|
||
if h.settings == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"})
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"initialized": internalConfig.PersonaInitializedKV(h.settings),
|
||
"current_prompt": internalConfig.CurrentPersonaKV(h.settings),
|
||
"file_override": h.personaFileExists(),
|
||
})
|
||
case http.MethodPost:
|
||
var req struct {
|
||
Mode string `json:"mode"`
|
||
Content string `json:"content"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
return
|
||
}
|
||
restart, err := internalConfig.SetPersonaKV(h.settings, req.Mode, req.Content)
|
||
if err != nil {
|
||
// 非法 mode / 空内容 → 400;落库失败 → 500。两者都不打标记。
|
||
code := http.StatusInternalServerError
|
||
if strings.Contains(err.Error(), "unknown mode") || strings.Contains(err.Error(), "content required") {
|
||
code = http.StatusBadRequest
|
||
}
|
||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"status": "ok", "mode": req.Mode, "restart_required": restart,
|
||
})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。
|
||
// 数据目录取自 core.daemon.data_dir(由播种写入)。
|
||
func (h *Handler) personaFileExists() bool {
|
||
v, err := h.settings.GetCore("core.daemon.data_dir")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
dir, _ := v.(string)
|
||
if dir == "" {
|
||
return false
|
||
}
|
||
_, err = os.Stat(filepath.Join(dir, "personal", "personal.md"))
|
||
return err == nil
|
||
}
|
||
|
||
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
if h.supervisor == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"})
|
||
return
|
||
}
|
||
agents := h.supervisor.ListAgents()
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents})
|
||
case http.MethodPost:
|
||
var cfg types.AgentConfig
|
||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if cfg.ID == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent id is required"})
|
||
return
|
||
}
|
||
if h.config != nil {
|
||
kcfg := h.config.Get()
|
||
kcfg.Agents = append(kcfg.Agents, cfg)
|
||
h.config.Put(kcfg)
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]string{"id": string(cfg.ID)})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) {
|
||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||
parts := strings.Split(path, "/")
|
||
agentID := types.AgentID(parts[0])
|
||
if len(parts) == 1 {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
if h.supervisor == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"})
|
||
return
|
||
}
|
||
status, err := h.supervisor.GetAgentStatus(string(agentID))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, status)
|
||
case http.MethodDelete:
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": string(agentID)})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
return
|
||
}
|
||
action := parts[1]
|
||
switch action {
|
||
case "snapshots":
|
||
h.handleSnapshots(w, r, agentID, parts)
|
||
case "rollback":
|
||
h.handleRollback(w, r, agentID, parts)
|
||
case "start", "stop", "restart":
|
||
h.handleAgentAction(w, r, agentID, action)
|
||
default:
|
||
http.Error(w, "not found", http.StatusNotFound)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleSnapshots(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"agent_id": agentID, "snapshots": []map[string]interface{}{}})
|
||
case http.MethodPost:
|
||
if h.supervisor == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"})
|
||
return
|
||
}
|
||
snap, err := h.supervisor.PreActionSnapshot(string(agentID))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, snap)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleRollback(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) {
|
||
if r.Method != http.MethodPost || len(parts) < 3 {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if h.supervisor == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"})
|
||
return
|
||
}
|
||
snapID := types.SnapshotID(parts[2])
|
||
if err := h.supervisor.RollbackAgent(string(agentID), string(snapID)); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_initiated", "agent": string(agentID), "snap": string(snapID)})
|
||
}
|
||
|
||
func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agentID types.AgentID, action string) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
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,
|
||
})
|
||
}
|