feat: 增强聊天 UI 与终端配置
- 重构消息列表、思考块与工具调用块样式 - ChatContext 支持 agent 分段与 markdown 渲染优化 - 后端增加 PI 命令配置与 Web 终端改进 - 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
This commit is contained in:
@@ -27,16 +27,16 @@ func main() {
|
||||
defer database.Close()
|
||||
log.Printf("[sproutclaw-web] db: %s", database.DbPath)
|
||||
|
||||
// pi CLI RPC client
|
||||
// SproutClaw CLI RPC client
|
||||
piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.RepoRoot, cfg.AgentDir, func(code int) {
|
||||
if code != 0 {
|
||||
log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code)
|
||||
log.Printf("[sproutclaw-web] SproutClaw CLI exited with code %d, shutting down", code)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("pi client: %v", err)
|
||||
log.Fatalf("SproutClaw client: %v", err)
|
||||
}
|
||||
defer piClient.Stop()
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ConfigDirName = ".sproutclaw"
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
AgentDir string
|
||||
RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录)
|
||||
RepoRoot string // SproutClaw 仓库根目录(CLI 子进程的工作目录)
|
||||
DataDir string // sproutclaw-web 自己的数据目录(SQLite 等)
|
||||
PiCmd string // pi CLI 启动命令(完整路径)
|
||||
PiCmd string // SproutClaw CLI 启动命令(完整路径)
|
||||
PiArgs []string
|
||||
|
||||
// 派生路径(从 AgentDir 推导)
|
||||
@@ -35,13 +37,16 @@ func New() *Config {
|
||||
agentDir := flag.String("agent-dir", "", "SproutClaw agent directory (e.g. /path/to/.sproutclaw/agent)")
|
||||
repoRoot := flag.String("repo-root", "", "SproutClaw repo root (CLI cwd); defaults to two levels above agent-dir")
|
||||
dataDir := flag.String("data-dir", "", "sproutclaw-web data dir for SQLite (defaults to ./data)")
|
||||
piCmd := flag.String("pi-cmd", "", "pi CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
|
||||
sproutclawCmd := flag.String("sproutclaw-cmd", "", "SproutClaw CLI command (alias for --pi-cmd)")
|
||||
piCmd := flag.String("pi-cmd", "", "SproutClaw CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
|
||||
frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)")
|
||||
flag.Parse()
|
||||
|
||||
if *agentDir == "" {
|
||||
cwd, _ := os.Getwd()
|
||||
*agentDir = filepath.Join(cwd, ".sproutclaw", "agent")
|
||||
*agentDir = resolveDefaultAgentDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(*agentDir); err == nil {
|
||||
*agentDir = abs
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
@@ -50,9 +55,11 @@ func New() *Config {
|
||||
RepoRoot: *repoRoot,
|
||||
DataDir: *dataDir,
|
||||
}
|
||||
// repoRoot defaults to the directory two levels above agentDir (.sproutclaw/agent -> repo root)
|
||||
if cfg.RepoRoot == "" {
|
||||
cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir))
|
||||
cfg.RepoRoot = inferRepoRoot(*agentDir)
|
||||
}
|
||||
if abs, err := filepath.Abs(cfg.RepoRoot); err == nil {
|
||||
cfg.RepoRoot = abs
|
||||
}
|
||||
// dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/)
|
||||
if cfg.DataDir == "" {
|
||||
@@ -62,10 +69,14 @@ func New() *Config {
|
||||
if abs, err := filepath.Abs(cfg.DataDir); err == nil {
|
||||
cfg.DataDir = abs
|
||||
}
|
||||
// --pi-cmd may include arguments separated by spaces, e.g.
|
||||
// "node /path/to/tsx/dist/cli.mjs /path/to/pi/cli.ts"
|
||||
// --pi-cmd / --sproutclaw-cmd may include arguments separated by spaces, e.g.
|
||||
// "node /path/to/tsx/dist/cli.mjs /path/to/cli.ts"
|
||||
// Split into executable + args so exec.Command receives them correctly.
|
||||
if parts := strings.Fields(*piCmd); len(parts) > 0 {
|
||||
cmdLine := strings.TrimSpace(*sproutclawCmd)
|
||||
if cmdLine == "" {
|
||||
cmdLine = strings.TrimSpace(*piCmd)
|
||||
}
|
||||
if parts := strings.Fields(cmdLine); len(parts) > 0 {
|
||||
cfg.PiCmd = parts[0]
|
||||
cfg.PiArgs = parts[1:]
|
||||
}
|
||||
@@ -107,3 +118,32 @@ func Platform() string {
|
||||
func Arch() string {
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
func resolveDefaultAgentDir() string {
|
||||
if env := strings.TrimSpace(os.Getenv("SPROUTCLAW_AGENT_DIR")); env != "" {
|
||||
return env
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
candidates := []string{
|
||||
filepath.Join(cwd, ConfigDirName, "agent"),
|
||||
filepath.Join(cwd, "..", "sproutclaw", ConfigDirName, "agent"),
|
||||
filepath.Join(cwd, "..", "sproutclaw", ".pi", "agent"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if st, err := os.Stat(candidate); err == nil && st.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return filepath.Join(cwd, ConfigDirName, "agent")
|
||||
}
|
||||
|
||||
func inferRepoRoot(agentDir string) string {
|
||||
agentDir = filepath.Clean(agentDir)
|
||||
if filepath.Base(agentDir) == "agent" {
|
||||
configDir := filepath.Base(filepath.Dir(agentDir))
|
||||
if configDir == ConfigDirName || configDir == ".pi" {
|
||||
return filepath.Dir(filepath.Dir(agentDir))
|
||||
}
|
||||
}
|
||||
return filepath.Dir(filepath.Dir(agentDir))
|
||||
}
|
||||
|
||||
@@ -225,25 +225,26 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"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,
|
||||
"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,
|
||||
"sproutclawCmd": piCmd,
|
||||
"memUsagePct": func() string {
|
||||
if totalMemMb == 0 {
|
||||
return ""
|
||||
|
||||
@@ -37,7 +37,7 @@ func handleTerminalWS(cfg *config.Config) gin.HandlerFunc {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := services.ServeWebTerminal(conn, cfg.RepoRoot); err != nil {
|
||||
if err := services.ServeWebTerminal(conn, cfg.RepoRoot, cfg.AgentDir); err != nil {
|
||||
_ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,16 +73,19 @@ type Client struct {
|
||||
// with the exit code when the subprocess terminates.
|
||||
func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) {
|
||||
if piCmd == "" {
|
||||
return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
|
||||
return nil, fmt.Errorf("SproutClaw CLI command not specified (use --pi-cmd or --sproutclaw-cmd)")
|
||||
}
|
||||
|
||||
// pi enters RPC mode via "--mode rpc"
|
||||
// SproutClaw enters RPC mode via "--mode rpc"
|
||||
args := append(append([]string{}, piArgs...), "--mode", "rpc")
|
||||
cmd := exec.Command(piCmd, args...)
|
||||
cmd.Dir = repoRoot
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir))
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir),
|
||||
fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir),
|
||||
)
|
||||
|
||||
log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot)
|
||||
log.Printf("[sproutclaw-web] launching SproutClaw RPC: %s %s (cwd=%s agent-dir=%s)", piCmd, strings.Join(args, " "), repoRoot, agentDir)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
@@ -115,7 +118,7 @@ func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit
|
||||
if cmd.ProcessState != nil {
|
||||
code = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code)
|
||||
log.Printf("[sproutclaw-web] SproutClaw RPC exited, code=%d", code)
|
||||
onExit(code)
|
||||
}()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type terminalWSMessage struct {
|
||||
}
|
||||
|
||||
// ServeWebTerminal attaches a PTY shell session to the websocket connection.
|
||||
func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
|
||||
func ServeWebTerminal(conn *websocket.Conn, workDir, agentDir string) error {
|
||||
abs, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析目录失败: %w", err)
|
||||
@@ -34,15 +34,21 @@ func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
|
||||
return fmt.Errorf("不是有效目录: %s", abs)
|
||||
}
|
||||
|
||||
envInject := map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
}
|
||||
if agentDir != "" {
|
||||
envInject["SPROUTCLAW_CODING_AGENT_DIR"] = agentDir
|
||||
envInject["PI_CODING_AGENT_DIR"] = agentDir
|
||||
}
|
||||
|
||||
ptmx, err := crosspty.Start(crosspty.CommandConfig{
|
||||
Argv: defaultShellArgv(),
|
||||
Dir: abs,
|
||||
Env: os.Environ(),
|
||||
EnvInject: map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
},
|
||||
Size: crosspty.TermSize{Rows: 24, Cols: 80},
|
||||
Argv: defaultShellArgv(),
|
||||
Dir: abs,
|
||||
Env: os.Environ(),
|
||||
EnvInject: envInject,
|
||||
Size: crosspty.TermSize{Rows: 24, Cols: 80},
|
||||
})
|
||||
if err != nil {
|
||||
if err == crosspty.ErrConPTYNotSupported {
|
||||
|
||||
Reference in New Issue
Block a user