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