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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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");
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 {