feat: update chat, settings, prompts and system info modules
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
179
backend/internal/handlers/agenttools.go
Normal file
179
backend/internal/handlers/agenttools.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
153
backend/internal/handlers/prompts.go
Normal file
153
backend/internal/handlers/prompts.go
Normal 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})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
53
backend/internal/handlers/sysinfo_linux.go
Normal file
53
backend/internal/handlers/sysinfo_linux.go
Normal 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 ""
|
||||
}
|
||||
11
backend/internal/handlers/sysinfo_other.go
Normal file
11
backend/internal/handlers/sysinfo_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package handlers
|
||||
|
||||
func getSysMemMb() (total, free int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func getOSRelease() string {
|
||||
return ""
|
||||
}
|
||||
68
backend/internal/handlers/sysinfo_windows.go
Normal file
68
backend/internal/handlers/sysinfo_windows.go
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
24
frontend/src/api/prompts.ts
Normal file
24
frontend/src/api/prompts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
|
||||
export interface PromptFile {
|
||||
name: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
}
|
||||
|
||||
export function fetchPrompts(): Promise<{ prompts: PromptFile[] }> {
|
||||
return apiGet("/api/prompts");
|
||||
}
|
||||
|
||||
export function fetchPromptFile(name: string): Promise<{ content: string }> {
|
||||
return apiGet(`/api/prompts/file?name=${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function savePromptFile(name: string, content: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/prompts/file", { name, content });
|
||||
}
|
||||
|
||||
export function deletePromptFile(name: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/prompts/delete", { name });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { AvatarSettings, EnvironmentInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
|
||||
import type { AvatarSettings, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
|
||||
|
||||
export function fetchSettings(): Promise<SettingsData> {
|
||||
return apiGet("/api/settings");
|
||||
@@ -55,6 +55,14 @@ export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
||||
return apiGet("/api/environment");
|
||||
}
|
||||
|
||||
export function fetchEnvironmentTools(): Promise<EnvironmentToolsInfo> {
|
||||
return apiGet("/api/environment/tools");
|
||||
}
|
||||
|
||||
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
|
||||
return apiGet("/api/terminal");
|
||||
}
|
||||
|
||||
export function restartBackend(): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/settings/restart");
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ChatInput() {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
||||
const slashMenuWasOpenRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchLike = isTouchLike();
|
||||
@@ -78,11 +79,15 @@ export function ChatInput() {
|
||||
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSlashIndex(0);
|
||||
}, [value, slashMenuOpen]);
|
||||
if (slashMenuOpen && !slashMenuWasOpenRef.current) {
|
||||
setSelectedSlashIndex(0);
|
||||
}
|
||||
slashMenuWasOpenRef.current = slashMenuOpen;
|
||||
}, [slashMenuOpen]);
|
||||
|
||||
const canSend = value.trim().length > 0 || pendingImages.length > 0;
|
||||
|
||||
// Tab: 只填入命令名,保留空格供用户继续输入参数
|
||||
const applySlashSelection = (command: SlashCommand) => {
|
||||
const nextValue = applySlashCompletion(value, command.name);
|
||||
setValue(nextValue);
|
||||
@@ -96,6 +101,16 @@ export function ChatInput() {
|
||||
});
|
||||
};
|
||||
|
||||
// Enter / 点击: 直接执行命令,不需要二次确认
|
||||
const executeSlashCommand = (command: SlashCommand) => {
|
||||
setValue("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
inputRef.current.focus();
|
||||
}
|
||||
void sendMessage(`/${command.name}`, undefined, { mode: "prompt" });
|
||||
};
|
||||
|
||||
const addImages = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files);
|
||||
if (!list.length) return;
|
||||
@@ -161,7 +176,7 @@ export function ChatInput() {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const command = filteredSlashCommands[selectedSlashIndex];
|
||||
if (command) applySlashSelection(command);
|
||||
if (command) applySlashSelection(command); // Tab = 填入,可继续编辑参数
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -176,7 +191,7 @@ export function ChatInput() {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const command = filteredSlashCommands[selectedSlashIndex];
|
||||
if (command) applySlashSelection(command);
|
||||
if (command) executeSlashCommand(command); // Enter = 立即执行
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +301,8 @@ export function ChatInput() {
|
||||
<SlashCommandMenu
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={selectedSlashIndex}
|
||||
onSelect={applySlashSelection}
|
||||
onSelect={executeSlashCommand}
|
||||
onHover={setSelectedSlashIndex}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.wrapper}>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
.menu {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
@@ -55,6 +58,21 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.hint span {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.item {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -64,4 +82,9 @@
|
||||
.source {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.hint {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ interface SlashCommandMenuProps {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCommandMenuProps) {
|
||||
export function SlashCommandMenu({ commands, selectedIndex, onSelect, onHover }: SlashCommandMenuProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,6 +32,7 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
|
||||
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(command);
|
||||
@@ -44,6 +46,12 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
<span>↑↓ 导航</span>
|
||||
<span>Tab 填入</span>
|
||||
<span>Enter 执行</span>
|
||||
<span>Esc 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
.envGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 0;
|
||||
border: 1px solid #edf0f4;
|
||||
border-radius: 8px;
|
||||
@@ -505,6 +506,21 @@
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.envSectionHeader {
|
||||
padding: 6px 14px 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
background: #f3f4f6;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
}
|
||||
|
||||
.envSectionHeader:not(:first-child) {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.envLabel {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
@@ -523,6 +539,187 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
.envToolFound {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.envToolPath {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.envToolMissing {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.envSectionLoading {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #b0b8c9;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.envLoadingRow {
|
||||
color: #b0b8c9;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== 命令模板 ===== */
|
||||
|
||||
.promptGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.promptGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.promptGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.promptCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.promptCard:hover {
|
||||
border-color: #2563eb;
|
||||
background: #f5f8ff;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.promptCardTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promptCardDesc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promptCardHint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promptCardName {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: #d1d5db;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.promptEditorTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #374151;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.promptFilenameInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.promptFilenameInput:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.dangerBtn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: #b91c1c;
|
||||
background: #fff;
|
||||
border: 1px solid #fca5a5;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.dangerBtn:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
.dangerBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.terminalPanel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as promptsApi from "../api/prompts";
|
||||
import type { PromptFile } from "../api/prompts";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import { useAvatars } from "../context/AvatarContext";
|
||||
import { useBootstrap } from "../context/BootstrapContext";
|
||||
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import { renderMarkdown } from "../utils/markdown";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
@@ -12,7 +14,17 @@ const WebTerminal = lazy(() =>
|
||||
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
|
||||
);
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "terminal", "env"];
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "prompts", "other", "terminal", "env"];
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const m = Math.floor(seconds / 60) % 60;
|
||||
const h = Math.floor(seconds / 3600) % 24;
|
||||
const d = Math.floor(seconds / 86400);
|
||||
if (d > 0) return `${d} 天 ${h} 时 ${m} 分`;
|
||||
if (h > 0) return `${h} 时 ${m} 分`;
|
||||
return `${m} 分`;
|
||||
}
|
||||
|
||||
function isNpmSkill(skill: SkillInfo): boolean {
|
||||
if (skill.toggleable === false) return true;
|
||||
@@ -316,12 +328,27 @@ export function SettingsPage() {
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
// 命令模板 state
|
||||
const [promptList, setPromptList] = useState<PromptFile[]>([]);
|
||||
const [loadingPrompts, setLoadingPrompts] = useState(false);
|
||||
const [promptEditMode, setPromptEditMode] = useState<"list" | "edit">("list");
|
||||
const [promptFilename, setPromptFilename] = useState(""); // .md 后缀的文件名
|
||||
const [promptOriginalName, setPromptOriginalName] = useState(""); // 编辑前的原始文件名(空串表示新建)
|
||||
const [promptContent, setPromptContent] = useState("");
|
||||
const [promptPreview, setPromptPreview] = useState(false);
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
const [deletingPrompt, setDeletingPrompt] = useState(false);
|
||||
|
||||
const [togglingSkillPath, setTogglingSkillPath] = useState<string | null>(null);
|
||||
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
||||
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
||||
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||
const [toolsInfo, setToolsInfo] = useState<EnvironmentToolsInfo | null>(null);
|
||||
const [loadingTools, setLoadingTools] = useState(false);
|
||||
const [terminalPlatform, setTerminalPlatform] = useState("");
|
||||
const [terminalRequested, setTerminalRequested] = useState(false);
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
@@ -420,9 +447,103 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadToolsInfo = async () => {
|
||||
setLoadingTools(true);
|
||||
try {
|
||||
const data = await settingsApi.fetchEnvironmentTools();
|
||||
setToolsInfo(data);
|
||||
} catch (err) {
|
||||
setStatus(`检测工具失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPrompts = async () => {
|
||||
setLoadingPrompts(true);
|
||||
try {
|
||||
const data = await promptsApi.fetchPrompts();
|
||||
setPromptList(data.prompts || []);
|
||||
} catch (err) {
|
||||
setStatus(`加载命令模板失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setLoadingPrompts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openPromptEditor = async (file: PromptFile) => {
|
||||
try {
|
||||
const data = await promptsApi.fetchPromptFile(file.name);
|
||||
setPromptOriginalName(file.name);
|
||||
setPromptFilename(file.name);
|
||||
setPromptContent(data.content);
|
||||
setPromptPreview(false);
|
||||
setPromptEditMode("edit");
|
||||
} catch (err) {
|
||||
setStatus(`加载文件失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const openNewPrompt = () => {
|
||||
setPromptOriginalName("");
|
||||
setPromptFilename("新模板.md");
|
||||
setPromptContent("");
|
||||
setPromptPreview(false);
|
||||
setPromptEditMode("edit");
|
||||
};
|
||||
|
||||
const savePrompt = async () => {
|
||||
let name = promptFilename.trim();
|
||||
if (!name) { setStatus("文件名不能为空", "error"); return; }
|
||||
if (!name.endsWith(".md")) name += ".md";
|
||||
setSavingPrompt(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
await promptsApi.savePromptFile(name, promptContent);
|
||||
// 如果改名了,删除旧文件
|
||||
if (promptOriginalName && promptOriginalName !== name) {
|
||||
await promptsApi.deletePromptFile(promptOriginalName);
|
||||
}
|
||||
setStatus("已保存", "ok");
|
||||
setPromptOriginalName(name);
|
||||
setPromptFilename(name);
|
||||
await loadPrompts();
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePrompt = async () => {
|
||||
if (!promptOriginalName) {
|
||||
setPromptEditMode("list");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确认删除「${promptOriginalName}」?`)) return;
|
||||
setDeletingPrompt(true);
|
||||
try {
|
||||
await promptsApi.deletePromptFile(promptOriginalName);
|
||||
setStatus("已删除", "ok");
|
||||
setPromptEditMode("list");
|
||||
await loadPrompts();
|
||||
} catch (err) {
|
||||
setStatus(`删除失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setDeletingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activePane === "env" && !envInfo && !loadingEnv) {
|
||||
void loadEnvInfo();
|
||||
if (activePane === "env") {
|
||||
if (!envInfo && !loadingEnv) void loadEnvInfo();
|
||||
if (!toolsInfo && !loadingTools) void loadToolsInfo();
|
||||
}
|
||||
if (activePane === "prompts" && promptList.length === 0 && !loadingPrompts) {
|
||||
void loadPrompts();
|
||||
}
|
||||
if (activePane !== "prompts") {
|
||||
setPromptEditMode("list");
|
||||
}
|
||||
if (activePane === "terminal" && !terminalPlatform) {
|
||||
void loadTerminalInfo();
|
||||
@@ -573,6 +694,27 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const restartBackend = async () => {
|
||||
setRestarting(true);
|
||||
setStatus("正在重启后端...");
|
||||
try {
|
||||
await settingsApi.restartBackend();
|
||||
} catch {
|
||||
// 后端重启时连接断开属正常现象,忽略错误
|
||||
}
|
||||
// 轮询直到后端恢复
|
||||
const poll = async () => {
|
||||
try {
|
||||
await settingsApi.fetchSettings();
|
||||
setStatus("后端已重启,正在刷新...", "ok");
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} catch {
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
};
|
||||
|
||||
const saveAvatars = async () => {
|
||||
setSavingAvatars(true);
|
||||
setStatus("保存中...");
|
||||
@@ -641,6 +783,7 @@ export function SettingsPage() {
|
||||
["skills", "Skills"],
|
||||
["mcp", "MCP工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["prompts", "命令模板"],
|
||||
["other", "其他设置"],
|
||||
["terminal", "终端"],
|
||||
["env", "环境信息"],
|
||||
@@ -993,6 +1136,141 @@ export function SettingsPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ===== 命令模板 ===== */}
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "prompts" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "prompts"}
|
||||
>
|
||||
{promptEditMode === "list" ? (
|
||||
<>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>命令模板</h2>
|
||||
<p>{promptList.length} 个模板 · 存储于 prompts/ 目录</p>
|
||||
</div>
|
||||
<div className={styles.panelHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={loadingPrompts}
|
||||
onClick={() => void loadPrompts()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
onClick={openNewPrompt}
|
||||
>
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loadingPrompts ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : promptList.length === 0 ? (
|
||||
<div className={styles.empty}>暂无命令模板,点击「新建」创建第一个</div>
|
||||
) : (
|
||||
<div className={styles.promptGrid}>
|
||||
{promptList.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
type="button"
|
||||
className={styles.promptCard}
|
||||
onClick={() => void openPromptEditor(p)}
|
||||
>
|
||||
<div className={styles.promptCardTitle}>{p.title}</div>
|
||||
{p.description ? (
|
||||
<div className={styles.promptCardDesc}>{p.description}</div>
|
||||
) : null}
|
||||
{p.argumentHint ? (
|
||||
<div className={styles.promptCardHint}>参数:{p.argumentHint}</div>
|
||||
) : null}
|
||||
<div className={styles.promptCardName}>{p.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.promptEditorTitle}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => setPromptEditMode("list")}
|
||||
aria-label="返回列表"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<input
|
||||
className={styles.promptFilenameInput}
|
||||
value={promptFilename}
|
||||
onChange={(e) => setPromptFilename(e.target.value)}
|
||||
placeholder="文件名.md"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.panelHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.secondaryBtn} ${promptPreview ? styles.headerBtnActive : ""}`}
|
||||
onClick={() => setPromptPreview((v) => !v)}
|
||||
>
|
||||
{promptPreview ? "编辑" : "预览"}
|
||||
</button>
|
||||
{promptOriginalName ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dangerBtn}
|
||||
disabled={deletingPrompt}
|
||||
onClick={() => void deletePrompt()}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingPrompt}
|
||||
onClick={() => void savePrompt()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{promptPreview ? (
|
||||
<div className={styles.markdownPreview}>
|
||||
{promptContent.trim() ? (
|
||||
<div
|
||||
className={`markdown-body ${styles.markdownBody}`}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(promptContent) }}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>暂无内容</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="命令模板内容"
|
||||
value={promptContent}
|
||||
onChange={(e) => setPromptContent(e.target.value)}
|
||||
placeholder={"在此输入提示词内容...\n\n支持 YAML frontmatter:\n---\ndescription: 描述\nargument-hint: <参数说明>\n---"}
|
||||
/>
|
||||
)}
|
||||
{statusText && activePane === "prompts" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
@@ -1003,14 +1281,24 @@ export function SettingsPage() {
|
||||
<h2>其他设置</h2>
|
||||
<p>聊天头像使用网页图片链接</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingAvatars}
|
||||
onClick={() => void saveAvatars()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<div className={styles.panelHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={restarting}
|
||||
onClick={() => void restartBackend()}
|
||||
>
|
||||
{restarting ? "重启中..." : "重启后端"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingAvatars}
|
||||
onClick={() => void saveAvatars()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formBody}>
|
||||
<label className={styles.field}>
|
||||
@@ -1096,8 +1384,8 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={loadingEnv}
|
||||
onClick={() => void loadEnvInfo()}
|
||||
disabled={loadingEnv || loadingTools}
|
||||
onClick={() => { void loadEnvInfo(); void loadToolsInfo(); }}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
@@ -1109,21 +1397,17 @@ export function SettingsPage() {
|
||||
<div className={styles.empty}>暂无数据</div>
|
||||
) : (
|
||||
<div className={styles.envGrid}>
|
||||
<div className={styles.envSectionHeader}>系统信息</div>
|
||||
{(
|
||||
[
|
||||
["Node.js 版本", envInfo.nodeVersion],
|
||||
["平台", envInfo.platform],
|
||||
["架构", envInfo.arch],
|
||||
["系统类型", envInfo.osType],
|
||||
["系统版本", envInfo.osRelease],
|
||||
["操作系统", envInfo.osRelease || envInfo.platform],
|
||||
["平台 / 架构", envInfo.platform && envInfo.arch ? `${envInfo.platform} / ${envInfo.arch}` : undefined],
|
||||
["主机名", envInfo.hostname],
|
||||
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
|
||||
["运行时长", envInfo.uptime !== undefined ? envInfo.uptime + " 秒" : undefined],
|
||||
["总内存", envInfo.totalMemMb !== undefined ? envInfo.totalMemMb + " MB" : undefined],
|
||||
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
|
||||
["Node 路径", envInfo.execPath],
|
||||
["工作目录", envInfo.cwd],
|
||||
["SproutClaw 根目录", envInfo.repoRoot],
|
||||
["CPU 核心数", envInfo.numCPU !== undefined ? `${envInfo.numCPU} 核` : undefined],
|
||||
["系统总内存", envInfo.totalMemMb ? `${envInfo.totalMemMb} MB` : undefined],
|
||||
["可用内存", envInfo.freeMemMb !== undefined && envInfo.totalMemMb
|
||||
? `${envInfo.freeMemMb} MB${envInfo.memUsagePct ? `(已用 ${envInfo.memUsagePct})` : ""}`
|
||||
: undefined],
|
||||
] as [string, string | undefined][]
|
||||
)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
@@ -1133,6 +1417,82 @@ export function SettingsPage() {
|
||||
<code className={styles.envValue}>{value}</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.envSectionHeader}>进程信息</div>
|
||||
{(
|
||||
[
|
||||
["Go 版本", envInfo.goVersion || envInfo.nodeVersion],
|
||||
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
|
||||
["运行时长", envInfo.uptime !== undefined ? formatUptime(envInfo.uptime) : undefined],
|
||||
["Goroutine 数", envInfo.goroutines !== undefined ? String(envInfo.goroutines) : undefined],
|
||||
["Go 堆内存", envInfo.goHeapMb !== undefined ? `${envInfo.goHeapMb} MB` : undefined],
|
||||
["可执行文件", envInfo.execPath],
|
||||
] as [string, string | undefined][]
|
||||
)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
.map(([label, value]) => (
|
||||
<div key={label} className={styles.envRow}>
|
||||
<span className={styles.envLabel}>{label}</span>
|
||||
<code className={styles.envValue}>{value}</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.envSectionHeader}>路径与配置</div>
|
||||
{(
|
||||
[
|
||||
["监听端口", envInfo.port !== undefined ? String(envInfo.port) : undefined],
|
||||
["Agent 目录", envInfo.agentDir],
|
||||
["数据目录", envInfo.dataDir],
|
||||
["项目根目录", envInfo.repoRoot],
|
||||
["工作目录", envInfo.cwd],
|
||||
["Pi 命令", envInfo.piCmd],
|
||||
] as [string, string | undefined][]
|
||||
)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
.map(([label, value]) => (
|
||||
<div key={label} className={styles.envRow}>
|
||||
<span className={styles.envLabel}>{label}</span>
|
||||
<code className={styles.envValue}>{value}</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.envSectionHeader}>
|
||||
Agent 工具{loadingTools ? <span className={styles.envSectionLoading}> · 检测中...</span> : null}
|
||||
</div>
|
||||
{loadingTools && !toolsInfo ? (
|
||||
<div className={`${styles.envRow} ${styles.envLoadingRow}`}>正在检测 Agent 工具...</div>
|
||||
) : Array.isArray(toolsInfo?.agentTools) ? (
|
||||
toolsInfo!.agentTools.map((tool: AgentToolInfo) => (
|
||||
<div key={tool.name} className={styles.envRow}>
|
||||
<span className={styles.envLabel}>{tool.name}</span>
|
||||
{tool.found ? (
|
||||
<span className={styles.envToolFound}>
|
||||
<code className={styles.envValue}>{tool.version || "已安装"}</code>
|
||||
{tool.path ? <span className={styles.envToolPath}>{tool.path}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.envToolMissing}>未安装</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
<div className={styles.envSectionHeader}>
|
||||
运行时环境{loadingTools ? <span className={styles.envSectionLoading}> · 检测中...</span> : null}
|
||||
</div>
|
||||
{loadingTools && !toolsInfo ? (
|
||||
<div className={`${styles.envRow} ${styles.envLoadingRow}`}>正在检测语言运行时...</div>
|
||||
) : Array.isArray(toolsInfo?.runtimes) ? (
|
||||
toolsInfo!.runtimes.map((rt: RuntimeInfo) => (
|
||||
<div key={rt.name} className={styles.envRow}>
|
||||
<span className={styles.envLabel}>{rt.name}</span>
|
||||
{rt.found ? (
|
||||
<span className={styles.envToolFound}>
|
||||
<code className={styles.envValue}>{rt.version || "已安装"}</code>
|
||||
{rt.path ? <span className={styles.envToolPath}>{rt.path}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.envToolMissing}>未安装</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -213,22 +213,56 @@ export interface AvatarSettings {
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env";
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env" | "prompts";
|
||||
|
||||
export interface AgentToolInfo {
|
||||
name: string;
|
||||
found: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeInfo {
|
||||
name: string;
|
||||
found: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentToolsInfo {
|
||||
agentTools?: AgentToolInfo[];
|
||||
runtimes?: RuntimeInfo[];
|
||||
}
|
||||
|
||||
export interface EnvironmentInfo {
|
||||
nodeVersion?: string;
|
||||
// 系统信息
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
osType?: string;
|
||||
osRelease?: string;
|
||||
hostname?: string;
|
||||
pid?: number;
|
||||
cwd?: string;
|
||||
repoRoot?: string;
|
||||
uptime?: number;
|
||||
numCPU?: number;
|
||||
totalMemMb?: number;
|
||||
freeMemMb?: number;
|
||||
memUsagePct?: string;
|
||||
// 进程信息
|
||||
goVersion?: string;
|
||||
pid?: number;
|
||||
uptime?: number;
|
||||
execPath?: string;
|
||||
goroutines?: number;
|
||||
goHeapMb?: number;
|
||||
// 路径与配置
|
||||
port?: number;
|
||||
agentDir?: string;
|
||||
dataDir?: string;
|
||||
repoRoot?: string;
|
||||
cwd?: string;
|
||||
piCmd?: string;
|
||||
// Agent 工具
|
||||
agentTools?: AgentToolInfo[];
|
||||
// 兼容旧字段
|
||||
nodeVersion?: string;
|
||||
osType?: string;
|
||||
}
|
||||
|
||||
export interface RetryState {
|
||||
|
||||
Reference in New Issue
Block a user