mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 04:27:28 +08:00
Restore WebUI config editing surface
This commit is contained in:
@@ -322,12 +322,87 @@ func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
out, _ := json.MarshalIndent(merged, "", " ")
|
||||
_, _ = w.Write(out)
|
||||
case http.MethodPost:
|
||||
http.Error(w, "webui config editing is disabled", http.StatusMethodNotAllowed)
|
||||
if err := s.saveWebUIConfig(r); err != nil {
|
||||
var validationErr *configValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": validationErr.Error(),
|
||||
"errors": validationErr.Fields,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
type configValidationError struct {
|
||||
Fields []string
|
||||
}
|
||||
|
||||
func (e *configValidationError) Error() string {
|
||||
if e == nil || len(e.Fields) == 0 {
|
||||
return "invalid config"
|
||||
}
|
||||
return "invalid config: " + strings.Join(e.Fields, "; ")
|
||||
}
|
||||
|
||||
func (s *Server) saveWebUIConfig(r *http.Request) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("request is nil")
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("mode")))
|
||||
switch mode {
|
||||
case "", "raw":
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
if err := json.NewDecoder(r.Body).Decode(cfg); err != nil {
|
||||
return fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
return s.persistWebUIConfig(cfg)
|
||||
case "normalized":
|
||||
cfg, err := cfgpkg.LoadConfig(s.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var view cfgpkg.NormalizedConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&view); err != nil {
|
||||
return fmt.Errorf("decode normalized config: %w", err)
|
||||
}
|
||||
cfg.ApplyNormalizedView(view)
|
||||
return s.persistWebUIConfig(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unsupported config mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) persistWebUIConfig(cfg *cfgpkg.Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
cfg.Normalize()
|
||||
if errs := cfgpkg.Validate(cfg); len(errs) > 0 {
|
||||
fields := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
fields = append(fields, err.Error())
|
||||
}
|
||||
}
|
||||
return &configValidationError{Fields: fields}
|
||||
}
|
||||
if err := cfgpkg.SaveConfig(s.configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.onConfigAfter != nil {
|
||||
return s.onConfigAfter()
|
||||
}
|
||||
return requestSelfReloadSignal()
|
||||
}
|
||||
|
||||
func mergeJSONMap(base, override map[string]interface{}) map[string]interface{} {
|
||||
if base == nil {
|
||||
base = map[string]interface{}{}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUIConfigPostIsDisabled(t *testing.T) {
|
||||
func TestHandleWebUIConfigPostSavesRawConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
@@ -99,17 +99,76 @@ func TestHandleWebUIConfigPostIsDisabled(t *testing.T) {
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "")
|
||||
srv.SetConfigPath(cfgPath)
|
||||
hookCalled := 0
|
||||
srv.SetConfigAfterHook(func() error {
|
||||
hookCalled++
|
||||
return nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config", strings.NewReader(`{"gateway":{"host":"127.0.0.1"}}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config", strings.NewReader(`{"gateway":{"host":"127.0.0.1","port":7788,"token":"abc"},"logging":{"enabled":false,"persist":false,"level":"debug","file":"logs/app.log","format":"text"},"models":{"providers":{"openai":{"api_base":"https://api.openai.com/v1","auth":"bearer","api_key":"secret","models":["gpt-5"],"timeout_sec":120}}},"tools":{"shell":{"enabled":true},"mcp":{"enabled":false}},"agents":{"defaults":{"model":{"primary":"openai/gpt-5"},"max_tool_iterations":10,"execution":{"run_state_ttl_seconds":3600,"run_state_max":128,"tool_parallel_safe_names":[],"tool_max_parallel_calls":4}},"router":{"enabled":false,"policy":{"intent_max_input_chars":2000,"max_rounds_without_user":3}},"subagents":{}},"channels":{"telegram":{"enabled":true,"token":"bot-token"}},"cron":{"enabled":false},"sentinel":{"enabled":false}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleWebUIConfig(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d: %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "webui config editing is disabled") {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
if hookCalled != 1 {
|
||||
t.Fatalf("expected hook to be called once, got %d", hookCalled)
|
||||
}
|
||||
updated, err := cfgpkg.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if updated.Gateway.Host != "127.0.0.1" {
|
||||
t.Fatalf("expected updated gateway host, got %q", updated.Gateway.Host)
|
||||
}
|
||||
if !updated.Channels.Telegram.Enabled {
|
||||
t.Fatalf("expected telegram channel to remain editable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUIConfigPostSavesNormalizedConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.json")
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
cfg.Logging.Enabled = false
|
||||
cfg.Gateway.Host = "0.0.0.0"
|
||||
cfg.Gateway.Port = 7788
|
||||
cfg.Models.Providers["openai"] = cfgpkg.ProviderConfig{
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
Auth: "bearer",
|
||||
APIKey: "secret",
|
||||
Models: []string{"gpt-5"},
|
||||
TimeoutSec: 120,
|
||||
}
|
||||
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "")
|
||||
srv.SetConfigPath(cfgPath)
|
||||
srv.SetConfigAfterHook(func() error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config?mode=normalized", strings.NewReader(`{"core":{"gateway":{"host":"127.0.0.1","port":18790},"tools":{"shell_enabled":false,"mcp_enabled":true}},"runtime":{"router":{"enabled":true,"strategy":"rules_first","max_hops":2,"default_timeout_sec":90},"providers":{"openai":{"api_base":"https://api.openai.com/v1","auth":"bearer","timeout_sec":150}}}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleWebUIConfig(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
updated, err := cfgpkg.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if updated.Gateway.Host != "127.0.0.1" || updated.Gateway.Port != 18790 {
|
||||
t.Fatalf("expected normalized gateway update, got %s:%d", updated.Gateway.Host, updated.Gateway.Port)
|
||||
}
|
||||
if updated.Tools.Shell.Enabled {
|
||||
t.Fatalf("expected shell tool to be disabled by normalized save")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user