diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0c803ac..96af49d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6c8b18b..72f7732 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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() diff --git a/backend/internal/handlers/agenttools.go b/backend/internal/handlers/agenttools.go new file mode 100644 index 0000000..248ae1b --- /dev/null +++ b/backend/internal/handlers/agenttools.go @@ -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 +} diff --git a/backend/internal/handlers/chat.go b/backend/internal/handlers/chat.go index 512721a..7842fc7 100644 --- a/backend/internal/handlers/chat.go +++ b/backend/internal/handlers/chat.go @@ -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 diff --git a/backend/internal/handlers/prompts.go b/backend/internal/handlers/prompts.go new file mode 100644 index 0000000..1454642 --- /dev/null +++ b/backend/internal/handlers/prompts.go @@ -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}) + } +} diff --git a/backend/internal/handlers/settings.go b/backend/internal/handlers/settings.go index d638188..c04619b 100644 --- a/backend/internal/handlers/settings.go +++ b/backend/internal/handlers/settings.go @@ -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, }) } } diff --git a/backend/internal/handlers/sysinfo_linux.go b/backend/internal/handlers/sysinfo_linux.go new file mode 100644 index 0000000..0d02d1c --- /dev/null +++ b/backend/internal/handlers/sysinfo_linux.go @@ -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 "" +} diff --git a/backend/internal/handlers/sysinfo_other.go b/backend/internal/handlers/sysinfo_other.go new file mode 100644 index 0000000..e37b359 --- /dev/null +++ b/backend/internal/handlers/sysinfo_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !windows + +package handlers + +func getSysMemMb() (total, free int64) { + return 0, 0 +} + +func getOSRelease() string { + return "" +} diff --git a/backend/internal/handlers/sysinfo_windows.go b/backend/internal/handlers/sysinfo_windows.go new file mode 100644 index 0000000..34ce227 --- /dev/null +++ b/backend/internal/handlers/sysinfo_windows.go @@ -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 +} diff --git a/backend/internal/services/skills.go b/backend/internal/services/skills.go index e5ff751..dfb20f0 100644 --- a/backend/internal/services/skills.go +++ b/backend/internal/services/skills.go @@ -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()) diff --git a/frontend/src/api/prompts.ts b/frontend/src/api/prompts.ts new file mode 100644 index 0000000..50d6721 --- /dev/null +++ b/frontend/src/api/prompts.ts @@ -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 }); +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index b1fe0c3..071aa64 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -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 { return apiGet("/api/settings"); @@ -55,6 +55,14 @@ export function fetchEnvironment(): Promise { return apiGet("/api/environment"); } +export function fetchEnvironmentTools(): Promise { + 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"); +} diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index 38ebc0c..0aea6a4 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -34,6 +34,7 @@ export function ChatInput() { const [dragOver, setDragOver] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [selectedSlashIndex, setSelectedSlashIndex] = useState(0); + const slashMenuWasOpenRef = useRef(false); const inputRef = useRef(null); const fileInputRef = useRef(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() { ) : null}
diff --git a/frontend/src/components/chat/SlashCommandMenu.module.css b/frontend/src/components/chat/SlashCommandMenu.module.css index 4c8ffff..9e26de1 100644 --- a/frontend/src/components/chat/SlashCommandMenu.module.css +++ b/frontend/src/components/chat/SlashCommandMenu.module.css @@ -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; + } } diff --git a/frontend/src/components/chat/SlashCommandMenu.tsx b/frontend/src/components/chat/SlashCommandMenu.tsx index c8e00fe..6fe1123 100644 --- a/frontend/src/components/chat/SlashCommandMenu.tsx +++ b/frontend/src/components/chat/SlashCommandMenu.tsx @@ -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(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 ))}
+
+ ↑↓ 导航 + Tab 填入 + Enter 执行 + Esc 关闭 +
); } diff --git a/frontend/src/routes/SettingsPage.module.css b/frontend/src/routes/SettingsPage.module.css index b9a9322..ce61b33 100644 --- a/frontend/src/routes/SettingsPage.module.css +++ b/frontend/src/routes/SettingsPage.module.css @@ -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; } diff --git a/frontend/src/routes/SettingsPage.tsx b/frontend/src/routes/SettingsPage.tsx index 948331f..7be75cd 100644 --- a/frontend/src/routes/SettingsPage.tsx +++ b/frontend/src/routes/SettingsPage.tsx @@ -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([]); + 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(null); const [togglingMcpServer, setTogglingMcpServer] = useState(null); const [togglingMcpTool, setTogglingMcpTool] = useState(null); const [togglingExtensionPath, setTogglingExtensionPath] = useState(null); const [envInfo, setEnvInfo] = useState(null); const [loadingEnv, setLoadingEnv] = useState(false); + const [toolsInfo, setToolsInfo] = useState(null); + const [loadingTools, setLoadingTools] = useState(false); const [terminalPlatform, setTerminalPlatform] = useState(""); const [terminalRequested, setTerminalRequested] = useState(false); const sidebarRef = useRef(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} + {/* ===== 命令模板 ===== */} +