feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
214
backend/internal/handlers/chat.go
Normal file
214
backend/internal/handlers/chat.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
// RegisterChat registers all chat-related routes.
|
||||
func RegisterChat(r *gin.RouterGroup, piClient *rpc.Client) {
|
||||
r.POST("/chat", handleChat(piClient))
|
||||
r.POST("/steer", handleSteer(piClient))
|
||||
r.POST("/follow-up", handleFollowUp(piClient))
|
||||
r.POST("/bash", handleBash(piClient))
|
||||
r.POST("/abort", handleAbort(piClient))
|
||||
r.POST("/abort-retry", handleAbortRetry(piClient))
|
||||
r.POST("/messages", handleMessages(piClient))
|
||||
r.GET("/events", handleSSE(piClient))
|
||||
}
|
||||
|
||||
func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate images
|
||||
if len(req.Images) > 0 {
|
||||
if err := services.ValidateImages(req.Images); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try slash command dispatch
|
||||
result := services.TrySlashDispatch(req.Message)
|
||||
if result.Handled {
|
||||
if result.Message != "" {
|
||||
// unsupported command – return friendly message
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "message": result.Message})
|
||||
return
|
||||
}
|
||||
// supported built-in command – forward to pi CLI as a command type
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: result.Command,
|
||||
Args: result.Args,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "result": resp.Result})
|
||||
return
|
||||
}
|
||||
|
||||
// Regular prompt
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SteerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err := piClient.SubmitPrompt(models.ChatRequest{
|
||||
Message: req.Message,
|
||||
StreamingBehavior: "steer",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFollowUp(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
req.StreamingBehavior = "follow_up"
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBash(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.BashRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "bash",
|
||||
Args: req.Command,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleMessages(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sessionFile": snap.SessionFile,
|
||||
"events": snap.ReplayEvents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.String(http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan []byte, 64)
|
||||
client := &rpc.SSEClient{
|
||||
Ch: ch,
|
||||
Done: c.Request.Context().Done(),
|
||||
}
|
||||
piClient.RegisterSSEClient(client)
|
||||
defer piClient.UnregisterSSEClient(client)
|
||||
|
||||
// send initial keepalive
|
||||
_, _ = io.WriteString(c.Writer, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case raw, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
frame := rpc.FormatSSEEvent(raw)
|
||||
if _, err := c.Writer.Write(frame); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonRaw is a helper to return pre-serialized JSON.
|
||||
func jsonRaw(raw json.RawMessage) any {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v
|
||||
}
|
||||
18
backend/internal/handlers/commands.go
Normal file
18
backend/internal/handlers/commands.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
// RegisterCommands registers the slash commands list route.
|
||||
func RegisterCommands(r *gin.RouterGroup, cfg *config.Config) {
|
||||
r.GET("/commands", func(c *gin.Context) {
|
||||
cmds := services.ListSlashCommands(cfg.AgentDir)
|
||||
c.JSON(http.StatusOK, models.CommandsResponse{Commands: cmds})
|
||||
})
|
||||
}
|
||||
25
backend/internal/handlers/extension_ui.go
Normal file
25
backend/internal/handlers/extension_ui.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
)
|
||||
|
||||
// RegisterExtensionUI registers the extension UI response route.
|
||||
func RegisterExtensionUI(r *gin.RouterGroup, piClient *rpc.Client) {
|
||||
r.POST("/extension-ui-response", func(c *gin.Context) {
|
||||
var req models.ExtensionUIResponseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := piClient.SendExtensionUIResponse(req.ID, req.Response); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
})
|
||||
}
|
||||
77
backend/internal/handlers/models.go
Normal file
77
backend/internal/handlers/models.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
)
|
||||
|
||||
// RegisterModels registers model-related routes.
|
||||
func RegisterModels(r *gin.RouterGroup, cfg *config.Config, piClient *rpc.Client) {
|
||||
r.GET("/models", handleGetModels(cfg, piClient))
|
||||
r.POST("/model", handleSetModel(piClient))
|
||||
r.POST("/thinking", handleSetThinking(piClient))
|
||||
}
|
||||
|
||||
func handleGetModels(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "list_models"})
|
||||
if err != nil {
|
||||
// fallback: read models.json
|
||||
data, ferr := os.ReadFile(cfg.ModelsConfigFile)
|
||||
if ferr != nil {
|
||||
c.JSON(http.StatusOK, models.ModelsResponse{Models: []models.ModelEntry{}})
|
||||
return
|
||||
}
|
||||
var raw any
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
c.JSON(http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetModel(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SetModelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "set_model",
|
||||
Provider: req.Provider,
|
||||
ModelID: req.ModelID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetThinking(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SetThinkingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "set_thinking",
|
||||
Budget: req.Budget,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
191
backend/internal/handlers/sessions.go
Normal file
191
backend/internal/handlers/sessions.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"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"
|
||||
"sproutclaw-web/internal/utils"
|
||||
)
|
||||
|
||||
// RegisterSessions registers all session management routes.
|
||||
func RegisterSessions(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) {
|
||||
r.GET("/sessions", handleListSessions(cfg, database))
|
||||
r.POST("/new-session", handleNewSession(piClient))
|
||||
r.POST("/sessions/history", handleSessionHistory(cfg))
|
||||
r.POST("/sessions/delete", handleDeleteSession(cfg, database))
|
||||
r.POST("/sessions/pin", handlePinSession(cfg, database))
|
||||
r.POST("/sessions/load", handleLoadSession(cfg, piClient))
|
||||
r.POST("/sessions/activate", handleActivateSession(cfg, piClient))
|
||||
r.POST("/sessions/name", handleNameSession(cfg))
|
||||
r.GET("/session-state", handleSessionState(piClient))
|
||||
}
|
||||
|
||||
func handleListSessions(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
summaries, err := services.BuildSessionList(cfg.SessionsDir, database)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []models.SessionSummary{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.SessionsResponse{Sessions: summaries})
|
||||
}
|
||||
}
|
||||
|
||||
func handleNewSession(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "new_session"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionHistory(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.HistoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
lines, err := services.ReadSessionMessages(req.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": lines})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteSession(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.DeleteSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(req.Path); err != nil && !os.IsNotExist(err) {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = database.SetSessionPinned(req.Path, false)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handlePinSession(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.PinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := database.SetSessionPinned(req.Path, req.Pinned); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleLoadSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.LoadSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
lines, err := services.ReadSessionMessages(req.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": lines, "result": jsonRaw(resp.Result)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleActivateSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ActivateSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleNameSession(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.NameSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := services.AppendSessionName(req.Path, req.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionState(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, models.SessionStateResponse{
|
||||
IsStreaming: snap.IsStreaming,
|
||||
SessionFile: snap.SessionFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
215
backend/internal/handlers/settings.go
Normal file
215
backend/internal/handlers/settings.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"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))
|
||||
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.GET("/environment", handleEnvironment(cfg))
|
||||
}
|
||||
|
||||
func handleGetSettings(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
skills := services.ListSkills(cfg.AgentDir)
|
||||
extensions := services.ListExtensions(cfg.AgentDir)
|
||||
mcpServers := services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile)
|
||||
systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile)
|
||||
modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile)
|
||||
|
||||
c.JSON(http.StatusOK, models.SettingsResponse{
|
||||
Skills: skills,
|
||||
Extensions: extensions,
|
||||
MCPServers: mcpServers,
|
||||
SystemPrompt: systemPrompt,
|
||||
ModelsConfig: modelsConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleSkill(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleExtension(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{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 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, models.OKResponse{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 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, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleReload(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{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
|
||||
}
|
||||
if err := services.WriteModelsConfig(cfg.ModelsConfigFile, req.Content); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err := services.WriteSystemPrompt(cfg.SystemPromptFile, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if req.UserAvatar != "" {
|
||||
_ = database.SetConfig("userAvatar", req.UserAvatar)
|
||||
}
|
||||
if req.AssistantAvatar != "" {
|
||||
_ = database.SetConfig("assistantAvatar", req.AssistantAvatar)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
lanAddrs := getLANAddresses()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
"version": runtime.Version(),
|
||||
"port": cfg.Port,
|
||||
"lan": lanAddrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func reloadAgent(piClient *rpc.Client) {
|
||||
_, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"})
|
||||
}
|
||||
|
||||
func getLANAddresses() []string {
|
||||
// simplified – returns first non-loopback IPv4
|
||||
var addrs []string
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return addrs
|
||||
}
|
||||
_ = hostname
|
||||
// Use a simple approach: read network interfaces via fmt
|
||||
// For a full implementation, net.InterfaceAddrs() would be used
|
||||
ifaces := getNetworkAddresses()
|
||||
for _, a := range ifaces {
|
||||
if !strings.HasPrefix(a, "127.") && !strings.HasPrefix(a, "::") {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func getNetworkAddresses() []string {
|
||||
var result []string
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname != "" {
|
||||
result = append(result, fmt.Sprintf("%s (hostname)", hostname))
|
||||
}
|
||||
return result
|
||||
}
|
||||
92
backend/internal/handlers/webui_config.go
Normal file
92
backend/internal/handlers/webui_config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// RegisterWebuiConfig registers WebUI config and avatar routes.
|
||||
func RegisterWebuiConfig(r *gin.RouterGroup, database *db.DB) {
|
||||
r.GET("/avatars", handleGetAvatars(database))
|
||||
r.GET("/webui/config", handleGetWebuiConfig(database))
|
||||
r.POST("/webui/config", handleSetWebuiConfig(database))
|
||||
r.POST("/webui/config/delete", handleDeleteWebuiConfig(database))
|
||||
}
|
||||
|
||||
func handleGetAvatars(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, _ := database.GetConfig("userAvatar")
|
||||
assistant, _ := database.GetConfig("assistantAvatar")
|
||||
c.JSON(http.StatusOK, models.AvatarsResponse{
|
||||
UserAvatar: normalizeAvatarURL(user),
|
||||
AssistantAvatar: normalizeAvatarURL(assistant),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAvatarURL(u string) string {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleGetWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
if key != "" {
|
||||
val, _ := database.GetConfig(key)
|
||||
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
|
||||
return
|
||||
}
|
||||
all, err := database.AllConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"entries": all})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.WebuiConfigSetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Entries != nil {
|
||||
for k, v := range req.Entries {
|
||||
if err := database.SetConfig(k, v); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if req.Key != "" {
|
||||
if err := database.SetConfig(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.WebuiConfigDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := database.DeleteConfig(req.Key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user