diff --git a/pkg/api/server.go b/pkg/api/server.go index b745ab5..27a91fc 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -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{}{} diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index d27dce1..c7e34ec 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -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") } }