mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-10 15:29:06 +08:00
feat webui: config hot-reload + chat/media upload endpoints
This commit is contained in:
@@ -178,6 +178,16 @@ func gatewayCmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager())
|
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 {
|
if err := registryServer.Start(ctx); err != nil {
|
||||||
fmt.Printf("Error starting node registry server: %v\n", err)
|
fmt.Printf("Error starting node registry server: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RegistryServer struct {
|
type RegistryServer struct {
|
||||||
addr string
|
addr string
|
||||||
token string
|
token string
|
||||||
mgr *Manager
|
mgr *Manager
|
||||||
server *http.Server
|
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 {
|
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}
|
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 {
|
func (s *RegistryServer) Start(ctx context.Context) error {
|
||||||
if s.mgr == nil {
|
if s.mgr == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -38,6 +51,10 @@ func (s *RegistryServer) Start(ctx context.Context) error {
|
|||||||
})
|
})
|
||||||
mux.HandleFunc("/nodes/register", s.handleRegister)
|
mux.HandleFunc("/nodes/register", s.handleRegister)
|
||||||
mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat)
|
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}
|
s.server = &http.Server{Addr: s.addr, Handler: mux}
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-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})
|
_ = 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 {
|
func (s *RegistryServer) checkAuth(r *http.Request) bool {
|
||||||
if s.token == "" {
|
if s.token == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
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 = `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>ClawGo WebUI</title>
|
||||||
|
<style>body{font-family:system-ui;margin:20px;max-width:980px}textarea{width:100%;min-height:220px}#chatlog{white-space:pre-wrap;border:1px solid #ddd;padding:12px;min-height:180px}</style>
|
||||||
|
</head><body>
|
||||||
|
<h2>ClawGo WebUI</h2>
|
||||||
|
<p>Token: <input id="token" placeholder="gateway token" style="width:320px"/></p>
|
||||||
|
<h3>Config (dynamic + hot reload)</h3>
|
||||||
|
<button onclick="loadCfg()">Load Config</button>
|
||||||
|
<button onclick="saveCfg()">Save + Reload</button>
|
||||||
|
<textarea id="cfg"></textarea>
|
||||||
|
<h3>Chat (supports media upload)</h3>
|
||||||
|
<div>Session: <input id="session" value="webui:default"/> <input id="msg" placeholder="message" style="width:420px"/> <input id="file" type="file"/> <button onclick="sendChat()">Send</button></div>
|
||||||
|
<div id="chatlog"></div>
|
||||||
|
<script>
|
||||||
|
function auth(){const t=document.getElementById('token').value.trim();return t?('?token='+encodeURIComponent(t)):''}
|
||||||
|
async function loadCfg(){let r=await fetch('/webui/api/config'+auth());document.getElementById('cfg').value=await r.text()}
|
||||||
|
async function saveCfg(){let j=JSON.parse(document.getElementById('cfg').value);let r=await fetch('/webui/api/config'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(j)});alert(await r.text())}
|
||||||
|
async function sendChat(){
|
||||||
|
let media='';const f=document.getElementById('file').files[0];
|
||||||
|
if(f){let fd=new FormData();fd.append('file',f);let ur=await fetch('/webui/api/upload'+auth(),{method:'POST',body:fd});let uj=await ur.json();media=uj.path||''}
|
||||||
|
const payload={session:document.getElementById('session').value,message:document.getElementById('msg').value,media};
|
||||||
|
let r=await fetch('/webui/api/chat'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});let t=await r.text();
|
||||||
|
document.getElementById('chatlog').textContent += '\nUSER> '+payload.message+(media?(' [file:'+media+']'):'')+'\nBOT> '+t+'\n';
|
||||||
|
}
|
||||||
|
loadCfg();
|
||||||
|
</script></body></html>`
|
||||||
|
|||||||
Reference in New Issue
Block a user