From 175a96fb2b38303538b162a1fc836ae83e7bf3a4 Mon Sep 17 00:00:00 2001 From: DBT Date: Wed, 25 Feb 2026 11:54:08 +0000 Subject: [PATCH] feat webui: config hot-reload + chat/media upload endpoints --- cmd/clawgo/cmd_gateway.go | 10 ++ pkg/nodes/registry_server.go | 196 ++++++++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index 7d7b6c6..7aa14a1 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -178,6 +178,16 @@ func gatewayCmd() { } registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager()) + registryServer.SetConfigPath(getConfigPath()) + registryServer.SetChatHandler(func(cctx context.Context, sessionKey, content string) (string, error) { + if strings.TrimSpace(content) == "" { + return "", nil + } + return agentLoop.ProcessDirect(cctx, content, sessionKey) + }) + registryServer.SetConfigAfterHook(func() { + _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) + }) if err := registryServer.Start(ctx); err != nil { fmt.Printf("Error starting node registry server: %v\n", err) } else { diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index ab9dc3a..8c1629a 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -4,16 +4,23 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "os" + "path/filepath" "strings" + "syscall" "time" ) type RegistryServer struct { - addr string - token string - mgr *Manager - server *http.Server + addr string + token string + mgr *Manager + server *http.Server + configPath string + onChat func(ctx context.Context, sessionKey, content string) (string, error) + onConfigAfter func() } func NewRegistryServer(host string, port int, token string, mgr *Manager) *RegistryServer { @@ -27,6 +34,12 @@ func NewRegistryServer(host string, port int, token string, mgr *Manager) *Regis return &RegistryServer{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr} } +func (s *RegistryServer) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } +func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { + s.onChat = fn +} +func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } + func (s *RegistryServer) Start(ctx context.Context) error { if s.mgr == nil { return nil @@ -38,6 +51,10 @@ func (s *RegistryServer) Start(ctx context.Context) error { }) mux.HandleFunc("/nodes/register", s.handleRegister) mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat) + mux.HandleFunc("/webui", s.handleWebUI) + mux.HandleFunc("/webui/api/config", s.handleWebUIConfig) + mux.HandleFunc("/webui/api/chat", s.handleWebUIChat) + mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload) s.server = &http.Server{Addr: s.addr, Handler: mux} go func() { <-ctx.Done() @@ -98,10 +115,179 @@ func (s *RegistryServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": body.ID}) } +func (s *RegistryServer) handleWebUI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(webUIHTML)) +} + +func (s *RegistryServer) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if strings.TrimSpace(s.configPath) == "" { + http.Error(w, "config path not set", http.StatusInternalServerError) + return + } + switch r.Method { + case http.MethodGet: + b, err := os.ReadFile(s.configPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + case http.MethodPost: + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + b, err := json.MarshalIndent(body, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tmp := s.configPath + ".tmp" + if err := os.WriteFile(tmp, b, 0644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := os.Rename(tmp, s.configPath); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if s.onConfigAfter != nil { + s.onConfigAfter() + } else { + _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *RegistryServer) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f, h, err := r.FormFile("file") + if err != nil { + http.Error(w, "file required", http.StatusBadRequest) + return + } + defer f.Close() + dir := filepath.Join(os.TempDir(), "clawgo_webui_uploads") + _ = os.MkdirAll(dir, 0755) + name := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(h.Filename)) + path := filepath.Join(dir, name) + out, err := os.Create(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer out.Close() + if _, err := io.Copy(out, f); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": path, "name": h.Filename}) +} + +func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.onChat == nil { + http.Error(w, "chat handler not configured", http.StatusInternalServerError) + return + } + var body struct { + Session string `json:"session"` + Message string `json:"message"` + Media string `json:"media"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + session := strings.TrimSpace(body.Session) + if session == "" { + session = "webui:default" + } + prompt := strings.TrimSpace(body.Message) + if strings.TrimSpace(body.Media) != "" { + if prompt != "" { + prompt += "\n" + } + prompt += "[file: " + strings.TrimSpace(body.Media) + "]" + } + resp, err := s.onChat(r.Context(), session, prompt) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session}) +} + func (s *RegistryServer) checkAuth(r *http.Request) bool { if s.token == "" { return true } auth := strings.TrimSpace(r.Header.Get("Authorization")) - return auth == "Bearer "+s.token + if auth == "Bearer "+s.token { + return true + } + if strings.TrimSpace(r.URL.Query().Get("token")) == s.token { + return true + } + return false } + +const webUIHTML = ` + +ClawGo WebUI + + +

ClawGo WebUI

+

Token:

+

Config (dynamic + hot reload)

+ + + +

Chat (supports media upload)

+
Session:
+
+`