feat: 增强聊天 UI 与终端配置

- 重构消息列表、思考块与工具调用块样式
- ChatContext 支持 agent 分段与 markdown 渲染优化
- 后端增加 PI 命令配置与 Web 终端改进
- 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
This commit is contained in:
2026-06-26 13:21:15 +08:00
parent bd2251e89a
commit 33717d742b
27 changed files with 803 additions and 475 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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