mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-25 03:18:08 +00:00
重构: 插件自注册 + .so 动态加载 + 中断打断机制
- 所有内置插件 init() 自注册 (plugin.RegisterFactory), 移除 main.go 硬编码 - 新增 .so 动态加载器 (internal/plugin/dynamic.go), 插件可编译为 plugin.so - 新增 plugin.json 元数据 (internal/plugin/manifest.go) - 新增 interceptLoop 独立 goroutine: (a) cancelLLM() 取消进行中的 HTTP 请求 (b) interceptCh → drainInterrupt() 注入 [打断消息] 到 LLM 上下文 (c) InjectInput 空闲时触发新处理循环 - 新增 internal/plugins/all.go 空白导入触发所有内置插件 init() - internal/sdk/ 作为 PluginSDK 正式 Go API - internal/api/ → internal/plugins/webui/ 迁移 - 删除旧 cmd/cli/, 使用 cmd/waiter/ 替代 - 更新 PLAN.md / ARCHITECTURE.md / README.md 文档
This commit is contained in:
851
internal/plugins/webui/handler.go
Normal file
851
internal/plugins/webui/handler.go
Normal file
@ -0,0 +1,851 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
supervisor *supervisor.Daemon
|
||||
memory *memory.GraphDB
|
||||
indexer *memory.Indexer
|
||||
skills *skill.Manager
|
||||
lua *luaVM.VM
|
||||
config *types.Config
|
||||
startTime time.Time
|
||||
iom *agentIO.IOManager
|
||||
textMem *text.Memory
|
||||
knowledge *knowledge.Store
|
||||
tracker *tracker.Tracker
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
pluginReg *plugin.Registry
|
||||
eventBus *events.Bus
|
||||
}
|
||||
|
||||
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus) *Handler {
|
||||
var idx *memory.Indexer
|
||||
if mem != nil {
|
||||
idx = memory.NewIndexer(mem)
|
||||
}
|
||||
return &Handler{
|
||||
supervisor: sup,
|
||||
memory: mem,
|
||||
indexer: idx,
|
||||
skills: sk,
|
||||
lua: lua,
|
||||
config: cfg,
|
||||
startTime: time.Now(),
|
||||
iom: iom,
|
||||
textMem: tm,
|
||||
knowledge: ks,
|
||||
tracker: tr,
|
||||
cfgReg: cr,
|
||||
pluginReg: pr,
|
||||
eventBus: evBus,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/status", h.handleStatus)
|
||||
mux.HandleFunc("/api/v1/agents", h.handleAgents)
|
||||
mux.HandleFunc("/api/v1/agents/", h.handleAgentByID)
|
||||
mux.HandleFunc("/api/v1/skills", h.handleSkills)
|
||||
mux.HandleFunc("/api/v1/memory", h.handleMemory)
|
||||
mux.HandleFunc("/api/v1/memory/", h.handleMemory)
|
||||
mux.HandleFunc("/api/v1/memory/context", h.handleMemoryContext)
|
||||
mux.HandleFunc("/api/v1/memory/tools", h.handleMemoryTools)
|
||||
mux.HandleFunc("/api/v1/memory/text", h.handleTextMemory)
|
||||
mux.HandleFunc("/api/v1/network", h.handleNetwork)
|
||||
mux.HandleFunc("/api/v1/config", h.handleConfig)
|
||||
mux.HandleFunc("/api/v1/settings", h.handleSettings)
|
||||
mux.HandleFunc("/api/v1/settings/", h.handleSettings)
|
||||
mux.HandleFunc("/api/v1/knowledge", h.handleKnowledge)
|
||||
mux.HandleFunc("/api/v1/knowledge/", h.handleKnowledge)
|
||||
mux.HandleFunc("/api/v1/adapters", h.handleAdapters)
|
||||
mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID)
|
||||
mux.HandleFunc("/api/v1/tracker", h.handleTracker)
|
||||
mux.HandleFunc("/api/v1/tracker/", h.handleTracker)
|
||||
mux.HandleFunc("/api/v1/chat", h.handleChat)
|
||||
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
|
||||
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
|
||||
mux.HandleFunc("/", h.handleStatic)
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
agents := h.supervisor.ListAgents()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "running",
|
||||
"uptime": time.Since(h.startTime).String(),
|
||||
"agents": len(agents),
|
||||
"version": "0.1.0",
|
||||
"startedAt": h.startTime,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
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
|
||||
}
|
||||
h.config.Agents = append(h.config.Agents, cfg)
|
||||
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:
|
||||
status, err := h.supervisor.GetAgentStatus(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:
|
||||
snap, err := h.supervisor.PreActionSnapshot(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
|
||||
}
|
||||
snapID := types.SnapshotID(parts[2])
|
||||
if err := h.supervisor.RollbackAgent(agentID, 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.StatusOK, map[string]string{"status": fmt.Sprintf("%s_requested", action), "agent": string(agentID)})
|
||||
}
|
||||
|
||||
func (h *Handler) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
if h.skills == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skills not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": h.skills.List()})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
}
|
||||
if err := h.skills.Install(req.Name, req.Content); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "installed", "name": req.Name})
|
||||
case http.MethodDelete:
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"})
|
||||
return
|
||||
}
|
||||
if err := h.skills.Uninstall(name); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "uninstalled", "name": name})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if h.memory == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
userInput := r.URL.Query().Get("q")
|
||||
keywords := strings.Split(userInput, ",")
|
||||
depth, _ := strconv.Atoi(r.URL.Query().Get("depth"))
|
||||
if depth <= 0 {
|
||||
depth = 2
|
||||
}
|
||||
result, err := h.memory.Recall(keywords, nil, depth, "")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Triples []memory.Triple `json:"triples"`
|
||||
SessionID string `json:"session_id"`
|
||||
TurnID int `json:"turn_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
ec, rc, err := h.memory.Commit(req.Triples, req.SessionID, req.TurnID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]int{"entities_created": ec, "relations_created": rc})
|
||||
case http.MethodDelete:
|
||||
var req struct {
|
||||
Criteria map[string]string `json:"criteria"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
deleted, err := h.memory.Purge(req.Criteria, req.Mode)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.indexer == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
|
||||
return
|
||||
}
|
||||
userInput := r.URL.Query().Get("q")
|
||||
injected := h.indexer.BuildContext(userInput)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"context": h.indexer.FormatContext(injected),
|
||||
"summary": injected.Summary,
|
||||
"entities": injected.Entities,
|
||||
"token_estimate": injected.TokenEstimate,
|
||||
"tool_prompt": h.indexer.BuildToolPrompt(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.indexer == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"tools": h.indexer.GetToolDefinitions(),
|
||||
"tool_prompt": h.indexer.BuildToolPrompt(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if h.knowledge == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
|
||||
return
|
||||
}
|
||||
query := r.URL.Query().Get("q")
|
||||
if query != "" {
|
||||
results := h.knowledge.Search(query, 10)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"categories": h.knowledge.List(),
|
||||
"stats": h.knowledge.Stats(),
|
||||
})
|
||||
|
||||
case http.MethodPost:
|
||||
if h.knowledge == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
|
||||
return
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
buf := make([]byte, 10<<20)
|
||||
n, _ := file.Read(buf)
|
||||
content := string(buf[:n])
|
||||
if err := h.knowledge.Add(name, content); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": name})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
}
|
||||
if req.Name == "" || req.Content == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and content required"})
|
||||
return
|
||||
}
|
||||
if err := h.knowledge.Add(req.Name, req.Content); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": req.Name})
|
||||
|
||||
case http.MethodDelete:
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"})
|
||||
return
|
||||
}
|
||||
if err := h.knowledge.Remove(name); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if h.textMem == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
recent, _ := h.textMem.RecentEvents(50)
|
||||
stats := h.textMem.Stats()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"stats": stats,
|
||||
"recent": recent,
|
||||
})
|
||||
case http.MethodDelete:
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) {
|
||||
if h.lua == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": h.lua.ListAdapters()})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("%s/%s.lua", h.lua.AdapterDir(), req.Name)
|
||||
if err := os.WriteFile(path, []byte(req.Code), 0644); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.lua.ReloadAll(); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "loaded", "name": req.Name})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) {
|
||||
if h.lua == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters/")
|
||||
if name == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
for _, a := range h.lua.ListAdapters() {
|
||||
if a.Name == name {
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
case http.MethodDelete:
|
||||
path := fmt.Sprintf("%s/%s.lua", h.lua.AdapterDir(), name)
|
||||
if err := os.Remove(path); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"})
|
||||
return
|
||||
}
|
||||
h.lua.ReloadAll()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"network_status": "monitoring",
|
||||
"endpoints": h.config.Defaults.LLMEndpoints,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if body.Message == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := h.iom.InjectTextSync("cli", body.Message)
|
||||
if resp == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
|
||||
return
|
||||
}
|
||||
content, _ := resp.Payload["content"].(string)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"response": content,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
done := r.Context().Done()
|
||||
if h.eventBus == nil {
|
||||
fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
unsub := h.eventBus.Subscribe(events.EventAll, func(evt *events.Event) {
|
||||
data, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, data)
|
||||
flusher.Flush()
|
||||
})
|
||||
defer unsub()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Fprintf(w, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, h.config)
|
||||
case http.MethodPut:
|
||||
var cfg types.Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid config"})
|
||||
return
|
||||
}
|
||||
h.config = &cfg
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "config_updated"})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfgReg == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "config registry not available"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
keys := h.cfgReg.List(prefix)
|
||||
values := make(map[string]interface{})
|
||||
for _, k := range keys {
|
||||
v, _ := h.cfgReg.Get(k)
|
||||
values[k] = v
|
||||
}
|
||||
plugins := []string{"core"}
|
||||
if h.pluginReg != nil {
|
||||
for _, p := range h.pluginReg.List() {
|
||||
plugins = append(plugins, "plugin."+p)
|
||||
}
|
||||
}
|
||||
sort.Strings(plugins)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"settings": values,
|
||||
"plugins": plugins,
|
||||
})
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Key string `json:"key"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.cfgReg.Set(body.Key, body.Value); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if h.iom == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "messages is required"})
|
||||
return
|
||||
}
|
||||
|
||||
lastMsg := req.Messages[len(req.Messages)-1]
|
||||
if lastMsg.Role != "user" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"})
|
||||
return
|
||||
}
|
||||
|
||||
response := h.iom.InjectTextSync("http", lastMsg.Content)
|
||||
if response == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no response from agent"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
|
||||
"object": "chat.completion",
|
||||
"created": time.Now().Unix(),
|
||||
"model": req.Model,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": response.Payload["content"],
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": len(lastMsg.Content) / 2,
|
||||
"completion_tokens": len(response.Payload["content"].(string)) / 2,
|
||||
"total_tokens": (len(lastMsg.Content) + len(response.Payload["content"].(string))) / 2,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
|
||||
if h.tracker == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "changesets" && r.Method == http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"changesets": h.tracker.ChangeSets(),
|
||||
"count": len(h.tracker.ChangeSets()),
|
||||
})
|
||||
case path == "rollback" && r.Method == http.MethodPost:
|
||||
if err := h.tracker.Rollback(); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"})
|
||||
case path == "" && r.Method == http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"stats": h.tracker.Stats(),
|
||||
"has_changes": h.tracker.HasChanges(),
|
||||
"changesets": len(h.tracker.ChangeSets()),
|
||||
})
|
||||
case path == "" && r.Method == http.MethodDelete:
|
||||
if err := h.tracker.Rollback(); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(webuiHTML)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
var webuiHTML = []byte(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HomeAgent Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||||
body{background:#0f172a;color:#e2e8f0;min-height:100vh}
|
||||
nav{background:#1e293b;padding:12px 24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #334155}
|
||||
nav h1{font-size:18px;font-weight:600;color:#38bdf8}
|
||||
nav a{color:#94a3b8;text-decoration:none;font-size:14px;cursor:pointer}
|
||||
nav a:hover{color:#38bdf8;text-decoration:none}
|
||||
nav a.active{color:#38bdf8;border-bottom:2px solid #38bdf8}
|
||||
.container{padding:24px;max-width:1400px;margin:0 auto}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px;margin-bottom:16px}
|
||||
.card h2{font-size:16px;font-weight:600;margin-bottom:12px;color:#f1f5f9}
|
||||
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}
|
||||
.dot-green{background:#22c55e}
|
||||
.dot-yellow{background:#eab308}
|
||||
.dot-red{background:#ef4444}
|
||||
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||
.stat-value{font-size:28px;font-weight:700;color:#38bdf8}
|
||||
.stat-label{font-size:12px;color:#64748b;margin-top:4px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:500;border-bottom:1px solid #334155;font-size:12px;text-transform:uppercase}
|
||||
td{padding:8px 12px;border-bottom:1px solid #1e293b}
|
||||
.status-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
|
||||
.badge-running{background:#166534;color:#86efac}
|
||||
.badge-stopped{background:#7f1d1d;color:#fca5a5}
|
||||
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500}
|
||||
.btn-primary{background:#2563eb;color:#fff}
|
||||
.btn-primary:hover{background:#1d4ed8}
|
||||
.btn-danger{background:#dc2626;color:#fff}
|
||||
.btn-sm{padding:4px 10px;font-size:11px}
|
||||
.tab-content{display:none}
|
||||
.tab-content.active{display:block}
|
||||
input,textarea,select{background:#0f172a;border:1px solid #334155;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%;margin-bottom:12px}
|
||||
label{display:block;font-size:12px;color:#94a3b8;margin-bottom:4px}
|
||||
h3{font-size:14px;font-weight:600;color:#f1f5f9;margin-bottom:8px}
|
||||
pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:#a5b4fc}
|
||||
.settings-layout{display:flex;gap:20px;min-height:60vh}
|
||||
.settings-sidebar{width:200px;flex-shrink:0;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:12px 0;overflow-y:auto}
|
||||
.settings-sidebar a{display:block;padding:10px 16px;color:#94a3b8;font-size:13px;cursor:pointer;text-decoration:none;border-left:3px solid transparent}
|
||||
.settings-sidebar a:hover{background:#0f172a;color:#e2e8f0}
|
||||
.settings-sidebar a.active{background:#0f172a;color:#38bdf8;border-left-color:#38bdf8}
|
||||
.settings-content{flex:1;min-width:0}
|
||||
.settings-key{font-family:monospace;font-size:12px;color:#64748b;margin-bottom:2px}
|
||||
.save-btn{float:right}
|
||||
.toast{position:fixed;bottom:20px;right:20px;background:#166534;color:#86efac;padding:10px 20px;border-radius:8px;font-size:13px;display:none;z-index:100}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<h1>HomeAgent</h1>
|
||||
<a class="active" onclick="switchTab('overview')">概览</a>
|
||||
<a onclick="switchTab('memory')">图记忆</a>
|
||||
<a onclick="switchTab('skills')">技能</a>
|
||||
<a onclick="switchTab('network')">网络</a>
|
||||
<a onclick="switchTab('config')">配置</a>
|
||||
</nav>
|
||||
<div class="container" id="app">
|
||||
<div id="tab-overview" class="tab-content active"></div>
|
||||
<div id="tab-memory" class="tab-content"></div>
|
||||
<div id="tab-skills" class="tab-content"></div>
|
||||
<div id="tab-network" class="tab-content"></div>
|
||||
<div id="tab-config" class="tab-content"></div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
let state={status:null,settings:null,settingsPlugins:[],selectedSection:'core'};
|
||||
async function api(p,o={}){const r=await fetch('/api/v1'+p,{headers:{'Content-Type':'application/json',...o.headers},...o});return r.json()}
|
||||
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));document.getElementById('tab-'+n).classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="'+n+'"]')?.classList.add('active');renderAll()}
|
||||
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2500)}
|
||||
async function renderAll(){try{state.status=await api('/status')}catch(e){}try{var s=await api('/settings');state.settings=s.settings||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderMemory();renderSkills();renderNetwork();renderConfig();renderConfigSidebar()}
|
||||
function renderOverview(){const s=state.status||{};document.getElementById('tab-overview').innerHTML='<div class="grid-3">'+statCard('运行状态',s.status||'unknown')+statCard('运行时间',s.uptime||'-')+statCard('版本',s.version||'-')+'</div>'}
|
||||
function statCard(l,v){return '<div class="card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
|
||||
function renderMemory(){document.getElementById('tab-memory').innerHTML='<div class="card"><h2>图记忆</h2><p style="color:#94a3b8">agent 通过 memory_recall / memory_commit 自动管理</p></div>'}
|
||||
function renderSkills(){document.getElementById('tab-skills').innerHTML='<div class="card"><h2>技能</h2><p style="color:#94a3b8">SKILL.md 插件通过 IO 层注入</p></div>'}
|
||||
function renderNetwork(){document.getElementById('tab-network').innerHTML='<div class="card"><h2>网络</h2><p style="color:#94a3b8">LLM API 连通性监控</p></div>'}
|
||||
function renderConfigSidebar(){var el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){var a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderConfig()};el.appendChild(a)})}
|
||||
function renderConfig(){var prefix=state.selectedSection+'.';var filtered=Object.keys(state.settings||{}).filter(function(k){return k===prefix.slice(0,-1)||k.startsWith(prefix)});filtered.sort();var html='<div class="settings-layout"><div class="settings-sidebar" id="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+state.selectedSection+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(function(k){var v=state.settings[k];var sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><div class="save-btn"><button class="btn btn-primary btn-sm" onclick="saveSetting(\''+k+'\')">保存</button></div><div class="settings-key">'+k+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"/></div>'})}html+='</div></div>';document.getElementById('tab-config').innerHTML=html;renderConfigSidebar()}
|
||||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function markDirty(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||
async function saveSetting(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;var raw=inp.value;var val;try{val=JSON.parse(raw)}catch(e){val=raw}var r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'))}}
|
||||
renderAll();setInterval(renderAll,30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
693
internal/plugins/webui/handler_test.go
Normal file
693
internal/plugins/webui/handler_test.go
Normal file
@ -0,0 +1,693 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
|
||||
t.Helper()
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
|
||||
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus()), sup
|
||||
}
|
||||
|
||||
func TestHandleStatus(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["status"] != "running" {
|
||||
t.Errorf("expected running, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusMethodNotAllowed(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
sup.RegisterAgent("test_agent")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
agents, ok := resp["agents"].([]interface{})
|
||||
if !ok || len(agents) == 0 {
|
||||
t.Error("expected agents list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentByID(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
sup.RegisterAgent("my_agent")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/my_agent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgentByID(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["id"] != "my_agent" {
|
||||
t.Errorf("expected my_agent, got %v", resp["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentByIDNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAgentByID(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeSearch(t *testing.T) {
|
||||
ks := knowledge.NewStore(t.TempDir())
|
||||
ks.Start()
|
||||
ks.Add("test_doc", "this is test content for searching")
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
results, ok := resp["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Error("expected search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeCreate(t *testing.T) {
|
||||
ks := knowledge.NewStore(t.TempDir())
|
||||
ks.Start()
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
|
||||
|
||||
body := `{"name":"new_doc","content":"fresh content"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleKnowledgeUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleKnowledge(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMemoryUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory?q=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleMemory(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTrackerNotAvailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTracker(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTrackerStats(t *testing.T) {
|
||||
tr := tracker.NewTracker(t.TempDir(), t.TempDir())
|
||||
|
||||
cfg := &types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
sup := supervisor.New(cfg)
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTracker(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
// 给 handler 一个 IOManager,才能通过 nil 检查到达消息校验
|
||||
h.iom = agentIO.NewIOManager()
|
||||
|
||||
body := `{"model":"test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
// 给 handler 一个 IOManager,才能通过 nil 检查到达消息校验
|
||||
h.iom = agentIO.NewIOManager()
|
||||
|
||||
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAICompletionsMethodNotAllowed(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticServesHTML(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatic(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "HomeAgent Dashboard") {
|
||||
t.Error("expected dashboard HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatic(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigGet(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleConfig(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
method string
|
||||
code int
|
||||
}{
|
||||
{"/api/v1/status", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/agents", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/config", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/network", http.MethodGet, http.StatusOK},
|
||||
{"/", http.MethodGet, http.StatusOK},
|
||||
{"/api/v1/memory", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/knowledge", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/tracker", http.MethodGet, http.StatusServiceUnavailable},
|
||||
{"/api/v1/adapters", http.MethodGet, http.StatusServiceUnavailable},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.code {
|
||||
t.Errorf("%s %s: expected %d, got %d", tt.method, tt.path, tt.code, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdapterByIDNotFound(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAdapterByID(w, req)
|
||||
|
||||
// Returns 503 when lua VM is not available
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdaptersUnavailable(t *testing.T) {
|
||||
h, sup := newTestHandler(t)
|
||||
defer sup.Shutdown()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleAdapters(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// === 全流程集成测试:ConfigRegistry → WebUI → HTTP API ===
|
||||
|
||||
func TestSettingsAPIFlow(t *testing.T) {
|
||||
cfgReg := internalConfig.NewConfigRegistry("")
|
||||
cfgReg.Register("core.llm.model", "deepseek-v4-flash")
|
||||
cfgReg.Register("core.llm.base_url", "https://api.deepseek.com")
|
||||
cfgReg.Register("core.daemon.listen_addr", ":8080")
|
||||
cfgReg.Register("plugin.qq.access_token", "secret123")
|
||||
|
||||
sup := supervisor.New(&types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
})
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
|
||||
|
||||
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSettings(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
settings, ok := resp["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("settings not a map")
|
||||
}
|
||||
if v, _ := settings["core.llm.model"].(string); v != "deepseek-v4-flash" {
|
||||
t.Fatalf("expected deepseek-v4-flash, got %v", settings["core.llm.model"])
|
||||
}
|
||||
|
||||
plugins, ok := resp["plugins"].([]interface{})
|
||||
if !ok || len(plugins) == 0 {
|
||||
t.Fatal("expected plugins list")
|
||||
}
|
||||
if plugins[0] != "core" {
|
||||
t.Fatalf("expected first plugin 'core', got %v", plugins[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET_settings_with_prefix", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=core.daemon", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSettings(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
settings := resp["settings"].(map[string]interface{})
|
||||
|
||||
if _, ok := settings["core.daemon.listen_addr"]; !ok {
|
||||
t.Fatal("expected core.daemon.listen_addr in filtered results")
|
||||
}
|
||||
if _, ok := settings["core.llm.model"]; ok {
|
||||
t.Fatal("core.llm.model should not be in core.daemon filtered results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT_settings_updates_value", func(t *testing.T) {
|
||||
body := `{"key":"core.llm.model","value":"gpt-4"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSettings(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
val, err := cfgReg.Get("core.llm.model")
|
||||
if err != nil {
|
||||
t.Fatalf("Get error: %v", err)
|
||||
}
|
||||
if v, _ := val.(string); v != "gpt-4" {
|
||||
t.Fatalf("expected gpt-4, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT_settings_invalid_body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader("not json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSettings(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handleStatic_returns_webui_html", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleStatic(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
html := string(body)
|
||||
if !strings.Contains(html, "settings-layout") {
|
||||
t.Fatal("HTML should contain settings-layout class")
|
||||
}
|
||||
if !strings.Contains(html, "settings-sidebar") {
|
||||
t.Fatal("HTML should contain settings-sidebar class")
|
||||
}
|
||||
if !strings.Contains(html, "saveSetting") {
|
||||
t.Fatal("HTML should contain saveSetting JS function")
|
||||
}
|
||||
if !strings.Contains(html, "api('/settings'") {
|
||||
t.Fatal("HTML should call api('/settings')")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings_not_available_without_registry", func(t *testing.T) {
|
||||
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h2.handleSettings(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSettingsWithPluginRegistry(t *testing.T) {
|
||||
cfgReg := internalConfig.NewConfigRegistry("")
|
||||
cfgReg.Register("core.test.key", "value")
|
||||
cfgReg.Register("plugin.testplug.apikey", "abc123")
|
||||
|
||||
sup := supervisor.New(&types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
})
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSettings(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
plugins, _ := resp["plugins"].([]interface{})
|
||||
foundCore := false
|
||||
for _, p := range plugins {
|
||||
if p == "core" {
|
||||
foundCore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCore {
|
||||
t.Fatal("expected 'core' in plugins list")
|
||||
}
|
||||
}
|
||||
|
||||
// === 端到端测试:Handler + IOManager + Agent + HTTP ===
|
||||
|
||||
type echoProvider struct{ name string }
|
||||
|
||||
func (p *echoProvider) Name() string { return p.name }
|
||||
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
content := "echo: " + req.Messages[len(req.Messages)-1].Content
|
||||
return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil
|
||||
}
|
||||
func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
ch <- agentAPI.StreamChunk{Content: "mock", Done: true}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 避免测试时自动输出
|
||||
}
|
||||
|
||||
func TestHandleCompletionsEndToEnd(t *testing.T) {
|
||||
iom := agentIO.NewIOManager()
|
||||
|
||||
// 启动一个最小 Agent,使用 echoProvider(不调真实 LLM)
|
||||
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
defer memDB.Close()
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "test",
|
||||
SystemPrompt: "你是测试助手",
|
||||
Provider: &echoProvider{name: "echo"},
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: nil,
|
||||
MaxToolTurns: 0,
|
||||
ContextSavePath: "",
|
||||
})
|
||||
agent.Start()
|
||||
defer agent.Stop()
|
||||
|
||||
// Handler 需要 iom
|
||||
sup := supervisor.New(&types.Config{
|
||||
Daemon: types.DaemonConfig{
|
||||
CheckInterval: time.Minute,
|
||||
HeartbeatInterval: 30 * time.Second,
|
||||
},
|
||||
})
|
||||
sup.Start()
|
||||
defer sup.Shutdown()
|
||||
|
||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus())
|
||||
|
||||
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
|
||||
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
choices, ok := resp["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
t.Fatal("expected choices")
|
||||
}
|
||||
msg, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected message")
|
||||
}
|
||||
if msg["content"] != "echo: 你好" {
|
||||
t.Fatalf("expected 'echo: 你好', got '%v'", msg["content"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
|
||||
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus())
|
||||
body := `{"messages":[{"role":"user","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h2.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST_chat_completions_400_on_no_messages", func(t *testing.T) {
|
||||
body := `{"model":"test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST_chat_completions_400_on_non_user_last_msg", func(t *testing.T) {
|
||||
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleOpenAICompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
127
internal/plugins/webui/plugin.go
Normal file
127
internal/plugins/webui/plugin.go
Normal file
@ -0,0 +1,127 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。
|
||||
var (
|
||||
webuiAddr string
|
||||
webuiSup *supervisor.Daemon
|
||||
webuiMem *memory.GraphDB
|
||||
webuiSK *skill.Manager
|
||||
webuiLua *luaVM.VM
|
||||
webuiCfg *types.Config
|
||||
webuiIOM *agentIO.IOManager
|
||||
webuiTM *text.Memory
|
||||
webuiKS *knowledge.Store
|
||||
webuiTR *tracker.Tracker
|
||||
webuiCR *internalConfig.ConfigRegistry
|
||||
webuiPR *plugin.Registry
|
||||
webuiEvBus *events.Bus
|
||||
)
|
||||
|
||||
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
|
||||
func Configure(addr string,
|
||||
sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
|
||||
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
||||
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
||||
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
||||
) {
|
||||
webuiAddr = addr
|
||||
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
|
||||
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
|
||||
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
if webuiSup == nil {
|
||||
return nil, nil // 未 Configure 则跳过(不给日志警告)
|
||||
}
|
||||
addr := webuiAddr
|
||||
if a, ok := config["addr"].(string); ok {
|
||||
addr = a
|
||||
}
|
||||
return New(name, addr,
|
||||
webuiSup, webuiMem, webuiSK, webuiLua,
|
||||
webuiCfg, webuiIOM, webuiTM, webuiKS,
|
||||
webuiTR, webuiCR, webuiPR, webuiEvBus,
|
||||
), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
addr string
|
||||
handler *Handler
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
|
||||
sup *supervisor.Daemon
|
||||
mem *memory.GraphDB
|
||||
sk *skill.Manager
|
||||
lua *luaVM.VM
|
||||
cfg *types.Config
|
||||
iom *agentIO.IOManager
|
||||
tm *text.Memory
|
||||
ks *knowledge.Store
|
||||
tr *tracker.Tracker
|
||||
cr *internalConfig.ConfigRegistry
|
||||
pr *plugin.Registry
|
||||
evBus *events.Bus
|
||||
}
|
||||
|
||||
func New(name, addr string,
|
||||
sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
|
||||
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
||||
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
||||
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
||||
) *Plugin {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
addr: addr,
|
||||
mux: http.NewServeMux(),
|
||||
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
|
||||
iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus)
|
||||
p.handler = h
|
||||
h.RegisterRoutes(p.mux)
|
||||
|
||||
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
|
||||
go func() {
|
||||
log.Printf("[webui] HTTP server listening on %s", p.addr)
|
||||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[webui] server error: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
if p.server != nil {
|
||||
return p.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user