feat: update chat, settings, prompts and system info modules
This commit is contained in:
@@ -56,6 +56,7 @@ func main() {
|
||||
handlers.RegisterSettings(api, cfg, database, piClient)
|
||||
handlers.RegisterTerminal(api, cfg)
|
||||
handlers.RegisterExtensionUI(api, piClient)
|
||||
handlers.RegisterPrompts(api, cfg)
|
||||
|
||||
// Static files (production: frontend/dist/)
|
||||
static.Register(r, cfg.FrontendDist)
|
||||
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
SkillsDir string
|
||||
SkillsDisabledDir string
|
||||
ExtensionsDir string
|
||||
PromptsDir string
|
||||
FrontendDist string // ../frontend/dist 相对于可执行文件
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ func (c *Config) derivePaths() {
|
||||
c.SkillsDir = filepath.Join(a, "skills")
|
||||
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
|
||||
c.ExtensionsDir = filepath.Join(a, "extensions")
|
||||
c.PromptsDir = filepath.Join(a, "prompts")
|
||||
|
||||
// frontend/dist 相对于二进制所在目录的上级
|
||||
exe, err := os.Executable()
|
||||
|
||||
179
backend/internal/handlers/agenttools.go
Normal file
179
backend/internal/handlers/agenttools.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentToolInfo holds version info for an AI coding agent tool.
|
||||
type AgentToolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Found bool `json:"found"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeInfo holds version info for a programming language runtime or compiler.
|
||||
type RuntimeInfo struct {
|
||||
Name string `json:"name"`
|
||||
Found bool `json:"found"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type agentToolDef struct {
|
||||
Name string
|
||||
Binaries []string
|
||||
Args []string
|
||||
}
|
||||
|
||||
type runtimeDef struct {
|
||||
Name string
|
||||
Binaries []string // try in order
|
||||
Args []string
|
||||
}
|
||||
|
||||
var agentToolDefs = []agentToolDef{
|
||||
{Name: "Codex", Binaries: []string{"codex"}, Args: []string{"--version"}},
|
||||
{Name: "Claude Code", Binaries: []string{"claude"}, Args: []string{"--version"}},
|
||||
{Name: "OpenCode", Binaries: []string{"opencode"}, Args: []string{"--version"}},
|
||||
}
|
||||
|
||||
var runtimeDefs = []runtimeDef{
|
||||
// python3 first; fall back to python on Windows where python3 may not exist
|
||||
{Name: "Python", Binaries: []string{"python3", "python"}, Args: []string{"--version"}},
|
||||
{Name: "Node.js", Binaries: []string{"node"}, Args: []string{"--version"}},
|
||||
{Name: "Bun", Binaries: []string{"bun"}, Args: []string{"--version"}},
|
||||
{Name: "Go", Binaries: []string{"go"}, Args: []string{"version"}},
|
||||
// Java outputs version to stderr — runVersionOutput handles this
|
||||
{Name: "Java", Binaries: []string{"java"}, Args: []string{"-version"}},
|
||||
{Name: "Rust", Binaries: []string{"rustc"}, Args: []string{"--version"}},
|
||||
// C compilers: detect both, show whichever is present
|
||||
{Name: "GCC", Binaries: []string{"gcc"}, Args: []string{"--version"}},
|
||||
{Name: "Clang", Binaries: []string{"clang"}, Args: []string{"--version"}},
|
||||
}
|
||||
|
||||
// extraSearchPaths returns common installation directories not always in PATH.
|
||||
func extraSearchPaths() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
paths := []string{
|
||||
filepath.Join(home, "bin"),
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".bun", "bin"),
|
||||
filepath.Join(home, "go", "bin"),
|
||||
filepath.Join(home, ".cargo", "bin"),
|
||||
"/usr/local/go/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
appdata := os.Getenv("APPDATA")
|
||||
paths = append(paths,
|
||||
filepath.Join(appdata, "npm"),
|
||||
filepath.Join(home, "AppData", "Local", "Programs"),
|
||||
filepath.Join(home, "AppData", "Roaming", "npm"),
|
||||
)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// lookupBinary searches PATH then extra directories for the binary.
|
||||
func lookupBinary(name string) string {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
for _, dir := range extraSearchPaths() {
|
||||
candidate := filepath.Join(dir, name)
|
||||
if runtime.GOOS == "windows" {
|
||||
candidate += ".exe"
|
||||
}
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runVersionOutput runs the binary and returns the first non-empty line from stdout
|
||||
// or stderr (some tools like Java output version to stderr).
|
||||
func runVersionOutput(binaryPath string, args []string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
_ = cmd.Run()
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out == "" {
|
||||
out = strings.TrimSpace(stderr.String())
|
||||
}
|
||||
if idx := strings.IndexByte(out, '\n'); idx >= 0 {
|
||||
out = strings.TrimSpace(out[:idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runVersion is kept for backward compatibility.
|
||||
func runVersion(binaryPath string, args []string) string {
|
||||
return runVersionOutput(binaryPath, args)
|
||||
}
|
||||
|
||||
// detectAgentTools detects all AI agent tools in parallel.
|
||||
func detectAgentTools() []AgentToolInfo {
|
||||
results := make([]AgentToolInfo, len(agentToolDefs))
|
||||
var wg sync.WaitGroup
|
||||
for i, def := range agentToolDefs {
|
||||
wg.Add(1)
|
||||
go func(idx int, d agentToolDef) {
|
||||
defer wg.Done()
|
||||
info := AgentToolInfo{Name: d.Name}
|
||||
for _, bin := range d.Binaries {
|
||||
p := lookupBinary(bin)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
info.Found = true
|
||||
info.Path = p
|
||||
info.Version = runVersionOutput(p, d.Args)
|
||||
break
|
||||
}
|
||||
results[idx] = info
|
||||
}(i, def)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// detectRuntimes detects all language runtimes in parallel.
|
||||
func detectRuntimes() []RuntimeInfo {
|
||||
results := make([]RuntimeInfo, len(runtimeDefs))
|
||||
var wg sync.WaitGroup
|
||||
for i, def := range runtimeDefs {
|
||||
wg.Add(1)
|
||||
go func(idx int, d runtimeDef) {
|
||||
defer wg.Done()
|
||||
info := RuntimeInfo{Name: d.Name}
|
||||
for _, bin := range d.Binaries {
|
||||
p := lookupBinary(bin)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
info.Found = true
|
||||
info.Path = p
|
||||
info.Version = runVersionOutput(p, d.Args)
|
||||
break
|
||||
}
|
||||
results[idx] = info
|
||||
}(i, def)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -38,6 +38,12 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept slash commands before forwarding to agent.
|
||||
if dispatch := services.TrySlashDispatch(req.Message); dispatch.Handled {
|
||||
dispatchSlashCommand(c, dispatch, piClient)
|
||||
return
|
||||
}
|
||||
|
||||
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
|
||||
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
|
||||
req.StreamingBehavior = ""
|
||||
@@ -52,6 +58,95 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchSlashCommand handles slash commands intercepted from /api/chat.
|
||||
func dispatchSlashCommand(c *gin.Context, d services.SlashDispatchResult, piClient *rpc.Client) {
|
||||
// Unsupported command (not in the whitelist)
|
||||
if d.Message != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": d.Message})
|
||||
return
|
||||
}
|
||||
|
||||
switch d.Command {
|
||||
case "new":
|
||||
slashNew(c, piClient)
|
||||
case "reload":
|
||||
slashReload(c, piClient)
|
||||
case "copy":
|
||||
slashCopy(c, piClient)
|
||||
default:
|
||||
// Forward to agent as a prompt; SSE events carry the result back.
|
||||
msg := "/" + d.Command
|
||||
if d.Args != "" {
|
||||
msg += " " + d.Args
|
||||
}
|
||||
if err := piClient.SubmitPrompt(models.ChatRequest{Message: msg}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"ok": true, "slash": true, "accepted": true})
|
||||
}
|
||||
}
|
||||
|
||||
func slashNew(c *gin.Context, piClient *rpc.Client) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "new_session"})
|
||||
if err != nil || !resp.Success {
|
||||
msg := "新建会话失败"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
} else if resp.Error != "" {
|
||||
msg = resp.Error
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: msg})
|
||||
return
|
||||
}
|
||||
out := gin.H{"ok": true, "slash": true, "action": "new_session"}
|
||||
if state, serr := piClient.SendCmd(models.RPCCommand{Type: "get_state"}); serr == nil && state.Success {
|
||||
var sd struct {
|
||||
SessionFile string `json:"sessionFile"`
|
||||
}
|
||||
if json.Unmarshal(state.Data, &sd) == nil && sd.SessionFile != "" {
|
||||
out["sessionFile"] = sd.SessionFile
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func slashReload(c *gin.Context, piClient *rpc.Client) {
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "action": "reload_sessions", "message": "已重新加载"})
|
||||
}
|
||||
|
||||
func slashCopy(c *gin.Context, piClient *rpc.Client) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
|
||||
if err != nil || !resp.Success {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "获取消息失败"})
|
||||
return
|
||||
}
|
||||
var msgs []struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(resp.Data, &msgs) != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "没有可复制的消息"})
|
||||
return
|
||||
}
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "assistant" {
|
||||
text := ""
|
||||
switch v := msgs[i].Content.(type) {
|
||||
case string:
|
||||
text = v
|
||||
default:
|
||||
b, _ := json.Marshal(v)
|
||||
text = string(b)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "action": "copy", "message": text})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "slash": true, "message": "没有助手消息可复制"})
|
||||
}
|
||||
|
||||
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ChatRequest
|
||||
|
||||
153
backend/internal/handlers/prompts.go
Normal file
153
backend/internal/handlers/prompts.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
)
|
||||
|
||||
// RegisterPrompts registers prompt-template CRUD routes.
|
||||
func RegisterPrompts(r *gin.RouterGroup, cfg *config.Config) {
|
||||
dir := cfg.PromptsDir
|
||||
r.GET("/prompts", handleListPrompts(dir))
|
||||
r.GET("/prompts/file", handleGetPromptFile(dir))
|
||||
r.POST("/prompts/file", handleSavePromptFile(dir))
|
||||
r.POST("/prompts/delete", handleDeletePromptFile(dir))
|
||||
}
|
||||
|
||||
// PromptMeta is the metadata extracted from a prompt .md file.
|
||||
type PromptMeta struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ArgumentHint string `json:"argumentHint,omitempty"`
|
||||
}
|
||||
|
||||
// parsePromptMeta reads YAML frontmatter (description / argument-hint) from content.
|
||||
func parsePromptMeta(name, content string) PromptMeta {
|
||||
title := strings.TrimSuffix(name, ".md")
|
||||
meta := PromptMeta{Name: name, Title: title}
|
||||
|
||||
if !strings.HasPrefix(content, "---\n") {
|
||||
return meta
|
||||
}
|
||||
rest := content[4:]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return meta
|
||||
}
|
||||
for _, line := range strings.Split(rest[:end], "\n") {
|
||||
if after, ok := strings.CutPrefix(line, "description:"); ok {
|
||||
meta.Description = strings.Trim(strings.TrimSpace(after), `"'`)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "argument-hint:"); ok {
|
||||
meta.ArgumentHint = strings.Trim(strings.TrimSpace(after), `"'`)
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// safePromptName validates that the filename is a plain .md name with no path traversal.
|
||||
func safePromptName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
return false
|
||||
}
|
||||
// Reject anything that looks like a path
|
||||
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handleListPrompts(dir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{"prompts": []any{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list := make([]PromptMeta, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
list = append(list, parsePromptMeta(e.Name(), string(raw)))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"prompts": list})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetPromptFile(dir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
if !safePromptName(name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"content": string(raw)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSavePromptFile(dir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !safePromptName(req.Name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件名必须以 .md 结尾,且不能含路径符"})
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, req.Name), []byte(req.Content), 0o644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeletePromptFile(dir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !safePromptName(req.Name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, req.Name)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
@@ -24,7 +30,33 @@ func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, p
|
||||
r.POST("/settings/models-config", handleSetModelsConfig(cfg, piClient))
|
||||
r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient))
|
||||
r.POST("/settings/avatars", handleSetAvatars(database))
|
||||
r.POST("/settings/restart", handleRestart())
|
||||
r.GET("/environment", handleEnvironment(cfg))
|
||||
r.GET("/environment/tools", handleEnvironmentTools())
|
||||
}
|
||||
|
||||
var processStartTime = time.Now()
|
||||
|
||||
func handleRestart() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("[restart] get executable: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[restart] start new process: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
@@ -200,15 +232,70 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
cwd, _ := os.Getwd()
|
||||
exe, _ := os.Executable()
|
||||
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
goHeapMb := int64(memStats.HeapAlloc / 1024 / 1024)
|
||||
|
||||
totalMemMb, freeMemMb := getSysMemMb()
|
||||
osRelease := getOSRelease()
|
||||
|
||||
uptimeSec := int64(time.Since(processStartTime).Seconds())
|
||||
|
||||
piCmd := cfg.PiCmd
|
||||
if len(cfg.PiArgs) > 0 {
|
||||
piCmd = strings.Join(append([]string{cfg.PiCmd}, cfg.PiArgs...), " ")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"nodeVersion": runtime.Version(),
|
||||
"goVersion": runtime.Version(),
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"osRelease": osRelease,
|
||||
"hostname": hostname,
|
||||
"numCPU": runtime.NumCPU(),
|
||||
"totalMemMb": totalMemMb,
|
||||
"freeMemMb": freeMemMb,
|
||||
"pid": os.Getpid(),
|
||||
"uptime": uptimeSec,
|
||||
"execPath": exe,
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"goHeapMb": goHeapMb,
|
||||
"cwd": cwd,
|
||||
"port": cfg.Port,
|
||||
"agentDir": cfg.AgentDir,
|
||||
"dataDir": cfg.DataDir,
|
||||
"repoRoot": cfg.RepoRoot,
|
||||
"piCmd": piCmd,
|
||||
"memUsagePct": func() string {
|
||||
if totalMemMb == 0 {
|
||||
return ""
|
||||
}
|
||||
used := totalMemMb - freeMemMb
|
||||
pct := float64(used) * 100 / float64(totalMemMb)
|
||||
return fmt.Sprintf("%.1f%%", pct)
|
||||
}(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleEnvironmentTools detects all agent tools and language runtimes in parallel.
|
||||
// This is a separate endpoint because version detection involves subprocess calls.
|
||||
func handleEnvironmentTools() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var (
|
||||
agentTools []AgentToolInfo
|
||||
runtimes []RuntimeInfo
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); agentTools = detectAgentTools() }()
|
||||
go func() { defer wg.Done(); runtimes = detectRuntimes() }()
|
||||
wg.Wait()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agentTools": agentTools,
|
||||
"runtimes": runtimes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
53
backend/internal/handlers/sysinfo_linux.go
Normal file
53
backend/internal/handlers/sysinfo_linux.go
Normal file
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getSysMemMb() (total, free int64) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
total = parseMemInfoKB(line)
|
||||
} else if strings.HasPrefix(line, "MemAvailable:") {
|
||||
free = parseMemInfoKB(line)
|
||||
}
|
||||
}
|
||||
return total / 1024, free / 1024
|
||||
}
|
||||
|
||||
func parseMemInfoKB(line string) int64 {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func getOSRelease() string {
|
||||
f, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||
return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
11
backend/internal/handlers/sysinfo_other.go
Normal file
11
backend/internal/handlers/sysinfo_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package handlers
|
||||
|
||||
func getSysMemMb() (total, free int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func getOSRelease() string {
|
||||
return ""
|
||||
}
|
||||
68
backend/internal/handlers/sysinfo_windows.go
Normal file
68
backend/internal/handlers/sysinfo_windows.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type memoryStatusEx struct {
|
||||
dwLength uint32
|
||||
dwMemoryLoad uint32
|
||||
ullTotalPhys uint64
|
||||
ullAvailPhys uint64
|
||||
ullTotalPageFile uint64
|
||||
ullAvailPageFile uint64
|
||||
ullTotalVirtual uint64
|
||||
ullAvailVirtual uint64
|
||||
ullAvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
func getSysMemMb() (total, free int64) {
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
proc := kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
var ms memoryStatusEx
|
||||
ms.dwLength = uint32(unsafe.Sizeof(ms))
|
||||
ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if ret == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return int64(ms.ullTotalPhys / 1024 / 1024), int64(ms.ullAvailPhys / 1024 / 1024)
|
||||
}
|
||||
|
||||
func getOSRelease() string {
|
||||
k, err := syscall.UTF16PtrFromString(`SOFTWARE\Microsoft\Windows NT\CurrentVersion`)
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
var handle syscall.Handle
|
||||
if err := syscall.RegOpenKeyEx(syscall.HKEY_LOCAL_MACHINE, k, 0, syscall.KEY_READ, &handle); err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
defer syscall.RegCloseKey(handle)
|
||||
|
||||
readStr := func(name string) string {
|
||||
namePtr, _ := syscall.UTF16PtrFromString(name)
|
||||
var typ uint32
|
||||
var buf [256]uint16
|
||||
n := uint32(len(buf) * 2)
|
||||
if err := syscall.RegQueryValueEx(handle, namePtr, nil, &typ, (*byte)(unsafe.Pointer(&buf[0])), &n); err != nil {
|
||||
return ""
|
||||
}
|
||||
return syscall.UTF16ToString(buf[:])
|
||||
}
|
||||
|
||||
productName := readStr("ProductName")
|
||||
displayVersion := readStr("DisplayVersion")
|
||||
if displayVersion == "" {
|
||||
displayVersion = readStr("ReleaseId")
|
||||
}
|
||||
if productName == "" {
|
||||
return "Windows"
|
||||
}
|
||||
if displayVersion != "" {
|
||||
return productName + " " + displayVersion
|
||||
}
|
||||
return productName
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
|
||||
}
|
||||
var skills []models.SkillInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
skillPath := filepath.Join(dir, e.Name())
|
||||
|
||||
Reference in New Issue
Block a user