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>
282 lines
9.2 KiB
Go
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"})
|
|
}
|