Files
antidrift/internal/web/settings_handlers.go
T

136 lines
4.0 KiB
Go

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 "/"
}