package handlers import ( "fmt" "net/http" "os" "runtime" "strings" "sync" "time" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" "sproutclaw-web/internal/db" "sproutclaw-web/internal/models" "sproutclaw-web/internal/rpc" "sproutclaw-web/internal/services" ) // RegisterSettings registers all settings-related routes. func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) { r.GET("/settings", handleGetSettings(cfg, database)) r.POST("/settings/skills/toggle", handleToggleSkill(cfg, piClient)) r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient)) r.POST("/settings/mcp/server/toggle", handleToggleMCPServer(cfg, piClient)) r.POST("/settings/mcp/tool/toggle", handleToggleMCPTool(cfg, piClient)) r.POST("/settings/reload", handleReload(piClient)) r.POST("/settings/models-config", handleSetModelsConfig(cfg, piClient)) r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient)) r.POST("/settings/avatars", handleSetAvatars(database)) r.POST("/settings/restart", handleRestart()) r.GET("/environment", handleEnvironment(cfg)) r.GET("/environment/tools", handleEnvironmentTools()) } var processStartTime = time.Now() func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile) modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile) userAvatar, _ := database.GetConfig("userAvatarUrl") agentAvatar, _ := database.GetConfig("agentAvatarUrl") c.JSON(http.StatusOK, gin.H{ "systemPrompt": systemPrompt, "systemPromptPath": cfg.SystemPromptFile, "modelsConfig": modelsConfig, "modelsConfigPath": cfg.ModelsConfigFile, "userAvatarUrl": userAvatar, "agentAvatarUrl": agentAvatar, "skills": services.ListSkills(cfg.AgentDir), "extensions": services.ListExtensions(cfg.AgentDir), "extensionsPath": cfg.ExtensionsDir, "mcpTools": services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile), "mcpConfigPath": cfg.McpConfigFile, "mcpCachePath": cfg.McpCacheFile, }) } } func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.SkillToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if req.Path == "" { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "skill path 无效"}) return } if err := services.ToggleSkill(cfg.AgentDir, req.Path, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.ExtensionToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if req.Path == "" { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "extension path 无效"}) return } if err := services.ToggleExtension(cfg.AgentDir, req.Path, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleToggleMCPServer(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.MCPServerToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if req.Server == "" { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server 名称无效"}) return } if err := services.ToggleMCPServer(cfg.McpConfigFile, req.Server, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleToggleMCPTool(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.MCPToolToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if req.Server == "" || req.Tool == "" { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server / tool 名称无效"}) return } if err := services.ToggleMCPTool(cfg.McpConfigFile, req.Server, req.Tool, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleReload(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleSetModelsConfig(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.ModelsConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } // Guard: the field must be present (not just empty) to avoid clobbering. if req.ModelsConfig == nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 modelsConfig 字段"}) return } if err := services.WriteModelsConfig(cfg.ModelsConfigFile, *req.ModelsConfig); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true, "modelsConfigPath": cfg.ModelsConfigFile}) } } func handleSetSystemPrompt(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.SystemPromptRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } // Guard: the field must be present. A missing field would otherwise // silently write an empty string and wipe the prompt file. if req.SystemPrompt == nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 systemPrompt 字段"}) return } if err := services.WriteSystemPrompt(cfg.SystemPromptFile, *req.SystemPrompt); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) c.JSON(http.StatusOK, gin.H{"ok": true, "systemPromptPath": cfg.SystemPromptFile}) } } func handleSetAvatars(database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { var req models.AvatarsSetRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } userURL := normalizeAvatarURL(req.UserAvatarURL) agentURL := normalizeAvatarURL(req.AgentAvatarURL) _ = database.SetConfig("userAvatarUrl", userURL) _ = database.SetConfig("agentAvatarUrl", agentURL) c.JSON(http.StatusOK, gin.H{"ok": true, "userAvatarUrl": userURL, "agentAvatarUrl": agentURL}) } } func handleEnvironment(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { hostname, _ := os.Hostname() cwd, _ := os.Getwd() exe, _ := os.Executable() var memStats runtime.MemStats runtime.ReadMemStats(&memStats) goHeapMb := int64(memStats.HeapAlloc / 1024 / 1024) totalMemMb, freeMemMb := getSysMemMb() osRelease := getOSRelease() uptimeSec := int64(time.Since(processStartTime).Seconds()) piCmd := cfg.PiCmd if len(cfg.PiArgs) > 0 { piCmd = strings.Join(append([]string{cfg.PiCmd}, cfg.PiArgs...), " ") } c.JSON(http.StatusOK, gin.H{ "goVersion": runtime.Version(), "platform": runtime.GOOS, "arch": runtime.GOARCH, "osRelease": osRelease, "hostname": hostname, "numCPU": runtime.NumCPU(), "totalMemMb": totalMemMb, "freeMemMb": freeMemMb, "pid": os.Getpid(), "uptime": uptimeSec, "execPath": exe, "goroutines": runtime.NumGoroutine(), "goHeapMb": goHeapMb, "cwd": cwd, "port": cfg.Port, "agentDir": cfg.AgentDir, "dataDir": cfg.DataDir, "repoRoot": cfg.RepoRoot, "piCmd": piCmd, "memUsagePct": func() string { if totalMemMb == 0 { return "" } used := totalMemMb - freeMemMb pct := float64(used) * 100 / float64(totalMemMb) return fmt.Sprintf("%.1f%%", pct) }(), }) } } // handleEnvironmentTools detects all agent tools and language runtimes in parallel. // This is a separate endpoint because version detection involves subprocess calls. func handleEnvironmentTools() gin.HandlerFunc { return func(c *gin.Context) { var ( agentTools []AgentToolInfo runtimes []RuntimeInfo wg sync.WaitGroup ) wg.Add(2) go func() { defer wg.Done(); agentTools = detectAgentTools() }() go func() { defer wg.Done(); runtimes = detectRuntimes() }() wg.Wait() c.JSON(http.StatusOK, gin.H{ "agentTools": agentTools, "runtimes": runtimes, }) } } func reloadAgent(piClient *rpc.Client) { _, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"}) }