package web import ( "net/http" "os" "path/filepath" "strings" "keel/internal/settings" "github.com/gin-gonic/gin" ) // SetSettings injects the persisted settings, their file path, and the applier // closure (built by main, which owns adapter construction). Mirrors the // controller's Set* injection so web never imports ai/tasks/knowledge. func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) { s.settingsMu.Lock() s.settingsPath = path s.settings = current s.applyFn = apply s.settingsMu.Unlock() } func (s *Server) handleGetSettings(c *gin.Context) { s.settingsMu.Lock() cur := s.settings s.settingsMu.Unlock() c.JSON(http.StatusOK, cur) } // handlePostSettings applies the new settings first, then persists them. The // applier checks the backend before mutating any state, so an invalid backend // yields 400 with nothing saved and the prior wiring intact. If Save fails after // a successful apply, the running adapters are already rewired but the file and // the in-memory copy are left unchanged; that surfaces as 500. func (s *Server) handlePostSettings(c *gin.Context) { var req settings.Settings if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) return } s.applyMu.Lock() defer s.applyMu.Unlock() s.settingsMu.Lock() apply := s.applyFn path := s.settingsPath // aw_url is not exposed in the settings form; preserve the persisted value // so a UI save never blanks a configured AW URL. if req.AWURL == "" { req.AWURL = s.settings.AWURL } // Likewise preserve ambient fields when a caller omits them, so a partial // POST never clobbers a configured ambient dial. if req.AmbientMode == "" { req.AmbientMode = s.settings.AmbientMode } if req.AmbientCadenceSecs <= 0 { req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs } s.settingsMu.Unlock() if apply == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) return } if err := apply(req); err != nil { // Apply validates the backend before mutating anything, so any error // (invalid backend or otherwise) means nothing changed; surface it as 400. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := settings.Save(path, req); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } s.settingsMu.Lock() s.settings = req s.settingsMu.Unlock() s.broadcast() c.JSON(http.StatusOK, req) } type browseEntry struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"is_dir"` } type browseResponse struct { Dir string `json:"dir"` Parent string `json:"parent"` Entries []browseEntry `json:"entries"` } // handleBrowse lists subdirectories plus .md files under dir, so the UI can build // a file picker that returns a real server-side path. Dotfiles are NOT hidden — // the default profile lives under ~/.keel. Localhost-only daemon; no jail // beyond OS permissions (same trust boundary as the rest of the UI). func (s *Server) handleBrowse(c *gin.Context) { dir := strings.TrimSpace(c.Query("dir")) if dir == "" { dir = s.browseStart() } dir = filepath.Clean(dir) infos, err := os.ReadDir(dir) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}} for _, e := range infos { name := e.Name() full := filepath.Join(dir, name) if e.IsDir() { resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true}) } else if strings.HasSuffix(name, ".md") { resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full}) } } c.JSON(http.StatusOK, resp) } // browseStart picks the directory of the current knowledge path, else $HOME. func (s *Server) browseStart() string { s.settingsMu.Lock() kp := s.settings.KnowledgePath s.settingsMu.Unlock() if kp != "" { return filepath.Dir(kp) } if home, err := os.UserHomeDir(); err == nil { return home } return "/" }