feat: init sproutclaw-web — Go+Gin backend + React frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 20:16:09 +08:00
commit c33b143176
109 changed files with 18773 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
package config
import (
"flag"
"os"
"path/filepath"
"runtime"
)
type Config struct {
Port int
AgentDir string
PiCmd string // pi CLI 启动命令(完整路径)
PiArgs []string
// 派生路径(从 AgentDir 推导)
SessionsDir string
SystemPromptFile string
ModelsConfigFile string
McpConfigFile string
McpCacheFile string
SettingsFile string
SkillsDir string
SkillsDisabledDir string
ExtensionsDir string
FrontendDist string // ../frontend/dist 相对于可执行文件
}
func New() *Config {
port := flag.Int("port", 19133, "HTTP port")
agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)")
piCmd := flag.String("pi-cmd", "", "pi CLI executable path")
flag.Parse()
if *agentDir == "" {
// 默认值:当前目录的 .pi/agent
cwd, _ := os.Getwd()
*agentDir = filepath.Join(cwd, ".pi", "agent")
}
cfg := &Config{
Port: *port,
AgentDir: *agentDir,
PiCmd: *piCmd,
}
cfg.derivePaths()
return cfg
}
func (c *Config) derivePaths() {
a := c.AgentDir
c.SessionsDir = filepath.Join(a, "sessions")
c.SystemPromptFile = filepath.Join(a, "AGENTS.md")
c.ModelsConfigFile = filepath.Join(a, "models.json")
c.McpConfigFile = filepath.Join(a, "mcp.json")
c.McpCacheFile = filepath.Join(a, "mcp-cache.json")
c.SettingsFile = filepath.Join(a, "settings.json")
c.SkillsDir = filepath.Join(a, "skills")
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
c.ExtensionsDir = filepath.Join(a, "extensions")
// frontend/dist 相对于二进制所在目录的上级
exe, err := os.Executable()
if err != nil {
exe, _ = os.Getwd()
}
exeDir := filepath.Dir(exe)
c.FrontendDist = filepath.Join(exeDir, "..", "frontend", "dist")
}
func Platform() string {
return runtime.GOOS
}
func Arch() string {
return runtime.GOARCH
}

122
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,122 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
sql *sql.DB
DbPath string
}
const schema = `
CREATE TABLE IF NOT EXISTS webui_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webui_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
func Init(dataDir string) (*DB, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
dbPath := filepath.Join(dataDir, "webui.db")
sqlDB, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := sqlDB.Exec(schema); err != nil {
return nil, fmt.Errorf("init schema: %w", err)
}
// ensure schema version
_, _ = sqlDB.Exec(`INSERT OR IGNORE INTO webui_meta (key,value) VALUES ('schema_version','1')`)
return &DB{sql: sqlDB, DbPath: dbPath}, nil
}
func (d *DB) Close() { _ = d.sql.Close() }
// ── Generic config key-value ──────────────────────────────────────────────────
func (d *DB) GetConfig(key string) (string, bool) {
var val string
err := d.sql.QueryRow(`SELECT value FROM webui_config WHERE key=?`, key).Scan(&val)
if err != nil {
return "", false
}
return val, true
}
func (d *DB) SetConfig(key, value string) error {
_, err := d.sql.Exec(`INSERT OR REPLACE INTO webui_config (key,value) VALUES (?,?)`, key, value)
return err
}
func (d *DB) DeleteConfig(key string) error {
_, err := d.sql.Exec(`DELETE FROM webui_config WHERE key=?`, key)
return err
}
func (d *DB) AllConfig() (map[string]string, error) {
rows, err := d.sql.Query(`SELECT key,value FROM webui_config`)
if err != nil {
return nil, err
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
m[k] = v
}
return m, nil
}
// ── Pinned sessions (stored as JSON array in webui_config) ───────────────────
const pinnedKey = "__pinned_sessions__"
func (d *DB) GetPinnedSessions() ([]string, error) {
val, ok := d.GetConfig(pinnedKey)
if !ok {
return nil, nil
}
var paths []string
if err := json.Unmarshal([]byte(val), &paths); err != nil {
return nil, err
}
return paths, nil
}
func (d *DB) SetSessionPinned(path string, pinned bool) error {
paths, _ := d.GetPinnedSessions()
set := map[string]struct{}{}
for _, p := range paths {
set[p] = struct{}{}
}
if pinned {
set[path] = struct{}{}
} else {
delete(set, path)
}
newPaths := make([]string, 0, len(set))
for p := range set {
newPaths = append(newPaths, p)
}
b, err := json.Marshal(newPaths)
if err != nil {
return err
}
return d.SetConfig(pinnedKey, string(b))
}

View 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
}

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

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

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

View 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,
})
}
}

View 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
}

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

View File

@@ -0,0 +1,17 @@
package middleware
import "github.com/gin-gonic/gin"
// CORS allows all origins (needed for desktop clients and dev server proxy).
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,263 @@
package models
import "encoding/json"
// ── RPC ──────────────────────────────────────────────────────────────────────
type RPCCommand struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
// prompt fields
Text string `json:"text,omitempty"`
Images []ChatImage `json:"images,omitempty"`
// model / thinking fields
Provider string `json:"provider,omitempty"`
ModelID string `json:"modelId,omitempty"`
Budget *int `json:"budget,omitempty"`
// session fields
Path string `json:"path,omitempty"`
Name string `json:"name,omitempty"`
// streaming behaviour
StreamingBehavior string `json:"streamingBehavior,omitempty"`
// slash dispatch passthrough
Args string `json:"args,omitempty"`
// extension ui
Response json.RawMessage `json:"response,omitempty"`
}
type RPCResponse struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// Raw event line forwarded to SSE clients as-is
type RPCEvent struct {
Type string `json:"type"`
Raw json.RawMessage `json:"-"`
}
// ── Chat ─────────────────────────────────────────────────────────────────────
type ChatImage struct {
MediaType string `json:"mediaType"`
Data string `json:"data"` // base64
}
type ChatRequest struct {
Message string `json:"message"`
Images []ChatImage `json:"images,omitempty"`
StreamingBehavior string `json:"streamingBehavior,omitempty"`
}
type SteerRequest struct {
Message string `json:"message"`
}
type BashRequest struct {
Command string `json:"command"`
}
type MessagesRequest struct {
Path string `json:"path"`
}
// ── Sessions ─────────────────────────────────────────────────────────────────
type SessionSummary struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
FirstMessage string `json:"firstMessage,omitempty"`
MessageCount int `json:"messageCount,omitempty"`
Modified string `json:"modified,omitempty"`
Created string `json:"created,omitempty"`
Pinned bool `json:"pinned,omitempty"`
}
type SessionsResponse struct {
Sessions []SessionSummary `json:"sessions"`
}
type SessionStateResponse struct {
IsStreaming bool `json:"isStreaming"`
SessionFile string `json:"sessionFile,omitempty"`
}
type LoadSessionRequest struct {
Path string `json:"path"`
}
type ActivateSessionRequest struct {
Path string `json:"path"`
}
type DeleteSessionRequest struct {
Path string `json:"path"`
}
type PinSessionRequest struct {
Path string `json:"path"`
Pinned bool `json:"pinned"`
}
type NameSessionRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
type HistoryRequest struct {
Path string `json:"path"`
}
// ── Models ───────────────────────────────────────────────────────────────────
type ModelEntry struct {
Provider string `json:"provider"`
ModelID string `json:"modelId"`
DisplayName string `json:"displayName,omitempty"`
IsDefault bool `json:"isDefault,omitempty"`
}
type ModelsResponse struct {
Models []ModelEntry `json:"models"`
Current *ModelEntry `json:"current,omitempty"`
}
type SetModelRequest struct {
Provider string `json:"provider"`
ModelID string `json:"modelId"`
}
type SetThinkingRequest struct {
Budget *int `json:"budget"` // nil = disable
}
// ── Commands ─────────────────────────────────────────────────────────────────
type SlashCommand struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Source string `json:"source"` // builtin | extension | skill | prompt
}
type CommandsResponse struct {
Commands []SlashCommand `json:"commands"`
}
// ── WebUI Config ──────────────────────────────────────────────────────────────
type AvatarsResponse struct {
UserAvatar string `json:"userAvatar,omitempty"`
AssistantAvatar string `json:"assistantAvatar,omitempty"`
}
type WebuiConfigResponse struct {
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
// batch response
Entries map[string]string `json:"entries,omitempty"`
}
type WebuiConfigSetRequest struct {
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
// batch
Entries map[string]string `json:"entries,omitempty"`
}
type WebuiConfigDeleteRequest struct {
Key string `json:"key"`
}
// ── Settings ──────────────────────────────────────────────────────────────────
type SkillInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Enabled bool `json:"enabled"`
Path string `json:"path"`
}
type ExtensionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
Enabled bool `json:"enabled"`
Source string `json:"source"` // local | npm
Commands []string `json:"commands,omitempty"`
}
type MCPTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Disabled bool `json:"disabled"`
}
type MCPServer struct {
Name string `json:"name"`
Disabled bool `json:"disabled"`
Tools []MCPTool `json:"tools,omitempty"`
}
type SettingsResponse struct {
Skills []SkillInfo `json:"skills"`
Extensions []ExtensionInfo `json:"extensions"`
MCPServers []MCPServer `json:"mcpServers"`
SystemPrompt string `json:"systemPrompt,omitempty"`
ModelsConfig string `json:"modelsConfig,omitempty"`
}
type ToggleRequest struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
type MCPServerToggleRequest struct {
Server string `json:"server"`
Enabled bool `json:"enabled"`
}
type MCPToolToggleRequest struct {
Server string `json:"server"`
Tool string `json:"tool"`
Enabled bool `json:"enabled"`
}
type SystemPromptRequest struct {
Content string `json:"content"`
}
type ModelsConfigRequest struct {
Content string `json:"content"`
}
type AvatarsSetRequest struct {
UserAvatar string `json:"userAvatar,omitempty"`
AssistantAvatar string `json:"assistantAvatar,omitempty"`
}
type EnvironmentResponse struct {
Platform string `json:"platform"`
Arch string `json:"arch"`
Hostname string `json:"hostname"`
Version string `json:"version"`
}
// ── Extension UI ──────────────────────────────────────────────────────────────
type ExtensionUIResponseRequest struct {
ID string `json:"id"`
Response json.RawMessage `json:"response"`
}
// ── Generic ───────────────────────────────────────────────────────────────────
type OKResponse struct {
OK bool `json:"ok"`
}
type ErrorResponse struct {
Error string `json:"error"`
}

View File

@@ -0,0 +1,304 @@
package rpc
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"strings"
"sync"
"time"
"github.com/google/uuid"
"sproutclaw-web/internal/models"
)
const (
cmdTimeout = 60 * time.Second
promptTimeout = 30 * time.Second
)
// SSEClient represents a connected browser SSE client.
type SSEClient struct {
Ch chan []byte
Done <-chan struct{}
}
// RunSnapshot holds a snapshot of the current pi agent run state.
type RunSnapshot struct {
IsStreaming bool
SessionFile string
ReplayEvents []json.RawMessage
}
// Client manages a pi CLI subprocess and bridges its JSON-RPC protocol
// to HTTP handlers and SSE clients.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
pending map[string]chan models.RPCResponse
sseClients map[*SSEClient]struct{}
replay []json.RawMessage // events in current agent run
isRunning bool
sessionFile string
}
// NewClient starts the pi CLI subprocess and begins reading its stdout.
// onExit is called with the exit code when the subprocess terminates.
func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) (*Client, error) {
if piCmd == "" {
return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
}
args := append(piArgs, "--rpc")
cmd := exec.Command(piCmd, args...)
cmd.Dir = agentDir
cmd.Env = append(cmd.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
cmd.Stderr = log.Writer()
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start pi cli: %w", err)
}
c := &Client{
cmd: cmd,
stdin: stdin,
pending: make(map[string]chan models.RPCResponse),
sseClients: make(map[*SSEClient]struct{}),
}
go c.readLoop(stdout)
go func() {
_ = cmd.Wait()
code := 0
if cmd.ProcessState != nil {
code = cmd.ProcessState.ExitCode()
}
onExit(code)
}()
return c, nil
}
// readLoop reads stdout line-by-line and dispatches messages.
func (c *Client) readLoop(r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
c.dispatch(line)
}
}
// dispatch parses one JSON line from stdout and routes it.
func (c *Client) dispatch(raw []byte) {
var msg struct {
Type string `json:"type"`
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
switch msg.Type {
case "response":
var resp models.RPCResponse
if err := json.Unmarshal(raw, &resp); err == nil {
if ch, ok := c.pending[resp.ID]; ok {
delete(c.pending, resp.ID)
ch <- resp
}
}
case "agent_start":
c.isRunning = true
c.replay = nil
// try to extract session path
var ev struct {
SessionFile string `json:"sessionFile"`
}
if err := json.Unmarshal(raw, &ev); err == nil && ev.SessionFile != "" {
c.sessionFile = ev.SessionFile
}
rawCopy := append([]byte(nil), raw...)
c.replay = append(c.replay, rawCopy)
c.broadcastLocked(rawCopy)
case "agent_end":
c.isRunning = false
rawCopy := append([]byte(nil), raw...)
c.replay = append(c.replay, rawCopy)
c.broadcastLocked(rawCopy)
default:
rawCopy := append([]byte(nil), raw...)
if c.isRunning {
c.replay = append(c.replay, rawCopy)
}
c.broadcastLocked(rawCopy)
}
}
// broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu.
func (c *Client) broadcastLocked(raw []byte) {
for client := range c.sseClients {
select {
case client.Ch <- raw:
default:
// slow client: drop
}
}
}
// send writes a JSON command to the pi CLI stdin.
func (c *Client) send(cmd models.RPCCommand) error {
b, err := json.Marshal(cmd)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
_, err = fmt.Fprintf(c.stdin, "%s\n", b)
return err
}
// SendCmd sends a command and waits for the matching response.
func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
if cmd.ID == "" {
cmd.ID = uuid.New().String()
}
ch := make(chan models.RPCResponse, 1)
c.mu.Lock()
c.pending[cmd.ID] = ch
c.mu.Unlock()
if err := c.send(cmd); err != nil {
c.mu.Lock()
delete(c.pending, cmd.ID)
c.mu.Unlock()
return models.RPCResponse{}, err
}
select {
case resp := <-ch:
return resp, nil
case <-time.After(cmdTimeout):
c.mu.Lock()
delete(c.pending, cmd.ID)
c.mu.Unlock()
return models.RPCResponse{}, fmt.Errorf("rpc timeout: %s", cmd.Type)
}
}
// SubmitPrompt sends a prompt without waiting for a response.
func (c *Client) SubmitPrompt(req models.ChatRequest) error {
cmd := models.RPCCommand{
Type: "prompt",
ID: uuid.New().String(),
Text: req.Message,
Images: req.Images,
StreamingBehavior: req.StreamingBehavior,
}
return c.send(cmd)
}
// GetSnapshot returns the current run snapshot.
func (c *Client) GetSnapshot() RunSnapshot {
c.mu.Lock()
defer c.mu.Unlock()
replay := make([]json.RawMessage, len(c.replay))
copy(replay, c.replay)
return RunSnapshot{
IsStreaming: c.isRunning,
SessionFile: c.sessionFile,
ReplayEvents: replay,
}
}
// RegisterSSEClient adds a client to the broadcast set and replays buffered events.
func (c *Client) RegisterSSEClient(client *SSEClient) {
c.mu.Lock()
c.sseClients[client] = struct{}{}
// replay current run events
replay := make([]json.RawMessage, len(c.replay))
copy(replay, c.replay)
c.mu.Unlock()
// send replay in a goroutine so we don't hold the lock
go func() {
for _, ev := range replay {
select {
case client.Ch <- ev:
case <-client.Done:
return
}
}
}()
}
// UnregisterSSEClient removes a client from the broadcast set.
func (c *Client) UnregisterSSEClient(client *SSEClient) {
c.mu.Lock()
delete(c.sseClients, client)
c.mu.Unlock()
}
// SendExtensionUIResponse forwards a UI response back to the pi CLI.
func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error {
return c.send(models.RPCCommand{
Type: "extension_ui_response",
ID: id,
Response: response,
})
}
// Stop terminates the pi CLI subprocess.
func (c *Client) Stop() {
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
}
// buildEnv returns os.Environ() with extra variables appended.
func (c *Client) Environ() []string {
return c.cmd.Environ()
}
// helper
func mustMarshal(v any) []byte {
b, _ := json.Marshal(v)
return b
}
// IsRunning returns true when the agent is actively streaming.
func (c *Client) IsRunning() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.isRunning
}
// FormatSSEEvent formats a raw JSON payload as an SSE data line.
func FormatSSEEvent(raw []byte) []byte {
// strip newlines so the SSE frame stays on one logical line
trimmed := strings.TrimRight(string(raw), "\n\r")
return []byte("data: " + trimmed + "\n\n")
}

View File

@@ -0,0 +1,91 @@
package services
import (
"os"
"path/filepath"
"strings"
"sproutclaw-web/internal/models"
)
// builtinCommands are the slash commands the WebUI handles natively.
var builtinCommands = []models.SlashCommand{
{Name: "new", Description: "开启新会话", Source: "builtin"},
{Name: "compact", Description: "压缩会话历史", Source: "builtin"},
{Name: "reload", Description: "重载配置", Source: "builtin"},
{Name: "clone", Description: "克隆当前会话", Source: "builtin"},
{Name: "name", Description: "重命名当前会话", Source: "builtin"},
{Name: "model", Description: "切换模型", Source: "builtin"},
{Name: "session", Description: "显示会话统计", Source: "builtin"},
{Name: "export", Description: "导出为 HTML", Source: "builtin"},
{Name: "copy", Description: "复制最后一条助手消息", Source: "builtin"},
{Name: "webui", Description: "WebUI 控制", Source: "builtin"},
}
// ListSlashCommands aggregates all slash commands visible in the WebUI.
func ListSlashCommands(agentDir string) []models.SlashCommand {
cmds := make([]models.SlashCommand, len(builtinCommands))
copy(cmds, builtinCommands)
// scan skills dir for SKILL.md files
skillsDir := filepath.Join(agentDir, "skills")
cmds = append(cmds, scanSkillCommands(skillsDir)...)
return cmds
}
func scanSkillCommands(skillsDir string) []models.SlashCommand {
entries, err := os.ReadDir(skillsDir)
if err != nil {
return nil
}
var cmds []models.SlashCommand
for _, e := range entries {
if !e.IsDir() {
continue
}
skillMd := filepath.Join(skillsDir, e.Name(), "SKILL.md")
name, desc := parseSkillMD(skillMd)
if name == "" {
name = e.Name()
}
cmds = append(cmds, models.SlashCommand{
Name: name,
Description: desc,
Source: "skill",
})
}
return cmds
}
func parseSkillMD(path string) (name, desc string) {
data, err := os.ReadFile(path)
if err != nil {
return "", ""
}
content := string(data)
// parse frontmatter between --- delimiters
if !strings.HasPrefix(content, "---") {
return "", ""
}
parts := strings.SplitN(content, "---", 3)
if len(parts) < 3 {
return "", ""
}
fm := parts[1]
for _, line := range strings.Split(fm, "\n") {
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
}
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
switch k {
case "name":
name = v
case "description":
desc = v
}
}
return name, desc
}

View File

@@ -0,0 +1,105 @@
package services
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sproutclaw-web/internal/models"
)
// ListExtensions reads the extensions directory and returns metadata.
func ListExtensions(agentDir string) []models.ExtensionInfo {
extDir := filepath.Join(agentDir, "extensions")
entries, err := os.ReadDir(extDir)
if err != nil {
return nil
}
var exts []models.ExtensionInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
ext := buildExtensionInfo(filepath.Join(extDir, e.Name()), e.Name())
exts = append(exts, ext)
}
return exts
}
func buildExtensionInfo(path, id string) models.ExtensionInfo {
info := models.ExtensionInfo{
ID: id,
Name: id,
Enabled: true, // assume enabled unless configured otherwise
Source: "local",
}
// try package.json for version/name
pkgPath := filepath.Join(path, "package.json")
if data, err := os.ReadFile(pkgPath); err == nil {
var pkg struct {
Name string `json:"name"`
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) == nil {
if pkg.Name != "" {
info.Name = pkg.Name
}
info.Version = pkg.Version
// detect npm packages
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
info.Source = "npm"
}
}
}
return info
}
// ToggleExtension is a placeholder full implementation requires settings.json management.
func ToggleExtension(agentDir, id string, enable bool) error {
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
}
func updateSettingsExtensions(settingsPath, id string, enable bool) error {
data, err := os.ReadFile(settingsPath)
if err != nil && !os.IsNotExist(err) {
return err
}
var settings map[string]any
if len(data) > 0 {
if err := json.Unmarshal(data, &settings); err != nil {
settings = map[string]any{}
}
} else {
settings = map[string]any{}
}
// extensions is a list of enabled extension IDs
extList, _ := settings["extensions"].([]any)
set := map[string]bool{}
for _, e := range extList {
if s, ok := e.(string); ok {
set[s] = true
}
}
if enable {
set[id] = true
} else {
delete(set, id)
}
newList := make([]string, 0, len(set))
for k := range set {
newList = append(newList, k)
}
settings["extensions"] = newList
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
}

View File

@@ -0,0 +1,42 @@
package services
import (
"encoding/base64"
"fmt"
"strings"
"sproutclaw-web/internal/models"
)
const (
maxImages = 8
maxImageSize = 10 * 1024 * 1024 // 10 MB
)
var allowedImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// ValidateImages checks image count, type, and base64-decoded size.
func ValidateImages(images []models.ChatImage) error {
if len(images) > maxImages {
return fmt.Errorf("最多支持 %d 张图片,当前 %d 张", maxImages, len(images))
}
for i, img := range images {
mt := strings.ToLower(img.MediaType)
if !allowedImageTypes[mt] {
return fmt.Errorf("图片 %d 类型不支持: %s", i+1, img.MediaType)
}
decoded, err := base64.StdEncoding.DecodeString(img.Data)
if err != nil {
return fmt.Errorf("图片 %d base64 解码失败", i+1)
}
if len(decoded) > maxImageSize {
return fmt.Errorf("图片 %d 超过 10MB 限制", i+1)
}
}
return nil
}

View File

@@ -0,0 +1,133 @@
package services
import (
"encoding/json"
"os"
"sproutclaw-web/internal/models"
)
type mcpConfig struct {
MCPServers map[string]any `json:"mcpServers"`
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
ExcludeTools []string `json:"excludeTools,omitempty"`
}
// ReadMCPServers loads mcp.json and returns the list of servers with their tools.
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
cfg := loadMCPConfig(mcpConfigPath)
cache := loadMCPCache(mcpCachePath)
disabledSet := map[string]bool{}
for _, s := range cfg.MCPServersDisabled {
disabledSet[s] = true
}
excludeSet := map[string]bool{}
for _, t := range cfg.ExcludeTools {
excludeSet[t] = true
}
var servers []models.MCPServer
for name := range cfg.MCPServers {
srv := models.MCPServer{
Name: name,
Disabled: disabledSet[name],
}
// add tools from cache
if toolNames, ok := cache[name]; ok {
for _, t := range toolNames {
srv.Tools = append(srv.Tools, models.MCPTool{
Name: t,
Disabled: excludeSet[name+"/"+t] || excludeSet[t],
})
}
}
servers = append(servers, srv)
}
return servers
}
// ToggleMCPServer enables or disables a server.
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
cfg := loadMCPConfig(mcpConfigPath)
set := map[string]bool{}
for _, s := range cfg.MCPServersDisabled {
set[s] = true
}
if enable {
delete(set, serverName)
} else {
set[serverName] = true
}
cfg.MCPServersDisabled = keys(set)
return saveMCPConfig(mcpConfigPath, cfg)
}
// ToggleMCPTool enables or disables a specific tool on a server.
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
cfg := loadMCPConfig(mcpConfigPath)
key := serverName + "/" + toolName
set := map[string]bool{}
for _, t := range cfg.ExcludeTools {
set[t] = true
}
if enable {
delete(set, key)
} else {
set[key] = true
}
cfg.ExcludeTools = keys(set)
return saveMCPConfig(mcpConfigPath, cfg)
}
func loadMCPConfig(path string) mcpConfig {
data, err := os.ReadFile(path)
if err != nil {
return mcpConfig{MCPServers: map[string]any{}}
}
var cfg mcpConfig
_ = json.Unmarshal(data, &cfg)
if cfg.MCPServers == nil {
cfg.MCPServers = map[string]any{}
}
return cfg
}
func saveMCPConfig(path string, cfg mcpConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o644)
}
func loadMCPCache(path string) map[string][]string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
// cache format: { "serverName": { "tools": [{ "name": "..." }] } }
var raw map[string]struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil
}
result := map[string][]string{}
for srv, info := range raw {
for _, t := range info.Tools {
result[srv] = append(result[srv], t.Name)
}
}
return result
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -0,0 +1,28 @@
package services
import (
"encoding/json"
"os"
)
// ReadModelsConfig reads models.json as a raw string.
func ReadModelsConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "{}", nil
}
return string(data), err
}
// WriteModelsConfig validates JSON and writes models.json.
func WriteModelsConfig(path, content string) error {
var v any
if err := json.Unmarshal([]byte(content), &v); err != nil {
return err
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(pretty, '\n'), 0o644)
}

View File

@@ -0,0 +1,171 @@
package services
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
"sproutclaw-web/internal/db"
"sproutclaw-web/internal/models"
)
// jsonlLine is a minimal parse of one line in a session JSONL file.
type jsonlLine struct {
Type string `json:"type"`
// session header
ID string `json:"id"`
Created string `json:"created"`
// message
Role string `json:"role"`
Content any `json:"content"`
// session_info
Name string `json:"name"`
}
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
entries, err := os.ReadDir(sessionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
pinned, _ := database.GetPinnedSessions()
pinnedSet := map[string]struct{}{}
for _, p := range pinned {
pinnedSet[p] = struct{}{}
}
var summaries []models.SessionSummary
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
path := filepath.Join(sessionsDir, e.Name())
sum, err := readSessionSummary(path)
if err != nil {
continue
}
_, sum.Pinned = pinnedSet[path]
sum.Path = path
summaries = append(summaries, sum)
}
// Sort: pinned first, then by modified desc
sort.SliceStable(summaries, func(i, j int) bool {
pi, pj := summaries[i].Pinned, summaries[j].Pinned
if pi != pj {
return pi
}
return summaries[i].Modified > summaries[j].Modified
})
return summaries, nil
}
func readSessionSummary(path string) (models.SessionSummary, error) {
f, err := os.Open(path)
if err != nil {
return models.SessionSummary{}, err
}
defer f.Close()
info, _ := f.Stat()
modified := ""
if info != nil {
modified = info.ModTime().UTC().Format(time.RFC3339)
}
var sum models.SessionSummary
sum.Modified = modified
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
msgCount := 0
for scanner.Scan() {
var line jsonlLine
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
continue
}
switch line.Type {
case "session":
sum.Created = line.Created
case "session_info":
if line.Name != "" {
sum.Name = line.Name
}
case "message":
msgCount++
if sum.FirstMessage == "" && line.Role == "user" {
sum.FirstMessage = extractTextContent(line.Content)
}
}
}
sum.MessageCount = msgCount
return sum, nil
}
func extractTextContent(content any) string {
switch v := content.(type) {
case string:
return truncate(v, 120)
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
if m["type"] == "text" {
if t, ok := m["text"].(string); ok {
return truncate(t, 120)
}
}
}
}
}
return ""
}
func truncate(s string, n int) string {
if len([]rune(s)) <= n {
return s
}
runes := []rune(s)
return string(runes[:n]) + "…"
}
// ReadSessionMessages reads all lines from a JSONL session file.
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var lines []json.RawMessage
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
for scanner.Scan() {
raw := append([]byte(nil), scanner.Bytes()...)
lines = append(lines, raw)
}
return lines, nil
}
// AppendSessionName appends a session_info rename record to the JSONL file.
func AppendSessionName(path, name string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
record := map[string]string{"type": "session_info", "name": name}
b, err := json.Marshal(record)
if err != nil {
return err
}
_, err = f.Write(append(b, '\n'))
return err
}

View File

@@ -0,0 +1,72 @@
package services
import (
"fmt"
"os"
"path/filepath"
"strings"
"sproutclaw-web/internal/models"
)
// ListSkills returns all skills (enabled and disabled) under agentDir.
func ListSkills(agentDir string) []models.SkillInfo {
var skills []models.SkillInfo
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills"), true)...)
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills-disabled"), false)...)
return skills
}
func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var skills []models.SkillInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
skillPath := filepath.Join(dir, e.Name())
name, desc := parseSkillMD(filepath.Join(skillPath, "SKILL.md"))
if name == "" {
name = e.Name()
}
skills = append(skills, models.SkillInfo{
Name: name,
Description: desc,
Enabled: enabled,
Path: skillPath,
})
}
return skills
}
// ToggleSkill moves a skill between skills/ and skills-disabled/ directories.
func ToggleSkill(agentDir, skillPath string, enable bool) error {
skillsDir := filepath.Join(agentDir, "skills")
disabledDir := filepath.Join(agentDir, "skills-disabled")
if !isUnderDir(skillPath, skillsDir) && !isUnderDir(skillPath, disabledDir) {
return fmt.Errorf("skill path not in managed directories")
}
name := filepath.Base(skillPath)
var src, dst string
if enable {
src = filepath.Join(disabledDir, name)
dst = filepath.Join(skillsDir, name)
} else {
src = filepath.Join(skillsDir, name)
dst = filepath.Join(disabledDir, name)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
func isUnderDir(path, dir string) bool {
return strings.HasPrefix(filepath.Clean(path), filepath.Clean(dir))
}

View File

@@ -0,0 +1,57 @@
package services
import (
"strings"
)
// supportedWebUICommands is the whitelist of commands the WebUI handles.
var supportedWebUICommands = map[string]bool{
"compact": true,
"new": true,
"reload": true,
"clone": true,
"name": true,
"model": true,
"session": true,
"export": true,
"copy": true,
}
// SlashDispatchResult holds the parsed result of a slash command attempt.
type SlashDispatchResult struct {
Handled bool
Command string
Args string
// For unsupported commands, return a friendly message
Message string
}
// TrySlashDispatch checks if the message is a slash command the WebUI supports.
func TrySlashDispatch(message string) SlashDispatchResult {
if !strings.HasPrefix(message, "/") {
return SlashDispatchResult{}
}
// strip leading slash and split
trimmed := strings.TrimPrefix(message, "/")
parts := strings.SplitN(trimmed, " ", 2)
cmd := strings.ToLower(parts[0])
args := ""
if len(parts) > 1 {
args = strings.TrimSpace(parts[1])
}
if !supportedWebUICommands[cmd] {
return SlashDispatchResult{
Handled: true,
Command: cmd,
Args: args,
Message: "该命令在 WebUI 模式下暂不支持,请在终端中使用",
}
}
return SlashDispatchResult{
Handled: true,
Command: cmd,
Args: args,
}
}

View File

@@ -0,0 +1,17 @@
package services
import "os"
// ReadSystemPrompt reads AGENTS.md content.
func ReadSystemPrompt(path string) (string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", nil
}
return string(data), err
}
// WriteSystemPrompt writes AGENTS.md content.
func WriteSystemPrompt(path, content string) error {
return os.WriteFile(path, []byte(content), 0o644)
}

View File

@@ -0,0 +1,68 @@
package static
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// Register attaches the static file handler to the Gin router.
// distDir is the path to frontend/dist/.
func Register(r *gin.Engine, distDir string) {
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Skip API routes they're handled elsewhere
if strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// Try to serve a real file
fsPath := filepath.Join(distDir, filepath.FromSlash(path))
if info, err := os.Stat(fsPath); err == nil && !info.IsDir() {
serveFile(c, fsPath)
return
}
// SPA fallback: serve index.html
indexPath := filepath.Join(distDir, "index.html")
if _, err := os.Stat(indexPath); err != nil {
c.String(http.StatusNotFound, "frontend not built run npm run build:web in frontend/")
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.File(indexPath)
})
}
func serveFile(c *gin.Context, fsPath string) {
name := filepath.Base(fsPath)
ext := strings.ToLower(filepath.Ext(name))
if name == "index.html" || strings.HasSuffix(name, ".webmanifest") {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
} else if isHashedAsset(name, ext) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else if ext == ".woff" || ext == ".woff2" || ext == ".ttf" || ext == ".otf" {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "public, max-age=3600")
}
// Gin's File() handles Content-Type and ETag automatically
c.File(fsPath)
}
// isHashedAsset returns true if the filename looks like a Vite hashed asset
// (e.g. index-BxYt3k2a.js, chunk-abc123.css).
func isHashedAsset(name, ext string) bool {
switch ext {
case ".js", ".css", ".mjs":
return strings.Contains(name, "-") || strings.Contains(name, ".")
}
return false
}

View File

@@ -0,0 +1,25 @@
package utils
import (
"path/filepath"
"strings"
)
// IsPathInsideRoot returns true if candidate is inside (or equal to) root.
// Both paths are cleaned and compared case-insensitively on Windows.
func IsPathInsideRoot(root, candidate string) bool {
root = filepath.Clean(root)
candidate = filepath.Clean(candidate)
// normalize separators
root = filepath.ToSlash(root)
candidate = filepath.ToSlash(candidate)
// case-insensitive on Windows
rootLow := strings.ToLower(root)
candLow := strings.ToLower(candidate)
return candLow == rootLow || strings.HasPrefix(candLow, rootLow+"/")
}
// IsSameResolvedPath compares two paths after cleaning.
func IsSameResolvedPath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}