feat: update chat, settings, prompts and system info modules

This commit is contained in:
2026-06-16 10:33:54 +08:00
parent b86c29c6a4
commit dd13d21c6a
18 changed files with 1362 additions and 43 deletions

View File

@@ -56,6 +56,7 @@ func main() {
handlers.RegisterSettings(api, cfg, database, piClient)
handlers.RegisterTerminal(api, cfg)
handlers.RegisterExtensionUI(api, piClient)
handlers.RegisterPrompts(api, cfg)
// Static files (production: frontend/dist/)
static.Register(r, cfg.FrontendDist)

View File

@@ -26,6 +26,7 @@ type Config struct {
SkillsDir string
SkillsDisabledDir string
ExtensionsDir string
PromptsDir string
FrontendDist string // ../frontend/dist 相对于可执行文件
}
@@ -88,6 +89,7 @@ func (c *Config) derivePaths() {
c.SkillsDir = filepath.Join(a, "skills")
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
c.ExtensionsDir = filepath.Join(a, "extensions")
c.PromptsDir = filepath.Join(a, "prompts")
// frontend/dist 相对于二进制所在目录的上级
exe, err := os.Executable()

View File

@@ -0,0 +1,179 @@
package handlers
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// AgentToolInfo holds version info for an AI coding agent tool.
type AgentToolInfo struct {
Name string `json:"name"`
Found bool `json:"found"`
Version string `json:"version,omitempty"`
Path string `json:"path,omitempty"`
}
// RuntimeInfo holds version info for a programming language runtime or compiler.
type RuntimeInfo struct {
Name string `json:"name"`
Found bool `json:"found"`
Version string `json:"version,omitempty"`
Path string `json:"path,omitempty"`
}
type agentToolDef struct {
Name string
Binaries []string
Args []string
}
type runtimeDef struct {
Name string
Binaries []string // try in order
Args []string
}
var agentToolDefs = []agentToolDef{
{Name: "Codex", Binaries: []string{"codex"}, Args: []string{"--version"}},
{Name: "Claude Code", Binaries: []string{"claude"}, Args: []string{"--version"}},
{Name: "OpenCode", Binaries: []string{"opencode"}, Args: []string{"--version"}},
}
var runtimeDefs = []runtimeDef{
// python3 first; fall back to python on Windows where python3 may not exist
{Name: "Python", Binaries: []string{"python3", "python"}, Args: []string{"--version"}},
{Name: "Node.js", Binaries: []string{"node"}, Args: []string{"--version"}},
{Name: "Bun", Binaries: []string{"bun"}, Args: []string{"--version"}},
{Name: "Go", Binaries: []string{"go"}, Args: []string{"version"}},
// Java outputs version to stderr — runVersionOutput handles this
{Name: "Java", Binaries: []string{"java"}, Args: []string{"-version"}},
{Name: "Rust", Binaries: []string{"rustc"}, Args: []string{"--version"}},
// C compilers: detect both, show whichever is present
{Name: "GCC", Binaries: []string{"gcc"}, Args: []string{"--version"}},
{Name: "Clang", Binaries: []string{"clang"}, Args: []string{"--version"}},
}
// extraSearchPaths returns common installation directories not always in PATH.
func extraSearchPaths() []string {
home, _ := os.UserHomeDir()
paths := []string{
filepath.Join(home, "bin"),
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".bun", "bin"),
filepath.Join(home, "go", "bin"),
filepath.Join(home, ".cargo", "bin"),
"/usr/local/go/bin",
"/usr/local/bin",
"/usr/bin",
}
if runtime.GOOS == "windows" {
appdata := os.Getenv("APPDATA")
paths = append(paths,
filepath.Join(appdata, "npm"),
filepath.Join(home, "AppData", "Local", "Programs"),
filepath.Join(home, "AppData", "Roaming", "npm"),
)
}
return paths
}
// lookupBinary searches PATH then extra directories for the binary.
func lookupBinary(name string) string {
if p, err := exec.LookPath(name); err == nil {
return p
}
for _, dir := range extraSearchPaths() {
candidate := filepath.Join(dir, name)
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
// runVersionOutput runs the binary and returns the first non-empty line from stdout
// or stderr (some tools like Java output version to stderr).
func runVersionOutput(binaryPath string, args []string) string {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
_ = cmd.Run()
out := strings.TrimSpace(stdout.String())
if out == "" {
out = strings.TrimSpace(stderr.String())
}
if idx := strings.IndexByte(out, '\n'); idx >= 0 {
out = strings.TrimSpace(out[:idx])
}
return out
}
// runVersion is kept for backward compatibility.
func runVersion(binaryPath string, args []string) string {
return runVersionOutput(binaryPath, args)
}
// detectAgentTools detects all AI agent tools in parallel.
func detectAgentTools() []AgentToolInfo {
results := make([]AgentToolInfo, len(agentToolDefs))
var wg sync.WaitGroup
for i, def := range agentToolDefs {
wg.Add(1)
go func(idx int, d agentToolDef) {
defer wg.Done()
info := AgentToolInfo{Name: d.Name}
for _, bin := range d.Binaries {
p := lookupBinary(bin)
if p == "" {
continue
}
info.Found = true
info.Path = p
info.Version = runVersionOutput(p, d.Args)
break
}
results[idx] = info
}(i, def)
}
wg.Wait()
return results
}
// detectRuntimes detects all language runtimes in parallel.
func detectRuntimes() []RuntimeInfo {
results := make([]RuntimeInfo, len(runtimeDefs))
var wg sync.WaitGroup
for i, def := range runtimeDefs {
wg.Add(1)
go func(idx int, d runtimeDef) {
defer wg.Done()
info := RuntimeInfo{Name: d.Name}
for _, bin := range d.Binaries {
p := lookupBinary(bin)
if p == "" {
continue
}
info.Found = true
info.Path = p
info.Version = runVersionOutput(p, d.Args)
break
}
results[idx] = info
}(i, def)
}
wg.Wait()
return results
}

View File

@@ -38,6 +38,12 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
}
}
// Intercept slash commands before forwarding to agent.
if dispatch := services.TrySlashDispatch(req.Message); dispatch.Handled {
dispatchSlashCommand(c, dispatch, piClient)
return
}
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
req.StreamingBehavior = ""
@@ -52,6 +58,95 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
}
}
// dispatchSlashCommand handles slash commands intercepted from /api/chat.
func dispatchSlashCommand(c *gin.Context, d services.SlashDispatchResult, piClient *rpc.Client) {
// Unsupported command (not in the whitelist)
if d.Message != "" {
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": d.Message})
return
}
switch d.Command {
case "new":
slashNew(c, piClient)
case "reload":
slashReload(c, piClient)
case "copy":
slashCopy(c, piClient)
default:
// Forward to agent as a prompt; SSE events carry the result back.
msg := "/" + d.Command
if d.Args != "" {
msg += " " + d.Args
}
if err := piClient.SubmitPrompt(models.ChatRequest{Message: msg}); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true, "slash": true, "accepted": true})
}
}
func slashNew(c *gin.Context, piClient *rpc.Client) {
resp, err := piClient.SendCmd(models.RPCCommand{Type: "new_session"})
if err != nil || !resp.Success {
msg := "新建会话失败"
if err != nil {
msg = err.Error()
} else if resp.Error != "" {
msg = resp.Error
}
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: msg})
return
}
out := gin.H{"ok": true, "slash": true, "action": "new_session"}
if state, serr := piClient.SendCmd(models.RPCCommand{Type: "get_state"}); serr == nil && state.Success {
var sd struct {
SessionFile string `json:"sessionFile"`
}
if json.Unmarshal(state.Data, &sd) == nil && sd.SessionFile != "" {
out["sessionFile"] = sd.SessionFile
}
}
c.JSON(http.StatusOK, out)
}
func slashReload(c *gin.Context, piClient *rpc.Client) {
reloadAgent(piClient)
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "action": "reload_sessions", "message": "已重新加载"})
}
func slashCopy(c *gin.Context, piClient *rpc.Client) {
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
if err != nil || !resp.Success {
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "获取消息失败"})
return
}
var msgs []struct {
Role string `json:"role"`
Content any `json:"content"`
}
if json.Unmarshal(resp.Data, &msgs) != nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "没有可复制的消息"})
return
}
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
text := ""
switch v := msgs[i].Content.(type) {
case string:
text = v
default:
b, _ := json.Marshal(v)
text = string(b)
}
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "action": "copy", "message": text})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "没有助手消息可复制"})
}
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
return func(c *gin.Context) {
var req models.ChatRequest

View File

@@ -0,0 +1,153 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"sproutclaw-web/internal/config"
)
// RegisterPrompts registers prompt-template CRUD routes.
func RegisterPrompts(r *gin.RouterGroup, cfg *config.Config) {
dir := cfg.PromptsDir
r.GET("/prompts", handleListPrompts(dir))
r.GET("/prompts/file", handleGetPromptFile(dir))
r.POST("/prompts/file", handleSavePromptFile(dir))
r.POST("/prompts/delete", handleDeletePromptFile(dir))
}
// PromptMeta is the metadata extracted from a prompt .md file.
type PromptMeta struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
ArgumentHint string `json:"argumentHint,omitempty"`
}
// parsePromptMeta reads YAML frontmatter (description / argument-hint) from content.
func parsePromptMeta(name, content string) PromptMeta {
title := strings.TrimSuffix(name, ".md")
meta := PromptMeta{Name: name, Title: title}
if !strings.HasPrefix(content, "---\n") {
return meta
}
rest := content[4:]
end := strings.Index(rest, "\n---")
if end < 0 {
return meta
}
for _, line := range strings.Split(rest[:end], "\n") {
if after, ok := strings.CutPrefix(line, "description:"); ok {
meta.Description = strings.Trim(strings.TrimSpace(after), `"'`)
}
if after, ok := strings.CutPrefix(line, "argument-hint:"); ok {
meta.ArgumentHint = strings.Trim(strings.TrimSpace(after), `"'`)
}
}
return meta
}
// safePromptName validates that the filename is a plain .md name with no path traversal.
func safePromptName(name string) bool {
if name == "" {
return false
}
if !strings.HasSuffix(name, ".md") {
return false
}
// Reject anything that looks like a path
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false
}
return true
}
func handleListPrompts(dir string) gin.HandlerFunc {
return func(c *gin.Context) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"prompts": []any{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
list := make([]PromptMeta, 0)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
list = append(list, parsePromptMeta(e.Name(), string(raw)))
}
c.JSON(http.StatusOK, gin.H{"prompts": list})
}
}
func handleGetPromptFile(dir string) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Query("name")
if !safePromptName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
return
}
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"content": string(raw)})
}
}
func handleSavePromptFile(dir string) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !safePromptName(req.Name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件名必须以 .md 结尾,且不能含路径符"})
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.WriteFile(filepath.Join(dir, req.Name), []byte(req.Content), 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func handleDeletePromptFile(dir string) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !safePromptName(req.Name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
return
}
if err := os.Remove(filepath.Join(dir, req.Name)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}

View File

@@ -1,9 +1,15 @@
package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"sproutclaw-web/internal/config"
@@ -24,7 +30,33 @@ func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, p
r.POST("/settings/models-config", handleSetModelsConfig(cfg, piClient))
r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient))
r.POST("/settings/avatars", handleSetAvatars(database))
r.POST("/settings/restart", handleRestart())
r.GET("/environment", handleEnvironment(cfg))
r.GET("/environment/tools", handleEnvironmentTools())
}
var processStartTime = time.Now()
func handleRestart() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
go func() {
time.Sleep(300 * time.Millisecond)
exe, err := os.Executable()
if err != nil {
log.Printf("[restart] get executable: %v", err)
os.Exit(1)
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("[restart] start new process: %v", err)
os.Exit(1)
}
os.Exit(0)
}()
}
}
func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc {
@@ -200,15 +232,70 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
hostname, _ := os.Hostname()
cwd, _ := os.Getwd()
exe, _ := os.Executable()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
goHeapMb := int64(memStats.HeapAlloc / 1024 / 1024)
totalMemMb, freeMemMb := getSysMemMb()
osRelease := getOSRelease()
uptimeSec := int64(time.Since(processStartTime).Seconds())
piCmd := cfg.PiCmd
if len(cfg.PiArgs) > 0 {
piCmd = strings.Join(append([]string{cfg.PiCmd}, cfg.PiArgs...), " ")
}
c.JSON(http.StatusOK, gin.H{
"nodeVersion": runtime.Version(),
"goVersion": runtime.Version(),
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"osRelease": osRelease,
"hostname": hostname,
"numCPU": runtime.NumCPU(),
"totalMemMb": totalMemMb,
"freeMemMb": freeMemMb,
"pid": os.Getpid(),
"uptime": uptimeSec,
"execPath": exe,
"goroutines": runtime.NumGoroutine(),
"goHeapMb": goHeapMb,
"cwd": cwd,
"port": cfg.Port,
"agentDir": cfg.AgentDir,
"dataDir": cfg.DataDir,
"repoRoot": cfg.RepoRoot,
"piCmd": piCmd,
"memUsagePct": func() string {
if totalMemMb == 0 {
return ""
}
used := totalMemMb - freeMemMb
pct := float64(used) * 100 / float64(totalMemMb)
return fmt.Sprintf("%.1f%%", pct)
}(),
})
}
}
// handleEnvironmentTools detects all agent tools and language runtimes in parallel.
// This is a separate endpoint because version detection involves subprocess calls.
func handleEnvironmentTools() gin.HandlerFunc {
return func(c *gin.Context) {
var (
agentTools []AgentToolInfo
runtimes []RuntimeInfo
wg sync.WaitGroup
)
wg.Add(2)
go func() { defer wg.Done(); agentTools = detectAgentTools() }()
go func() { defer wg.Done(); runtimes = detectRuntimes() }()
wg.Wait()
c.JSON(http.StatusOK, gin.H{
"agentTools": agentTools,
"runtimes": runtimes,
})
}
}

View File

@@ -0,0 +1,53 @@
//go:build linux
package handlers
import (
"bufio"
"os"
"strconv"
"strings"
)
func getSysMemMb() (total, free int64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "MemTotal:") {
total = parseMemInfoKB(line)
} else if strings.HasPrefix(line, "MemAvailable:") {
free = parseMemInfoKB(line)
}
}
return total / 1024, free / 1024
}
func parseMemInfoKB(line string) int64 {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
v, _ := strconv.ParseInt(fields[1], 10, 64)
return v
}
func getOSRelease() string {
f, err := os.Open("/etc/os-release")
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "PRETTY_NAME=") {
return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)
}
}
return ""
}

View File

@@ -0,0 +1,11 @@
//go:build !linux && !windows
package handlers
func getSysMemMb() (total, free int64) {
return 0, 0
}
func getOSRelease() string {
return ""
}

View File

@@ -0,0 +1,68 @@
//go:build windows
package handlers
import (
"syscall"
"unsafe"
)
type memoryStatusEx struct {
dwLength uint32
dwMemoryLoad uint32
ullTotalPhys uint64
ullAvailPhys uint64
ullTotalPageFile uint64
ullAvailPageFile uint64
ullTotalVirtual uint64
ullAvailVirtual uint64
ullAvailExtendedVirtual uint64
}
func getSysMemMb() (total, free int64) {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
proc := kernel32.NewProc("GlobalMemoryStatusEx")
var ms memoryStatusEx
ms.dwLength = uint32(unsafe.Sizeof(ms))
ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&ms)))
if ret == 0 {
return 0, 0
}
return int64(ms.ullTotalPhys / 1024 / 1024), int64(ms.ullAvailPhys / 1024 / 1024)
}
func getOSRelease() string {
k, err := syscall.UTF16PtrFromString(`SOFTWARE\Microsoft\Windows NT\CurrentVersion`)
if err != nil {
return "Windows"
}
var handle syscall.Handle
if err := syscall.RegOpenKeyEx(syscall.HKEY_LOCAL_MACHINE, k, 0, syscall.KEY_READ, &handle); err != nil {
return "Windows"
}
defer syscall.RegCloseKey(handle)
readStr := func(name string) string {
namePtr, _ := syscall.UTF16PtrFromString(name)
var typ uint32
var buf [256]uint16
n := uint32(len(buf) * 2)
if err := syscall.RegQueryValueEx(handle, namePtr, nil, &typ, (*byte)(unsafe.Pointer(&buf[0])), &n); err != nil {
return ""
}
return syscall.UTF16ToString(buf[:])
}
productName := readStr("ProductName")
displayVersion := readStr("DisplayVersion")
if displayVersion == "" {
displayVersion = readStr("ReleaseId")
}
if productName == "" {
return "Windows"
}
if displayVersion != "" {
return productName + " " + displayVersion
}
return productName
}

View File

@@ -25,7 +25,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
}
var skills []models.SkillInfo
for _, e := range entries {
if !e.IsDir() {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
skillPath := filepath.Join(dir, e.Name())