Files
sproutclaw-web/backend/internal/handlers/settings.go
shumengya bd2251e89a fix: restart backend under systemd and use .sproutclaw agent dir
Restart via systemctl when running as a service, set SPROUTCLAW_CODING_AGENT_DIR
for the RPC subprocess, and add a frontend poll timeout for failed restarts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 10:29:28 +08:00

282 lines
9.2 KiB
Go

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