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.RegisterSettings(api, cfg, database, piClient)
|
||||||
handlers.RegisterTerminal(api, cfg)
|
handlers.RegisterTerminal(api, cfg)
|
||||||
handlers.RegisterExtensionUI(api, piClient)
|
handlers.RegisterExtensionUI(api, piClient)
|
||||||
|
handlers.RegisterPrompts(api, cfg)
|
||||||
|
|
||||||
// Static files (production: frontend/dist/)
|
// Static files (production: frontend/dist/)
|
||||||
static.Register(r, cfg.FrontendDist)
|
static.Register(r, cfg.FrontendDist)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Config struct {
|
|||||||
SkillsDir string
|
SkillsDir string
|
||||||
SkillsDisabledDir string
|
SkillsDisabledDir string
|
||||||
ExtensionsDir string
|
ExtensionsDir string
|
||||||
|
PromptsDir string
|
||||||
FrontendDist string // ../frontend/dist 相对于可执行文件
|
FrontendDist string // ../frontend/dist 相对于可执行文件
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ func (c *Config) derivePaths() {
|
|||||||
c.SkillsDir = filepath.Join(a, "skills")
|
c.SkillsDir = filepath.Join(a, "skills")
|
||||||
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
|
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
|
||||||
c.ExtensionsDir = filepath.Join(a, "extensions")
|
c.ExtensionsDir = filepath.Join(a, "extensions")
|
||||||
|
c.PromptsDir = filepath.Join(a, "prompts")
|
||||||
|
|
||||||
// frontend/dist 相对于二进制所在目录的上级
|
// frontend/dist 相对于二进制所在目录的上级
|
||||||
exe, err := os.Executable()
|
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
|
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
|
||||||
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
|
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
|
||||||
req.StreamingBehavior = ""
|
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 {
|
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req models.ChatRequest
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"sproutclaw-web/internal/config"
|
"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/models-config", handleSetModelsConfig(cfg, piClient))
|
||||||
r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient))
|
r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient))
|
||||||
r.POST("/settings/avatars", handleSetAvatars(database))
|
r.POST("/settings/avatars", handleSetAvatars(database))
|
||||||
|
r.POST("/settings/restart", handleRestart())
|
||||||
r.GET("/environment", handleEnvironment(cfg))
|
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 {
|
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) {
|
return func(c *gin.Context) {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
cwd, _ := os.Getwd()
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"nodeVersion": runtime.Version(),
|
"goVersion": runtime.Version(),
|
||||||
"platform": runtime.GOOS,
|
"platform": runtime.GOOS,
|
||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
|
"osRelease": osRelease,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
|
"numCPU": runtime.NumCPU(),
|
||||||
|
"totalMemMb": totalMemMb,
|
||||||
|
"freeMemMb": freeMemMb,
|
||||||
"pid": os.Getpid(),
|
"pid": os.Getpid(),
|
||||||
|
"uptime": uptimeSec,
|
||||||
|
"execPath": exe,
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
"goHeapMb": goHeapMb,
|
||||||
"cwd": cwd,
|
"cwd": cwd,
|
||||||
"port": cfg.Port,
|
"port": cfg.Port,
|
||||||
|
"agentDir": cfg.AgentDir,
|
||||||
|
"dataDir": cfg.DataDir,
|
||||||
"repoRoot": cfg.RepoRoot,
|
"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
|
var skills []models.SkillInfo
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() {
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
skillPath := filepath.Join(dir, e.Name())
|
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 { 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> {
|
export function fetchSettings(): Promise<SettingsData> {
|
||||||
return apiGet("/api/settings");
|
return apiGet("/api/settings");
|
||||||
@@ -55,6 +55,14 @@ export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
|||||||
return apiGet("/api/environment");
|
return apiGet("/api/environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchEnvironmentTools(): Promise<EnvironmentToolsInfo> {
|
||||||
|
return apiGet("/api/environment/tools");
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
|
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
|
||||||
return apiGet("/api/terminal");
|
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 [dragOver, setDragOver] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
||||||
|
const slashMenuWasOpenRef = useRef(false);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const touchLike = isTouchLike();
|
const touchLike = isTouchLike();
|
||||||
@@ -78,11 +79,15 @@ export function ChatInput() {
|
|||||||
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
|
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSlashIndex(0);
|
if (slashMenuOpen && !slashMenuWasOpenRef.current) {
|
||||||
}, [value, slashMenuOpen]);
|
setSelectedSlashIndex(0);
|
||||||
|
}
|
||||||
|
slashMenuWasOpenRef.current = slashMenuOpen;
|
||||||
|
}, [slashMenuOpen]);
|
||||||
|
|
||||||
const canSend = value.trim().length > 0 || pendingImages.length > 0;
|
const canSend = value.trim().length > 0 || pendingImages.length > 0;
|
||||||
|
|
||||||
|
// Tab: 只填入命令名,保留空格供用户继续输入参数
|
||||||
const applySlashSelection = (command: SlashCommand) => {
|
const applySlashSelection = (command: SlashCommand) => {
|
||||||
const nextValue = applySlashCompletion(value, command.name);
|
const nextValue = applySlashCompletion(value, command.name);
|
||||||
setValue(nextValue);
|
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 addImages = async (files: FileList | File[]) => {
|
||||||
const list = Array.from(files);
|
const list = Array.from(files);
|
||||||
if (!list.length) return;
|
if (!list.length) return;
|
||||||
@@ -161,7 +176,7 @@ export function ChatInput() {
|
|||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const command = filteredSlashCommands[selectedSlashIndex];
|
const command = filteredSlashCommands[selectedSlashIndex];
|
||||||
if (command) applySlashSelection(command);
|
if (command) applySlashSelection(command); // Tab = 填入,可继续编辑参数
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -176,7 +191,7 @@ export function ChatInput() {
|
|||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const command = filteredSlashCommands[selectedSlashIndex];
|
const command = filteredSlashCommands[selectedSlashIndex];
|
||||||
if (command) applySlashSelection(command);
|
if (command) executeSlashCommand(command); // Enter = 立即执行
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +301,8 @@ export function ChatInput() {
|
|||||||
<SlashCommandMenu
|
<SlashCommandMenu
|
||||||
commands={filteredSlashCommands}
|
commands={filteredSlashCommands}
|
||||||
selectedIndex={selectedSlashIndex}
|
selectedIndex={selectedSlashIndex}
|
||||||
onSelect={applySlashSelection}
|
onSelect={executeSlashCommand}
|
||||||
|
onHover={setSelectedSlashIndex}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
.menu {
|
.menu {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto 8px;
|
margin: 0 auto 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 12px;
|
border-radius: 12px 12px 0 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,21 @@
|
|||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.item {
|
.item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -64,4 +82,9 @@
|
|||||||
.source {
|
.source {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ interface SlashCommandMenuProps {
|
|||||||
commands: SlashCommand[];
|
commands: SlashCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (command: SlashCommand) => void;
|
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);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,6 +32,7 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
|
|||||||
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
|
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index === selectedIndex}
|
aria-selected={index === selectedIndex}
|
||||||
|
onMouseEnter={() => onHover(index)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect(command);
|
onSelect(command);
|
||||||
@@ -44,6 +46,12 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.hint}>
|
||||||
|
<span>↑↓ 导航</span>
|
||||||
|
<span>Tab 填入</span>
|
||||||
|
<span>Enter 执行</span>
|
||||||
|
<span>Esc 关闭</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
.envGrid {
|
.envGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid #edf0f4;
|
border: 1px solid #edf0f4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -505,6 +506,21 @@
|
|||||||
background: #fafbfc;
|
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 {
|
.envLabel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@@ -523,6 +539,187 @@
|
|||||||
background: none;
|
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 {
|
.terminalPanel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 * as settingsApi from "../api/settings";
|
||||||
import { useAvatars } from "../context/AvatarContext";
|
import { useAvatars } from "../context/AvatarContext";
|
||||||
import { useBootstrap } from "../context/BootstrapContext";
|
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 type { McpToolInfo } from "../types/events";
|
||||||
import { renderMarkdown } from "../utils/markdown";
|
import { renderMarkdown } from "../utils/markdown";
|
||||||
import styles from "./SettingsPage.module.css";
|
import styles from "./SettingsPage.module.css";
|
||||||
@@ -12,7 +14,17 @@ const WebTerminal = lazy(() =>
|
|||||||
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
|
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 {
|
function isNpmSkill(skill: SkillInfo): boolean {
|
||||||
if (skill.toggleable === false) return true;
|
if (skill.toggleable === false) return true;
|
||||||
@@ -316,12 +328,27 @@ export function SettingsPage() {
|
|||||||
const [savingModels, setSavingModels] = useState(false);
|
const [savingModels, setSavingModels] = useState(false);
|
||||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||||
const [reloading, setReloading] = 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 [togglingSkillPath, setTogglingSkillPath] = useState<string | null>(null);
|
||||||
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
|
||||||
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
|
||||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||||
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
||||||
const [loadingEnv, setLoadingEnv] = useState(false);
|
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||||
|
const [toolsInfo, setToolsInfo] = useState<EnvironmentToolsInfo | null>(null);
|
||||||
|
const [loadingTools, setLoadingTools] = useState(false);
|
||||||
const [terminalPlatform, setTerminalPlatform] = useState("");
|
const [terminalPlatform, setTerminalPlatform] = useState("");
|
||||||
const [terminalRequested, setTerminalRequested] = useState(false);
|
const [terminalRequested, setTerminalRequested] = useState(false);
|
||||||
const sidebarRef = useRef<HTMLElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activePane === "env" && !envInfo && !loadingEnv) {
|
if (activePane === "env") {
|
||||||
void loadEnvInfo();
|
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) {
|
if (activePane === "terminal" && !terminalPlatform) {
|
||||||
void loadTerminalInfo();
|
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 () => {
|
const saveAvatars = async () => {
|
||||||
setSavingAvatars(true);
|
setSavingAvatars(true);
|
||||||
setStatus("保存中...");
|
setStatus("保存中...");
|
||||||
@@ -641,6 +783,7 @@ export function SettingsPage() {
|
|||||||
["skills", "Skills"],
|
["skills", "Skills"],
|
||||||
["mcp", "MCP工具"],
|
["mcp", "MCP工具"],
|
||||||
["extensions", "插件扩展"],
|
["extensions", "插件扩展"],
|
||||||
|
["prompts", "命令模板"],
|
||||||
["other", "其他设置"],
|
["other", "其他设置"],
|
||||||
["terminal", "终端"],
|
["terminal", "终端"],
|
||||||
["env", "环境信息"],
|
["env", "环境信息"],
|
||||||
@@ -993,6 +1136,141 @@ export function SettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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
|
<section
|
||||||
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
@@ -1003,14 +1281,24 @@ export function SettingsPage() {
|
|||||||
<h2>其他设置</h2>
|
<h2>其他设置</h2>
|
||||||
<p>聊天头像使用网页图片链接</p>
|
<p>聊天头像使用网页图片链接</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className={styles.panelHeaderActions}>
|
||||||
type="button"
|
<button
|
||||||
className={styles.primaryBtn}
|
type="button"
|
||||||
disabled={savingAvatars}
|
className={styles.secondaryBtn}
|
||||||
onClick={() => void saveAvatars()}
|
disabled={restarting}
|
||||||
>
|
onClick={() => void restartBackend()}
|
||||||
保存
|
>
|
||||||
</button>
|
{restarting ? "重启中..." : "重启后端"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryBtn}
|
||||||
|
disabled={savingAvatars}
|
||||||
|
onClick={() => void saveAvatars()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formBody}>
|
<div className={styles.formBody}>
|
||||||
<label className={styles.field}>
|
<label className={styles.field}>
|
||||||
@@ -1096,8 +1384,8 @@ export function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.secondaryBtn}
|
className={styles.secondaryBtn}
|
||||||
disabled={loadingEnv}
|
disabled={loadingEnv || loadingTools}
|
||||||
onClick={() => void loadEnvInfo()}
|
onClick={() => { void loadEnvInfo(); void loadToolsInfo(); }}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -1109,21 +1397,17 @@ export function SettingsPage() {
|
|||||||
<div className={styles.empty}>暂无数据</div>
|
<div className={styles.empty}>暂无数据</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.envGrid}>
|
<div className={styles.envGrid}>
|
||||||
|
<div className={styles.envSectionHeader}>系统信息</div>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["Node.js 版本", envInfo.nodeVersion],
|
["操作系统", envInfo.osRelease || envInfo.platform],
|
||||||
["平台", envInfo.platform],
|
["平台 / 架构", envInfo.platform && envInfo.arch ? `${envInfo.platform} / ${envInfo.arch}` : undefined],
|
||||||
["架构", envInfo.arch],
|
|
||||||
["系统类型", envInfo.osType],
|
|
||||||
["系统版本", envInfo.osRelease],
|
|
||||||
["主机名", envInfo.hostname],
|
["主机名", envInfo.hostname],
|
||||||
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
|
["CPU 核心数", envInfo.numCPU !== undefined ? `${envInfo.numCPU} 核` : undefined],
|
||||||
["运行时长", envInfo.uptime !== undefined ? envInfo.uptime + " 秒" : undefined],
|
["系统总内存", envInfo.totalMemMb ? `${envInfo.totalMemMb} MB` : undefined],
|
||||||
["总内存", envInfo.totalMemMb !== undefined ? envInfo.totalMemMb + " MB" : undefined],
|
["可用内存", envInfo.freeMemMb !== undefined && envInfo.totalMemMb
|
||||||
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
|
? `${envInfo.freeMemMb} MB${envInfo.memUsagePct ? `(已用 ${envInfo.memUsagePct})` : ""}`
|
||||||
["Node 路径", envInfo.execPath],
|
: undefined],
|
||||||
["工作目录", envInfo.cwd],
|
|
||||||
["SproutClaw 根目录", envInfo.repoRoot],
|
|
||||||
] as [string, string | undefined][]
|
] as [string, string | undefined][]
|
||||||
)
|
)
|
||||||
.filter(([, v]) => v !== undefined && v !== "")
|
.filter(([, v]) => v !== undefined && v !== "")
|
||||||
@@ -1133,6 +1417,82 @@ export function SettingsPage() {
|
|||||||
<code className={styles.envValue}>{value}</code>
|
<code className={styles.envValue}>{value}</code>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,22 +213,56 @@ export interface AvatarSettings {
|
|||||||
agentAvatarUrl: string;
|
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 {
|
export interface EnvironmentInfo {
|
||||||
nodeVersion?: string;
|
// 系统信息
|
||||||
platform?: string;
|
platform?: string;
|
||||||
arch?: string;
|
arch?: string;
|
||||||
osType?: string;
|
|
||||||
osRelease?: string;
|
osRelease?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
pid?: number;
|
numCPU?: number;
|
||||||
cwd?: string;
|
|
||||||
repoRoot?: string;
|
|
||||||
uptime?: number;
|
|
||||||
totalMemMb?: number;
|
totalMemMb?: number;
|
||||||
freeMemMb?: number;
|
freeMemMb?: number;
|
||||||
|
memUsagePct?: string;
|
||||||
|
// 进程信息
|
||||||
|
goVersion?: string;
|
||||||
|
pid?: number;
|
||||||
|
uptime?: number;
|
||||||
execPath?: string;
|
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 {
|
export interface RetryState {
|
||||||
|
|||||||
Reference in New Issue
Block a user