diff --git a/.gitignore b/.gitignore index d71275b..543a364 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ backend/sproutclaw-web backend/sproutclaw-web.exe +backend/data/ frontend/dist/ frontend/dist-desktop/ frontend/node_modules/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a558822..0c803ac 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "os" - "path/filepath" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" @@ -18,11 +17,10 @@ import ( func main() { cfg := config.New() - log.Printf("[sproutclaw-web] port=%d agent-dir=%s", cfg.Port, cfg.AgentDir) + log.Printf("[sproutclaw-web] port=%d agent-dir=%s repo-root=%s", cfg.Port, cfg.AgentDir, cfg.RepoRoot) - // Database (stores in agent data dir) - dataDir := filepath.Join(cfg.AgentDir, "data", "sproutclaw-web") - database, err := db.Init(dataDir) + // Database (stores in backend/data) + database, err := db.Init(cfg.DataDir) if err != nil { log.Fatalf("db init: %v", err) } @@ -30,7 +28,7 @@ func main() { log.Printf("[sproutclaw-web] db: %s", database.DbPath) // pi CLI RPC client - piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.AgentDir, func(code int) { + 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) os.Exit(1) @@ -56,13 +54,14 @@ func main() { handlers.RegisterCommands(api, cfg) handlers.RegisterWebuiConfig(api, database) handlers.RegisterSettings(api, cfg, database, piClient) + handlers.RegisterTerminal(api, cfg) handlers.RegisterExtensionUI(api, piClient) // Static files (production: frontend/dist/) static.Register(r, cfg.FrontendDist) addr := fmt.Sprintf("0.0.0.0:%d", cfg.Port) - log.Printf("[sproutclaw-web] listening on http://localhost:%d", cfg.Port) + log.Printf("[sproutclaw-web] listening on http://0.0.0.0:%d (LAN: http://<本机IP>:%d)", cfg.Port, cfg.Port) if err := r.Run(addr); err != nil { log.Fatalf("server: %v", err) } diff --git a/backend/go.mod b/backend/go.mod index ee5eb1e..b05ebd0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,10 +1,11 @@ module sproutclaw-web -go 1.23 +go 1.24.0 require ( + github.com/Kodecable/crosspty v1.0.0 github.com/gin-gonic/gin v1.10.0 - github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 modernc.org/sqlite v1.35.0 ) @@ -13,6 +14,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -20,6 +22,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -35,7 +38,7 @@ require ( golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 945d4cd..d4e2bf3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/Kodecable/crosspty v1.0.0 h1:pyVYwEZSpLo6ww1/a02L+sZSft/ukKzUT5rZb1PaB3s= +github.com/Kodecable/crosspty v1.0.0/go.mod h1:5tY5E6UCTGhnOzVBSNN1qpwa7aZX5vzS/ovsqPuzKFI= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -6,6 +8,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -34,6 +38,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -88,8 +94,10 @@ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f401dbf..6c8b18b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,11 +5,14 @@ import ( "os" "path/filepath" "runtime" + "strings" ) type Config struct { Port int AgentDir string + RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录) + DataDir string // sproutclaw-web 自己的数据目录(SQLite 等) PiCmd string // pi CLI 启动命令(完整路径) PiArgs []string @@ -29,11 +32,13 @@ type Config struct { func New() *Config { port := flag.Int("port", 19133, "HTTP port") agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)") - piCmd := flag.String("pi-cmd", "", "pi CLI executable path") + repoRoot := flag.String("repo-root", "", "pi repo root (pi 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\")") + frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)") flag.Parse() if *agentDir == "" { - // 默认值:当前目录的 .pi/agent cwd, _ := os.Getwd() *agentDir = filepath.Join(cwd, ".pi", "agent") } @@ -41,9 +46,34 @@ func New() *Config { cfg := &Config{ Port: *port, AgentDir: *agentDir, - PiCmd: *piCmd, + RepoRoot: *repoRoot, + DataDir: *dataDir, + } + // repoRoot defaults to the directory two levels above agentDir (.pi/agent -> repo root) + if cfg.RepoRoot == "" { + cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir)) + } + // dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/) + if cfg.DataDir == "" { + cwd, _ := os.Getwd() + cfg.DataDir = filepath.Join(cwd, "data") + } + 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" + // Split into executable + args so exec.Command receives them correctly. + if parts := strings.Fields(*piCmd); len(parts) > 0 { + cfg.PiCmd = parts[0] + cfg.PiArgs = parts[1:] } cfg.derivePaths() + + // Override frontend dist if explicitly specified + if *frontendDist != "" { + cfg.FrontendDist = *frontendDist + } return cfg } diff --git a/backend/internal/handlers/chat.go b/backend/internal/handlers/chat.go index 420b73b..512721a 100644 --- a/backend/internal/handlers/chat.go +++ b/backend/internal/handlers/chat.go @@ -31,7 +31,6 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc { return } - // Validate images if len(req.Images) > 0 { if err := services.ValidateImages(req.Images); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) @@ -39,51 +38,40 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc { } } - // Try slash command dispatch - result := services.TrySlashDispatch(req.Message) - if result.Handled { - if result.Message != "" { - // unsupported command – return friendly message - c.JSON(http.StatusOK, gin.H{"handled": true, "message": result.Message}) - return - } - // supported built-in command – forward to pi CLI as a command type - resp, err := piClient.SendCmd(models.RPCCommand{ - Type: result.Command, - Args: result.Args, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"handled": true, "result": resp.Result}) - return + // normalize streamingBehavior: only "steer" / "followUp" are forwarded + if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" { + req.StreamingBehavior = "" } - // Regular prompt + // Submit prompt; agent output streams back over SSE. if err := piClient.SubmitPrompt(req); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusAccepted, gin.H{"ok": true, "accepted": true}) } } func handleSteer(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - var req models.SteerRequest + var req models.ChatRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - err := piClient.SubmitPrompt(models.ChatRequest{ - Message: req.Message, - StreamingBehavior: "steer", + resp, err := piClient.SendCmd(models.RPCCommand{ + Type: "steer", + Message: req.Message, + Images: req.Images, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "steer 失败")}) + return + } c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } @@ -95,11 +83,19 @@ func handleFollowUp(piClient *rpc.Client) gin.HandlerFunc { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - req.StreamingBehavior = "follow_up" - if err := piClient.SubmitPrompt(req); err != nil { + resp, err := piClient.SendCmd(models.RPCCommand{ + Type: "follow_up", + Message: req.Message, + Images: req.Images, + }) + if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "follow_up 失败")}) + return + } c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } @@ -112,21 +108,24 @@ func handleBash(piClient *rpc.Client) gin.HandlerFunc { return } resp, err := piClient.SendCmd(models.RPCCommand{ - Type: "bash", - Args: req.Command, + Type: "bash", + Command: req.Command, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, resp.Result) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "命令执行失败")}) + return + } + c.JSON(http.StatusOK, jsonRaw(resp.Data)) } } func handleAbort(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - _, err := piClient.SendCmd(models.RPCCommand{Type: "abort"}) - if err != nil { + if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort"}); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } @@ -136,8 +135,7 @@ func handleAbort(piClient *rpc.Client) gin.HandlerFunc { func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - _, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"}) - if err != nil { + if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"}); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } @@ -147,16 +145,16 @@ func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc { func handleMessages(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - var req models.MessagesRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) + resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"}) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - snap := piClient.GetSnapshot() - c.JSON(http.StatusOK, gin.H{ - "sessionFile": snap.SessionFile, - "events": snap.ReplayEvents, - }) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + c.JSON(http.StatusOK, jsonRaw(resp.Data)) } } @@ -173,7 +171,7 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc { return } - ch := make(chan []byte, 64) + ch := make(chan []byte, 256) client := &rpc.SSEClient{ Ch: ch, Done: c.Request.Context().Done(), @@ -181,19 +179,19 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc { piClient.RegisterSSEClient(client) defer piClient.UnregisterSSEClient(client) - // send initial keepalive - _, _ = io.WriteString(c.Writer, ": keepalive\n\n") + // initial comment to open the stream + _, _ = io.WriteString(c.Writer, ": connected\n\n") flusher.Flush() for { select { case <-c.Request.Context().Done(): return - case raw, ok := <-ch: + case frame, ok := <-ch: if !ok { return } - frame := rpc.FormatSSEEvent(raw) + // frame is already a fully-formatted "data: ...\n\n" SSE frame if _, err := c.Writer.Write(frame); err != nil { return } @@ -203,12 +201,21 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc { } } -// jsonRaw is a helper to return pre-serialized JSON. +// jsonRaw decodes pre-serialized JSON for re-emission via gin. func jsonRaw(raw json.RawMessage) any { - if raw == nil { - return nil + if len(raw) == 0 { + return gin.H{} } var v any - _ = json.Unmarshal(raw, &v) + if err := json.Unmarshal(raw, &v); err != nil { + return gin.H{} + } return v } + +func orDefault(s, fallback string) string { + if s == "" { + return fallback + } + return s +} diff --git a/backend/internal/handlers/models.go b/backend/internal/handlers/models.go index db7c3b9..228a321 100644 --- a/backend/internal/handlers/models.go +++ b/backend/internal/handlers/models.go @@ -1,9 +1,7 @@ package handlers import ( - "encoding/json" "net/http" - "os" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" @@ -13,27 +11,23 @@ import ( // RegisterModels registers model-related routes. func RegisterModels(r *gin.RouterGroup, cfg *config.Config, piClient *rpc.Client) { - r.GET("/models", handleGetModels(cfg, piClient)) + r.GET("/models", handleGetModels(piClient)) r.POST("/model", handleSetModel(piClient)) r.POST("/thinking", handleSetThinking(piClient)) } -func handleGetModels(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { +func handleGetModels(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - resp, err := piClient.SendCmd(models.RPCCommand{Type: "list_models"}) + resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_available_models"}) if err != nil { - // fallback: read models.json - data, ferr := os.ReadFile(cfg.ModelsConfigFile) - if ferr != nil { - c.JSON(http.StatusOK, models.ModelsResponse{Models: []models.ModelEntry{}}) - return - } - var raw any - _ = json.Unmarshal(data, &raw) - c.JSON(http.StatusOK, raw) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, jsonRaw(resp.Result)) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + c.JSON(http.StatusOK, jsonRaw(resp.Data)) } } @@ -53,7 +47,11 @@ func handleSetModel(piClient *rpc.Client) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, jsonRaw(resp.Result)) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + c.JSON(http.StatusOK, gin.H{"model": jsonRaw(resp.Data)}) } } @@ -65,13 +63,17 @@ func handleSetThinking(piClient *rpc.Client) gin.HandlerFunc { return } resp, err := piClient.SendCmd(models.RPCCommand{ - Type: "set_thinking", - Budget: req.Budget, + Type: "set_thinking_level", + Level: req.Level, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, jsonRaw(resp.Result)) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } diff --git a/backend/internal/handlers/sessions.go b/backend/internal/handlers/sessions.go index ebd4e6b..d9ae02b 100644 --- a/backend/internal/handlers/sessions.go +++ b/backend/internal/handlers/sessions.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "os" @@ -47,7 +48,24 @@ func handleNewSession(piClient *rpc.Client) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, jsonRaw(resp.Result)) + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + // merge sessionFile from a follow-up get_state + out := map[string]any{} + if len(resp.Data) > 0 { + _ = jsonUnmarshalInto(resp.Data, &out) + } + if state, serr := piClient.SendCmd(models.RPCCommand{Type: "get_state"}); serr == nil && state.Success { + var sd struct { + SessionFile string `json:"sessionFile"` + } + if jsonUnmarshalInto(state.Data, &sd) == nil && sd.SessionFile != "" { + out["sessionFile"] = sd.SessionFile + } + } + c.JSON(http.StatusOK, out) } } @@ -121,20 +139,31 @@ func handleLoadSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } - lines, err := services.ReadSessionMessages(req.Path) - if err != nil { - c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()}) - return - } - resp, err := piClient.SendCmd(models.RPCCommand{ - Type: "switch_session", - Path: req.Path, + sw, err := piClient.SendCmd(models.RPCCommand{ + Type: "switch_session", + SessionPath: req.Path, + CwdOverride: cfg.RepoRoot, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"messages": lines, "result": jsonRaw(resp.Result)}) + if !sw.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error}) + return + } + mr, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"}) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) + return + } + if !mr.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: mr.Error}) + return + } + // mr.Data is { messages: [...] }; forward it plus a session summary + summary, _ := services.ReadSessionSummaryByPath(req.Path) + c.JSON(http.StatusOK, gin.H{"messages": extractMessages(mr.Data), "session": summary}) } } @@ -149,15 +178,25 @@ func handleActivateSession(cfg *config.Config, piClient *rpc.Client) gin.Handler c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } - resp, err := piClient.SendCmd(models.RPCCommand{ - Type: "switch_session", - Path: req.Path, + sw, err := piClient.SendCmd(models.RPCCommand{ + Type: "switch_session", + SessionPath: req.Path, + CwdOverride: cfg.RepoRoot, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } - c.JSON(http.StatusOK, jsonRaw(resp.Result)) + if !sw.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error}) + return + } + state, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"}) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "state": jsonRaw(state.Data)}) } } @@ -182,10 +221,36 @@ func handleNameSession(cfg *config.Config) gin.HandlerFunc { func handleSessionState(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - snap := piClient.GetSnapshot() - c.JSON(http.StatusOK, models.SessionStateResponse{ - IsStreaming: snap.IsStreaming, - SessionFile: snap.SessionFile, - }) + resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"}) + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) + return + } + if !resp.Success { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) + return + } + c.JSON(http.StatusOK, jsonRaw(resp.Data)) } } + +// extractMessages pulls the "messages" array out of a get_messages data payload. +func extractMessages(data json.RawMessage) any { + if len(data) == 0 { + return []any{} + } + var wrap struct { + Messages json.RawMessage `json:"messages"` + } + if jsonUnmarshalInto(data, &wrap) == nil && len(wrap.Messages) > 0 { + return jsonRaw(wrap.Messages) + } + return jsonRaw(data) +} + +func jsonUnmarshalInto(raw json.RawMessage, v any) error { + if len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, v) +} diff --git a/backend/internal/handlers/settings.go b/backend/internal/handlers/settings.go index 29fbf9c..d638188 100644 --- a/backend/internal/handlers/settings.go +++ b/backend/internal/handlers/settings.go @@ -1,11 +1,9 @@ package handlers import ( - "fmt" "net/http" "os" "runtime" - "strings" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" @@ -17,7 +15,7 @@ import ( // RegisterSettings registers all settings-related routes. func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) { - r.GET("/settings", handleGetSettings(cfg)) + r.GET("/settings", handleGetSettings(cfg, database)) r.POST("/settings/skills/toggle", handleToggleSkill(cfg, piClient)) r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient)) r.POST("/settings/mcp/server/toggle", handleToggleMCPServer(cfg, piClient)) @@ -29,53 +27,67 @@ func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, p r.GET("/environment", handleEnvironment(cfg)) } -func handleGetSettings(cfg *config.Config) gin.HandlerFunc { +func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { - skills := services.ListSkills(cfg.AgentDir) - extensions := services.ListExtensions(cfg.AgentDir) - mcpServers := services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile) systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile) modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile) + userAvatar, _ := database.GetConfig("userAvatarUrl") + agentAvatar, _ := database.GetConfig("agentAvatarUrl") - c.JSON(http.StatusOK, models.SettingsResponse{ - Skills: skills, - Extensions: extensions, - MCPServers: mcpServers, - SystemPrompt: systemPrompt, - ModelsConfig: modelsConfig, + c.JSON(http.StatusOK, gin.H{ + "systemPrompt": systemPrompt, + "systemPromptPath": cfg.SystemPromptFile, + "modelsConfig": modelsConfig, + "modelsConfigPath": cfg.ModelsConfigFile, + "userAvatarUrl": userAvatar, + "agentAvatarUrl": agentAvatar, + "skills": services.ListSkills(cfg.AgentDir), + "extensions": services.ListExtensions(cfg.AgentDir), + "extensionsPath": cfg.ExtensionsDir, + "mcpTools": services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile), + "mcpConfigPath": cfg.McpConfigFile, + "mcpCachePath": cfg.McpCacheFile, }) } } func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - var req models.ToggleRequest + var req models.SkillToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - if err := services.ToggleSkill(cfg.AgentDir, req.ID, req.Enabled); err != nil { + if req.Path == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "skill path 无效"}) + return + } + if err := services.ToggleSkill(cfg.AgentDir, req.Path, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { - var req models.ToggleRequest + var req models.ExtensionToggleRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - if err := services.ToggleExtension(cfg.AgentDir, req.ID, req.Enabled); err != nil { + if req.Path == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "extension path 无效"}) + return + } + if err := services.ToggleExtension(cfg.AgentDir, req.Path, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } @@ -86,12 +98,16 @@ func handleToggleMCPServer(cfg *config.Config, piClient *rpc.Client) gin.Handler c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } + if req.Server == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server 名称无效"}) + return + } if err := services.ToggleMCPServer(cfg.McpConfigFile, req.Server, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } @@ -102,19 +118,23 @@ func handleToggleMCPTool(cfg *config.Config, piClient *rpc.Client) gin.HandlerFu c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } + if req.Server == "" || req.Tool == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server / tool 名称无效"}) + return + } if err := services.ToggleMCPTool(cfg.McpConfigFile, req.Server, req.Tool, req.Enabled); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleReload(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } @@ -125,12 +145,17 @@ func handleSetModelsConfig(cfg *config.Config, piClient *rpc.Client) gin.Handler c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - if err := services.WriteModelsConfig(cfg.ModelsConfigFile, req.Content); err != nil { + // Guard: the field must be present (not just empty) to avoid clobbering. + if req.ModelsConfig == nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 modelsConfig 字段"}) + return + } + if err := services.WriteModelsConfig(cfg.ModelsConfigFile, *req.ModelsConfig); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true, "modelsConfigPath": cfg.ModelsConfigFile}) } } @@ -141,12 +166,18 @@ func handleSetSystemPrompt(cfg *config.Config, piClient *rpc.Client) gin.Handler c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - if err := services.WriteSystemPrompt(cfg.SystemPromptFile, req.Content); err != nil { + // Guard: the field must be present. A missing field would otherwise + // silently write an empty string and wipe the prompt file. + if req.SystemPrompt == nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 systemPrompt 字段"}) + return + } + if err := services.WriteSystemPrompt(cfg.SystemPromptFile, *req.SystemPrompt); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } reloadAgent(piClient) - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + c.JSON(http.StatusOK, gin.H{"ok": true, "systemPromptPath": cfg.SystemPromptFile}) } } @@ -157,27 +188,27 @@ func handleSetAvatars(database *db.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } - if req.UserAvatar != "" { - _ = database.SetConfig("userAvatar", req.UserAvatar) - } - if req.AssistantAvatar != "" { - _ = database.SetConfig("assistantAvatar", req.AssistantAvatar) - } - c.JSON(http.StatusOK, models.OKResponse{OK: true}) + userURL := normalizeAvatarURL(req.UserAvatarURL) + agentURL := normalizeAvatarURL(req.AgentAvatarURL) + _ = database.SetConfig("userAvatarUrl", userURL) + _ = database.SetConfig("agentAvatarUrl", agentURL) + c.JSON(http.StatusOK, gin.H{"ok": true, "userAvatarUrl": userURL, "agentAvatarUrl": agentURL}) } } func handleEnvironment(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { hostname, _ := os.Hostname() - lanAddrs := getLANAddresses() + cwd, _ := os.Getwd() c.JSON(http.StatusOK, gin.H{ - "platform": runtime.GOOS, - "arch": runtime.GOARCH, - "hostname": hostname, - "version": runtime.Version(), - "port": cfg.Port, - "lan": lanAddrs, + "nodeVersion": runtime.Version(), + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "hostname": hostname, + "pid": os.Getpid(), + "cwd": cwd, + "port": cfg.Port, + "repoRoot": cfg.RepoRoot, }) } } @@ -185,31 +216,3 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc { func reloadAgent(piClient *rpc.Client) { _, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"}) } - -func getLANAddresses() []string { - // simplified – returns first non-loopback IPv4 - var addrs []string - hostname, err := os.Hostname() - if err != nil { - return addrs - } - _ = hostname - // Use a simple approach: read network interfaces via fmt - // For a full implementation, net.InterfaceAddrs() would be used - ifaces := getNetworkAddresses() - for _, a := range ifaces { - if !strings.HasPrefix(a, "127.") && !strings.HasPrefix(a, "::") { - addrs = append(addrs, a) - } - } - return addrs -} - -func getNetworkAddresses() []string { - var result []string - hostname, _ := os.Hostname() - if hostname != "" { - result = append(result, fmt.Sprintf("%s (hostname)", hostname)) - } - return result -} diff --git a/backend/internal/handlers/terminal.go b/backend/internal/handlers/terminal.go new file mode 100644 index 0000000..f9a8769 --- /dev/null +++ b/backend/internal/handlers/terminal.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "net/http" + "runtime" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "sproutclaw-web/internal/config" + "sproutclaw-web/internal/services" +) + +var terminalUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// RegisterTerminal registers web terminal routes. +func RegisterTerminal(r *gin.RouterGroup, cfg *config.Config) { + r.GET("/terminal", handleTerminalInfo(cfg)) + r.GET("/terminal/ws", handleTerminalWS(cfg)) +} + +func handleTerminalInfo(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "repoRoot": cfg.RepoRoot, + "platform": runtime.GOOS, + }) + } +} + +func handleTerminalWS(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + if err := services.ServeWebTerminal(conn, cfg.RepoRoot); err != nil { + _ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()}) + } + } +} diff --git a/backend/internal/handlers/webui_config.go b/backend/internal/handlers/webui_config.go index a7893c8..2a810f1 100644 --- a/backend/internal/handlers/webui_config.go +++ b/backend/internal/handlers/webui_config.go @@ -19,11 +19,11 @@ func RegisterWebuiConfig(r *gin.RouterGroup, database *db.DB) { func handleGetAvatars(database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { - user, _ := database.GetConfig("userAvatar") - assistant, _ := database.GetConfig("assistantAvatar") + user, _ := database.GetConfig("userAvatarUrl") + agent, _ := database.GetConfig("agentAvatarUrl") c.JSON(http.StatusOK, models.AvatarsResponse{ - UserAvatar: normalizeAvatarURL(user), - AssistantAvatar: normalizeAvatarURL(assistant), + UserAvatarURL: normalizeAvatarURL(user), + AgentAvatarURL: normalizeAvatarURL(agent), }) } } diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index e6c4a26..cb500d6 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -7,7 +7,7 @@ func CORS() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Extensions") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return diff --git a/backend/internal/models/types.go b/backend/internal/models/types.go index 42fb9ad..a8761cd 100644 --- a/backend/internal/models/types.go +++ b/backend/internal/models/types.go @@ -7,29 +7,33 @@ import "encoding/json" type RPCCommand struct { Type string `json:"type"` ID string `json:"id,omitempty"` - // prompt fields - Text string `json:"text,omitempty"` - Images []ChatImage `json:"images,omitempty"` - // model / thinking fields - Provider string `json:"provider,omitempty"` - ModelID string `json:"modelId,omitempty"` - Budget *int `json:"budget,omitempty"` - // session fields - Path string `json:"path,omitempty"` - Name string `json:"name,omitempty"` - // streaming behaviour - StreamingBehavior string `json:"streamingBehavior,omitempty"` - // slash dispatch passthrough - Args string `json:"args,omitempty"` - // extension ui - Response json.RawMessage `json:"response,omitempty"` + // prompt / steer / follow_up + Message string `json:"message,omitempty"` + Images []ChatImage `json:"images,omitempty"` + StreamingBehavior string `json:"streamingBehavior,omitempty"` + // model / thinking + Provider string `json:"provider,omitempty"` + ModelID string `json:"modelId,omitempty"` + Level json.RawMessage `json:"level,omitempty"` + // session + SessionPath string `json:"sessionPath,omitempty"` + CwdOverride string `json:"cwdOverride,omitempty"` + Name string `json:"name,omitempty"` + // bash + Command string `json:"command,omitempty"` + ExcludeFromContext bool `json:"excludeFromContext,omitempty"` } +// RPCResponse mirrors the pi CLI response frame: +// +// {"type":"response","id":"req_1","command":"get_state","success":true,"data":{...},"error":"..."} type RPCResponse struct { - Type string `json:"type"` - ID string `json:"id,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error string `json:"error,omitempty"` + Type string `json:"type"` + ID string `json:"id,omitempty"` + Command string `json:"command,omitempty"` + Success bool `json:"success"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` } // Raw event line forwarded to SSE clients as-is @@ -130,7 +134,7 @@ type SetModelRequest struct { } type SetThinkingRequest struct { - Budget *int `json:"budget"` // nil = disable + Level json.RawMessage `json:"level"` // string ("high"/"off"...) or number } // ── Commands ───────────────────────────────────────────────────────────────── @@ -148,8 +152,8 @@ type CommandsResponse struct { // ── WebUI Config ────────────────────────────────────────────────────────────── type AvatarsResponse struct { - UserAvatar string `json:"userAvatar,omitempty"` - AssistantAvatar string `json:"assistantAvatar,omitempty"` + UserAvatarURL string `json:"userAvatarUrl"` + AgentAvatarURL string `json:"agentAvatarUrl"` } type WebuiConfigResponse struct { @@ -171,46 +175,53 @@ type WebuiConfigDeleteRequest struct { } // ── Settings ────────────────────────────────────────────────────────────────── +// Field names mirror the frontend SettingsData contract (frontend/src/types/events.ts). type SkillInfo struct { Name string `json:"name"` Description string `json:"description,omitempty"` - Enabled bool `json:"enabled"` Path string `json:"path"` + Source string `json:"source,omitempty"` + Scope string `json:"scope,omitempty"` + Enabled bool `json:"enabled"` + Toggleable bool `json:"toggleable"` } type ExtensionInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Enabled bool `json:"enabled"` - Source string `json:"source"` // local | npm - Commands []string `json:"commands,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Category string `json:"category,omitempty"` // local | npm + Source string `json:"source,omitempty"` + Version string `json:"version,omitempty"` + Enabled bool `json:"enabled"` + Toggleable bool `json:"toggleable"` // only local extensions can be toggled + Commands []string `json:"commands,omitempty"` } type MCPTool struct { + Server string `json:"server,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` - Disabled bool `json:"disabled"` + Enabled bool `json:"enabled"` } type MCPServer struct { - Name string `json:"name"` - Disabled bool `json:"disabled"` - Tools []MCPTool `json:"tools,omitempty"` + Name string `json:"name"` + Configured bool `json:"configured"` + Enabled bool `json:"enabled"` + ToolCount int `json:"toolCount"` + EnabledToolCount int `json:"enabledToolCount"` + Tools []MCPTool `json:"tools,omitempty"` } -type SettingsResponse struct { - Skills []SkillInfo `json:"skills"` - Extensions []ExtensionInfo `json:"extensions"` - MCPServers []MCPServer `json:"mcpServers"` - SystemPrompt string `json:"systemPrompt,omitempty"` - ModelsConfig string `json:"modelsConfig,omitempty"` +// SkillToggleRequest / ExtensionToggleRequest identify the target by path. +type SkillToggleRequest struct { + Path string `json:"path"` + Enabled bool `json:"enabled"` } -type ToggleRequest struct { - ID string `json:"id"` +type ExtensionToggleRequest struct { + Path string `json:"path"` Enabled bool `json:"enabled"` } @@ -225,24 +236,19 @@ type MCPToolToggleRequest struct { Enabled bool `json:"enabled"` } +// SystemPromptRequest uses a pointer so a missing field is distinguishable from +// an empty string — guards against accidentally clearing the file. type SystemPromptRequest struct { - Content string `json:"content"` + SystemPrompt *string `json:"systemPrompt"` } type ModelsConfigRequest struct { - Content string `json:"content"` + ModelsConfig *string `json:"modelsConfig"` } type AvatarsSetRequest struct { - UserAvatar string `json:"userAvatar,omitempty"` - AssistantAvatar string `json:"assistantAvatar,omitempty"` -} - -type EnvironmentResponse struct { - Platform string `json:"platform"` - Arch string `json:"arch"` - Hostname string `json:"hostname"` - Version string `json:"version"` + UserAvatarURL string `json:"userAvatarUrl"` + AgentAvatarURL string `json:"agentAvatarUrl"` } // ── Extension UI ────────────────────────────────────────────────────────────── diff --git a/backend/internal/rpc/client.go b/backend/internal/rpc/client.go index 0561d17..bbcda2c 100644 --- a/backend/internal/rpc/client.go +++ b/backend/internal/rpc/client.go @@ -6,12 +6,13 @@ import ( "fmt" "io" "log" + "os" "os/exec" + "strconv" "strings" "sync" "time" - "github.com/google/uuid" "sproutclaw-web/internal/models" ) @@ -20,10 +21,29 @@ const ( promptTimeout = 30 * time.Second ) +// bufferedEventTypes are the event types replayed to newly-connected SSE +// clients while the agent is mid-run (mirrors the original pi-client.ts). +var bufferedEventTypes = map[string]bool{ + "agent_start": true, + "agent_end": true, + "message_start": true, + "message_update": true, + "message_end": true, + "tool_execution_start": true, + "tool_execution_update": true, + "tool_execution_end": true, + "compaction_start": true, + "compaction_end": true, + "queue_update": true, + "auto_retry_start": true, + "auto_retry_end": true, + "bash_update": true, +} + // SSEClient represents a connected browser SSE client. type SSEClient struct { - Ch chan []byte - Done <-chan struct{} + Ch chan []byte + Done <-chan struct{} } // RunSnapshot holds a snapshot of the current pi agent run state. @@ -36,27 +56,33 @@ type RunSnapshot struct { // Client manages a pi CLI subprocess and bridges its JSON-RPC protocol // to HTTP handlers and SSE clients. type Client struct { - mu sync.Mutex - cmd *exec.Cmd - stdin io.WriteCloser - pending map[string]chan models.RPCResponse - sseClients map[*SSEClient]struct{} - replay []json.RawMessage // events in current agent run - isRunning bool + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + pending map[string]chan models.RPCResponse + sseClients map[*SSEClient]struct{} + replay []json.RawMessage // events in current agent run + isRunning bool sessionFile string + reqMu sync.Mutex + reqCounter int } // NewClient starts the pi CLI subprocess and begins reading its stdout. -// onExit is called with the exit code when the subprocess terminates. -func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) (*Client, error) { +// repoRoot is used as the subprocess working directory; onExit is called +// 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)") } - args := append(piArgs, "--rpc") + // pi enters RPC mode via "--mode rpc" + args := append(append([]string{}, piArgs...), "--mode", "rpc") cmd := exec.Command(piCmd, args...) - cmd.Dir = agentDir - cmd.Env = append(cmd.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) + + log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot) stdin, err := cmd.StdinPipe() if err != nil { @@ -66,12 +92,15 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) if err != nil { return nil, fmt.Errorf("stdout pipe: %w", err) } - cmd.Stderr = log.Writer() + cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return nil, fmt.Errorf("start pi cli: %w", err) } + // Keep stdin open; closing it on Windows can trigger RPC shutdown. + _, _ = stdin.Write([]byte("")) + c := &Client{ cmd: cmd, stdin: stdin, @@ -86,82 +115,103 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) if cmd.ProcessState != nil { code = cmd.ProcessState.ExitCode() } + log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code) onExit(code) }() return c, nil } +func (c *Client) nextReqID() string { + c.reqMu.Lock() + c.reqCounter++ + id := c.reqCounter + c.reqMu.Unlock() + return "req_" + strconv.Itoa(id) +} + // readLoop reads stdout line-by-line and dispatches messages. func (c *Client) readLoop(r io.Reader) { scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024) + scanner.Buffer(make([]byte, 4*1024*1024), 16*1024*1024) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } - c.dispatch(line) + c.dispatch(append([]byte(nil), line...)) } } // dispatch parses one JSON line from stdout and routes it. func (c *Client) dispatch(raw []byte) { - var msg struct { + var head struct { Type string `json:"type"` ID string `json:"id"` } - if err := json.Unmarshal(raw, &msg); err != nil { - return + if err := json.Unmarshal(raw, &head); err != nil { + return // ignore non-JSON lines } - c.mu.Lock() - defer c.mu.Unlock() - - switch msg.Type { - case "response": + // Response frame: resolve the pending request. + if head.Type == "response" && head.ID != "" { var resp models.RPCResponse if err := json.Unmarshal(raw, &resp); err == nil { - if ch, ok := c.pending[resp.ID]; ok { + // capture sessionFile from get_state responses + if resp.Command == "get_state" && resp.Success { + var data struct { + SessionFile string `json:"sessionFile"` + } + if json.Unmarshal(resp.Data, &data) == nil && data.SessionFile != "" { + c.mu.Lock() + c.sessionFile = data.SessionFile + c.mu.Unlock() + } + } + c.mu.Lock() + ch, ok := c.pending[resp.ID] + if ok { delete(c.pending, resp.ID) + } + c.mu.Unlock() + if ok { ch <- resp } + return } + } + // Event frame: track agent run state + broadcast to SSE clients. + c.trackAgentEvent(head.Type, raw) + c.mu.Lock() + c.broadcastLocked(raw) + c.mu.Unlock() +} + +func (c *Client) trackAgentEvent(typ string, raw []byte) { + c.mu.Lock() + defer c.mu.Unlock() + switch typ { case "agent_start": c.isRunning = true - c.replay = nil - // try to extract session path - var ev struct { - SessionFile string `json:"sessionFile"` - } - if err := json.Unmarshal(raw, &ev); err == nil && ev.SessionFile != "" { - c.sessionFile = ev.SessionFile - } - rawCopy := append([]byte(nil), raw...) - c.replay = append(c.replay, rawCopy) - c.broadcastLocked(rawCopy) - + c.replay = []json.RawMessage{raw} case "agent_end": + c.replay = append(c.replay, raw) c.isRunning = false - rawCopy := append([]byte(nil), raw...) - c.replay = append(c.replay, rawCopy) - c.broadcastLocked(rawCopy) - + c.replay = nil default: - rawCopy := append([]byte(nil), raw...) - if c.isRunning { - c.replay = append(c.replay, rawCopy) + if c.isRunning && bufferedEventTypes[typ] { + c.replay = append(c.replay, raw) } - c.broadcastLocked(rawCopy) } } // broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu. func (c *Client) broadcastLocked(raw []byte) { + frame := formatSSEData(raw) for client := range c.sseClients { select { - case client.Ch <- raw: + case client.Ch <- frame: default: // slow client: drop } @@ -176,15 +226,13 @@ func (c *Client) send(cmd models.RPCCommand) error { } c.mu.Lock() defer c.mu.Unlock() - _, err = fmt.Fprintf(c.stdin, "%s\n", b) + _, err = c.stdin.Write(append(b, '\n')) return err } // SendCmd sends a command and waits for the matching response. func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) { - if cmd.ID == "" { - cmd.ID = uuid.New().String() - } + cmd.ID = c.nextReqID() ch := make(chan models.RPCResponse, 1) c.mu.Lock() @@ -205,20 +253,50 @@ func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) { c.mu.Lock() delete(c.pending, cmd.ID) c.mu.Unlock() - return models.RPCResponse{}, fmt.Errorf("rpc timeout: %s", cmd.Type) + return models.RPCResponse{}, fmt.Errorf("命令超时: %s", cmd.Type) } } -// SubmitPrompt sends a prompt without waiting for a response. +// SubmitPrompt sends a prompt. It registers a preflight pending entry but does +// not block the HTTP handler — agent output arrives via SSE. func (c *Client) SubmitPrompt(req models.ChatRequest) error { + id := c.nextReqID() cmd := models.RPCCommand{ Type: "prompt", - ID: uuid.New().String(), - Text: req.Message, + ID: id, + Message: req.Message, Images: req.Images, StreamingBehavior: req.StreamingBehavior, } - return c.send(cmd) + + ch := make(chan models.RPCResponse, 1) + c.mu.Lock() + c.pending[id] = ch + c.mu.Unlock() + + if err := c.send(cmd); err != nil { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + return err + } + + // Preflight: wait briefly for a rejection; success streams via SSE. + go func() { + select { + case resp := <-ch: + if !resp.Success { + c.mu.Lock() + c.broadcastLocked([]byte(fmt.Sprintf(`{"type":"prompt_rejected","error":%q}`, resp.Error))) + c.mu.Unlock() + } + case <-time.After(promptTimeout): + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + } + }() + return nil } // GetSnapshot returns the current run snapshot. @@ -234,25 +312,26 @@ func (c *Client) GetSnapshot() RunSnapshot { } } -// RegisterSSEClient adds a client to the broadcast set and replays buffered events. +// RegisterSSEClient adds a client and sends the initial "connected" frame +// carrying the current run snapshot (matches the original pi-client.ts). func (c *Client) RegisterSSEClient(client *SSEClient) { c.mu.Lock() c.sseClients[client] = struct{}{} - // replay current run events replay := make([]json.RawMessage, len(c.replay)) copy(replay, c.replay) + isStreaming := c.isRunning c.mu.Unlock() - // send replay in a goroutine so we don't hold the lock - go func() { - for _, ev := range replay { - select { - case client.Ch <- ev: - case <-client.Done: - return - } - } - }() + connected := map[string]any{ + "type": "connected", + "isStreaming": isStreaming, + "replay": replay, + } + b, _ := json.Marshal(connected) + select { + case client.Ch <- formatSSEData(b): + case <-client.Done: + } } // UnregisterSSEClient removes a client from the broadcast set. @@ -263,12 +342,23 @@ func (c *Client) UnregisterSSEClient(client *SSEClient) { } // SendExtensionUIResponse forwards a UI response back to the pi CLI. +// The response object is merged into the top-level frame: {type, id, ...response}. func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error { - return c.send(models.RPCCommand{ - Type: "extension_ui_response", - ID: id, - Response: response, - }) + merged := map[string]any{"type": "extension_ui_response", "id": id} + var extra map[string]any + if json.Unmarshal(response, &extra) == nil { + for k, v := range extra { + merged[k] = v + } + } + b, err := json.Marshal(merged) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + _, err = c.stdin.Write(append(b, '\n')) + return err } // Stop terminates the pi CLI subprocess. @@ -278,17 +368,6 @@ func (c *Client) Stop() { } } -// buildEnv returns os.Environ() with extra variables appended. -func (c *Client) Environ() []string { - return c.cmd.Environ() -} - -// helper -func mustMarshal(v any) []byte { - b, _ := json.Marshal(v) - return b -} - // IsRunning returns true when the agent is actively streaming. func (c *Client) IsRunning() bool { c.mu.Lock() @@ -296,9 +375,8 @@ func (c *Client) IsRunning() bool { return c.isRunning } -// FormatSSEEvent formats a raw JSON payload as an SSE data line. -func FormatSSEEvent(raw []byte) []byte { - // strip newlines so the SSE frame stays on one logical line - trimmed := strings.TrimRight(string(raw), "\n\r") +// formatSSEData wraps a raw JSON payload as an SSE "data:" frame. +func formatSSEData(raw []byte) []byte { + trimmed := strings.TrimRight(string(raw), "\r\n") return []byte("data: " + trimmed + "\n\n") } diff --git a/backend/internal/services/extensions.go b/backend/internal/services/extensions.go index 6eb8526..6051d63 100644 --- a/backend/internal/services/extensions.go +++ b/backend/internal/services/extensions.go @@ -2,27 +2,43 @@ package services import ( "encoding/json" + "fmt" "os" "path/filepath" + "regexp" "strings" "sproutclaw-web/internal/models" ) -// ListExtensions reads the extensions directory and returns metadata. +var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`) + +// ListExtensions returns local extensions (enabled and disabled) plus npm packages +// declared in settings.json. Local ones live in extensions/ and extensions-disabled/. func ListExtensions(agentDir string) []models.ExtensionInfo { - extDir := filepath.Join(agentDir, "extensions") - entries, err := os.ReadDir(extDir) + var exts []models.ExtensionInfo + exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions"), true)...) + exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions-disabled"), false)...) + exts = append(exts, listNpmExtensions(agentDir)...) + return exts +} + +func scanExtensionsDir(dir string, enabled bool) []models.ExtensionInfo { + entries, err := os.ReadDir(dir) if err != nil { return nil } - var exts []models.ExtensionInfo for _, e := range entries { if !e.IsDir() { continue } - ext := buildExtensionInfo(filepath.Join(extDir, e.Name()), e.Name()) + ext := buildExtensionInfo(filepath.Join(dir, e.Name()), e.Name()) + // Anything physically under extensions/ or extensions-disabled/ is a + // local extension and can be toggled, regardless of its package.json name. + ext.Category = "local" + ext.Toggleable = true + ext.Enabled = enabled exts = append(exts, ext) } return exts @@ -30,13 +46,11 @@ func ListExtensions(agentDir string) []models.ExtensionInfo { func buildExtensionInfo(path, id string) models.ExtensionInfo { info := models.ExtensionInfo{ - ID: id, - Name: id, - Enabled: true, // assume enabled unless configured otherwise - Source: "local", + Name: id, + Path: path, } - // try package.json for version/name + // try package.json for name/version pkgPath := filepath.Join(path, "package.json") if data, err := os.ReadFile(pkgPath); err == nil { var pkg struct { @@ -48,58 +62,149 @@ func buildExtensionInfo(path, id string) models.ExtensionInfo { info.Name = pkg.Name } info.Version = pkg.Version - // detect npm packages - if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") { - info.Source = "npm" - } } } return info } -// ToggleExtension is a placeholder – full implementation requires settings.json management. -func ToggleExtension(agentDir, id string, enable bool) error { - return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable) +func listNpmExtensions(agentDir string) []models.ExtensionInfo { + sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json")) + if len(sources) == 0 { + return nil + } + + seen := make(map[string]bool, len(sources)) + var exts []models.ExtensionInfo + for _, source := range sources { + name, ok := npmPackageName(source) + if !ok || seen[name] { + continue + } + seen[name] = true + exts = append(exts, buildNpmExtensionInfo(agentDir, name, source)) + } + return exts } -func updateSettingsExtensions(settingsPath, id string, enable bool) error { +func readNpmPackageSources(settingsPath string) []string { data, err := os.ReadFile(settingsPath) - if err != nil && !os.IsNotExist(err) { - return err - } - - var settings map[string]any - if len(data) > 0 { - if err := json.Unmarshal(data, &settings); err != nil { - settings = map[string]any{} - } - } else { - settings = map[string]any{} - } - - // extensions is a list of enabled extension IDs - extList, _ := settings["extensions"].([]any) - set := map[string]bool{} - for _, e := range extList { - if s, ok := e.(string); ok { - set[s] = true - } - } - if enable { - set[id] = true - } else { - delete(set, id) - } - - newList := make([]string, 0, len(set)) - for k := range set { - newList = append(newList, k) - } - settings["extensions"] = newList - - out, err := json.MarshalIndent(settings, "", " ") if err != nil { + return nil + } + + var settings struct { + Packages []json.RawMessage `json:"packages"` + Extensions []string `json:"extensions"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return nil + } + + var sources []string + for _, raw := range settings.Packages { + if source := parsePackageSource(raw); source != "" { + sources = append(sources, source) + } + } + for _, source := range settings.Extensions { + if strings.HasPrefix(source, "npm:") { + sources = append(sources, source) + } + } + return sources +} + +func parsePackageSource(raw json.RawMessage) string { + var source string + if err := json.Unmarshal(raw, &source); err == nil { + return strings.TrimSpace(source) + } + + var obj struct { + Source string `json:"source"` + } + if err := json.Unmarshal(raw, &obj); err == nil { + return strings.TrimSpace(obj.Source) + } + return "" +} + +func npmPackageName(source string) (string, bool) { + if !strings.HasPrefix(source, "npm:") { + return "", false + } + spec := strings.TrimSpace(strings.TrimPrefix(source, "npm:")) + if spec == "" { + return "", false + } + name, _ := parseNpmSpec(spec) + if name == "" { + return "", false + } + return name, true +} + +func parseNpmSpec(spec string) (name, version string) { + match := npmSpecRe.FindStringSubmatch(spec) + if match == nil { + return spec, "" + } + name = match[1] + version = match[2] + return name, version +} + +func buildNpmExtensionInfo(agentDir, packageName, source string) models.ExtensionInfo { + modPath := filepath.Join(agentDir, "npm", "node_modules", packageName) + info := models.ExtensionInfo{ + Name: packageName, + Path: modPath, + Source: source, + Category: "npm", + Enabled: true, + Toggleable: false, + } + + pkgPath := filepath.Join(modPath, "package.json") + if data, err := os.ReadFile(pkgPath); err == nil { + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + if json.Unmarshal(data, &pkg) == nil { + if pkg.Name != "" { + info.Name = pkg.Name + } + info.Version = pkg.Version + } + } + return info +} + +// ToggleExtension moves a local extension between extensions/ and +// extensions-disabled/ directories. The agent's loader only scans extensions/, +// so moving a directory out of it disables that extension. npm extensions live +// elsewhere and are not managed here. +func ToggleExtension(agentDir, extPath string, enable bool) error { + extDir := filepath.Join(agentDir, "extensions") + disabledDir := filepath.Join(agentDir, "extensions-disabled") + + if !isUnderDir(extPath, extDir) && !isUnderDir(extPath, disabledDir) { + return fmt.Errorf("extension path not in managed directories") + } + + name := filepath.Base(extPath) + var src, dst string + if enable { + src = filepath.Join(disabledDir, name) + dst = filepath.Join(extDir, name) + } else { + src = filepath.Join(extDir, name) + dst = filepath.Join(disabledDir, name) + } + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - return os.WriteFile(settingsPath, append(out, '\n'), 0o644) + return os.Rename(src, dst) } diff --git a/backend/internal/services/extensions_test.go b/backend/internal/services/extensions_test.go new file mode 100644 index 0000000..0ee6dc8 --- /dev/null +++ b/backend/internal/services/extensions_test.go @@ -0,0 +1,56 @@ +package services + +import ( + "os" + "path/filepath" + "testing" +) + +func TestListNpmExtensions(t *testing.T) { + agentDir := t.TempDir() + npmRoot := filepath.Join(agentDir, "npm", "node_modules", "pi-subagents") + if err := os.MkdirAll(npmRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(npmRoot, "package.json"), []byte(`{"name":"pi-subagents","version":"0.28.0"}`), 0o644); err != nil { + t.Fatal(err) + } + settings := `{ + "packages": ["npm:pi-subagents", {"source": "npm:pi-mcp-adapter@2.10.0"}] + }` + if err := os.WriteFile(filepath.Join(agentDir, "settings.json"), []byte(settings), 0o644); err != nil { + t.Fatal(err) + } + + exts := listNpmExtensions(agentDir) + if len(exts) != 2 { + t.Fatalf("expected 2 npm extensions, got %d", len(exts)) + } + + found := map[string]modelsExtensionSnapshot{} + for _, ext := range exts { + found[ext.Name] = modelsExtensionSnapshot{ + Version: ext.Version, + Category: ext.Category, + Toggleable: ext.Toggleable, + Source: ext.Source, + } + } + + if found["pi-subagents"].Version != "0.28.0" { + t.Fatalf("pi-subagents version: %#v", found["pi-subagents"]) + } + if found["pi-subagents"].Category != "npm" || found["pi-subagents"].Toggleable { + t.Fatalf("pi-subagents metadata: %#v", found["pi-subagents"]) + } + if found["pi-mcp-adapter"].Source != "npm:pi-mcp-adapter@2.10.0" { + t.Fatalf("pi-mcp-adapter source: %#v", found["pi-mcp-adapter"]) + } +} + +type modelsExtensionSnapshot struct { + Version string + Category string + Toggleable bool + Source string +} diff --git a/backend/internal/services/mcp.go b/backend/internal/services/mcp.go index c788e56..106deed 100644 --- a/backend/internal/services/mcp.go +++ b/backend/internal/services/mcp.go @@ -7,68 +7,80 @@ import ( "sproutclaw-web/internal/models" ) -type mcpConfig struct { - MCPServers map[string]any `json:"mcpServers"` - MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"` - ExcludeTools []string `json:"excludeTools,omitempty"` -} +// mcp.json is shared with pi-mcp-adapter, which only reads the top-level +// "mcpServers", "imports" and "settings" keys. To disable a server we move its +// full definition into the adapter-ignored "mcpServersDisabled" object, so the +// adapter never starts it; enabling moves it back. All operations work on the +// raw JSON map so unrecognized keys (settings, imports, ...) are preserved. // ReadMCPServers loads mcp.json and returns the list of servers with their tools. +// Servers under "mcpServers" are reported enabled; those parked under +// "mcpServersDisabled" are reported disabled. func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer { - cfg := loadMCPConfig(mcpConfigPath) + raw := loadRawMCP(mcpConfigPath) cache := loadMCPCache(mcpCachePath) - disabledSet := map[string]bool{} - for _, s := range cfg.MCPServersDisabled { - disabledSet[s] = true - } excludeSet := map[string]bool{} - for _, t := range cfg.ExcludeTools { + for _, t := range toStringSlice(raw["excludeTools"]) { excludeSet[t] = true } var servers []models.MCPServer - for name := range cfg.MCPServers { - srv := models.MCPServer{ - Name: name, - Disabled: disabledSet[name], - } - // add tools from cache - if toolNames, ok := cache[name]; ok { - for _, t := range toolNames { - srv.Tools = append(srv.Tools, models.MCPTool{ - Name: t, - Disabled: excludeSet[name+"/"+t] || excludeSet[t], - }) + appendServer := func(name string, enabled bool) { + srv := models.MCPServer{Name: name, Configured: true, Enabled: enabled} + for _, t := range cache[name] { + toolEnabled := !(excludeSet[name+"/"+t] || excludeSet[t]) + srv.Tools = append(srv.Tools, models.MCPTool{Server: name, Name: t, Enabled: toolEnabled}) + srv.ToolCount++ + if toolEnabled { + srv.EnabledToolCount++ } } servers = append(servers, srv) } + + for name := range asMap(raw["mcpServers"]) { + appendServer(name, true) + } + for name := range asMap(raw["mcpServersDisabled"]) { + appendServer(name, false) + } return servers } -// ToggleMCPServer enables or disables a server. +// ToggleMCPServer enables/disables a server by moving its full definition between +// "mcpServers" (adapter reads it -> server can start) and "mcpServersDisabled" +// (adapter ignores it -> server never starts). func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error { - cfg := loadMCPConfig(mcpConfigPath) - set := map[string]bool{} - for _, s := range cfg.MCPServersDisabled { - set[s] = true - } + raw := loadRawMCP(mcpConfigPath) + servers := asMap(raw["mcpServers"]) + disabled := asMap(raw["mcpServersDisabled"]) + if enable { - delete(set, serverName) + if def, ok := disabled[serverName]; ok { + servers[serverName] = def + delete(disabled, serverName) + } } else { - set[serverName] = true + if def, ok := servers[serverName]; ok { + disabled[serverName] = def + delete(servers, serverName) + } } - cfg.MCPServersDisabled = keys(set) - return saveMCPConfig(mcpConfigPath, cfg) + + raw["mcpServers"] = servers + setOrDeleteMap(raw, "mcpServersDisabled", disabled) + return saveRawMCP(mcpConfigPath, raw) } -// ToggleMCPTool enables or disables a specific tool on a server. +// ToggleMCPTool enables or disables a specific tool via the top-level +// "excludeTools" list (server/tool keys). func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error { - cfg := loadMCPConfig(mcpConfigPath) + raw := loadRawMCP(mcpConfigPath) key := serverName + "/" + toolName + set := map[string]bool{} - for _, t := range cfg.ExcludeTools { + for _, t := range toStringSlice(raw["excludeTools"]) { set[t] = true } if enable { @@ -76,31 +88,68 @@ func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) erro } else { set[key] = true } - cfg.ExcludeTools = keys(set) - return saveMCPConfig(mcpConfigPath, cfg) + + list := make([]any, 0, len(set)) + for k := range set { + list = append(list, k) + } + if len(list) > 0 { + raw["excludeTools"] = list + } else { + delete(raw, "excludeTools") + } + return saveRawMCP(mcpConfigPath, raw) } -func loadMCPConfig(path string) mcpConfig { +func loadRawMCP(path string) map[string]any { data, err := os.ReadFile(path) if err != nil { - return mcpConfig{MCPServers: map[string]any{}} + return map[string]any{} } - var cfg mcpConfig - _ = json.Unmarshal(data, &cfg) - if cfg.MCPServers == nil { - cfg.MCPServers = map[string]any{} + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil || raw == nil { + return map[string]any{} } - return cfg + return raw } -func saveMCPConfig(path string, cfg mcpConfig) error { - data, err := json.MarshalIndent(cfg, "", " ") +func saveRawMCP(path string, raw map[string]any) error { + data, err := json.MarshalIndent(raw, "", " ") if err != nil { return err } return os.WriteFile(path, append(data, '\n'), 0o644) } +func asMap(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return map[string]any{} +} + +func setOrDeleteMap(raw map[string]any, key string, m map[string]any) { + if len(m) > 0 { + raw[key] = m + } else { + delete(raw, key) + } +} + +func toStringSlice(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, e := range arr { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out +} + func loadMCPCache(path string) map[string][]string { data, err := os.ReadFile(path) if err != nil { @@ -123,11 +172,3 @@ func loadMCPCache(path string) map[string][]string { } return result } - -func keys(m map[string]bool) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - return out -} diff --git a/backend/internal/services/sessions.go b/backend/internal/services/sessions.go index aa73ef7..9af4aee 100644 --- a/backend/internal/services/sessions.go +++ b/backend/internal/services/sessions.go @@ -1,10 +1,10 @@ package services import ( - "bufio" "encoding/json" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -13,159 +13,308 @@ import ( "sproutclaw-web/internal/models" ) -// jsonlLine is a minimal parse of one line in a session JSONL file. -type jsonlLine struct { - Type string `json:"type"` - // session header - ID string `json:"id"` - Created string `json:"created"` - // message - Role string `json:"role"` - Content any `json:"content"` - // session_info - Name string `json:"name"` +// sessionLine is one parsed line of a session JSONL file. +type sessionLine struct { + Type string `json:"type"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Name string `json:"name"` + Message json.RawMessage `json:"message"` } -// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries. -func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) { - entries, err := os.ReadDir(sessionsDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } +type sessionMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} - pinned, _ := database.GetPinnedSessions() - pinnedSet := map[string]struct{}{} - for _, p := range pinned { - pinnedSet[p] = struct{}{} - } +var ( + reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`) + reDigits = regexp.MustCompile(`^[0-9]{10,}$`) + reWS = regexp.MustCompile(`\s+`) + reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`) +) - var summaries []models.SessionSummary - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { - continue - } - path := filepath.Join(sessionsDir, e.Name()) - sum, err := readSessionSummary(path) +// listSessionFiles recursively collects every *.jsonl under sessionsDir. +// pi stores sessions in cwd-encoded subdirectories, e.g. +// sessions/--D--SmyProjects-AI-sproutclaw--/.jsonl +func listSessionFiles(sessionsDir string) []string { + var files []string + _ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error { if err != nil { - continue + return nil } - _, sum.Pinned = pinnedSet[path] - sum.Path = path - summaries = append(summaries, sum) - } - - // Sort: pinned first, then by modified desc - sort.SliceStable(summaries, func(i, j int) bool { - pi, pj := summaries[i].Pinned, summaries[j].Pinned - if pi != pj { - return pi + if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") { + files = append(files, path) } - return summaries[i].Modified > summaries[j].Modified + return nil }) - return summaries, nil + // newest first by path (ids are time-sortable); final sort happens later + sort.Sort(sort.Reverse(sort.StringSlice(files))) + return files } -func readSessionSummary(path string) (models.SessionSummary, error) { - f, err := os.Open(path) - if err != nil { - return models.SessionSummary{}, err +func isMachineSessionLabel(text, headerID string) bool { + t := strings.TrimSpace(text) + if t == "" { + return true } - defer f.Close() - - info, _ := f.Stat() - modified := "" - if info != nil { - modified = info.ModTime().UTC().Format(time.RFC3339) + if headerID != "" && t == headerID { + return true } - - var sum models.SessionSummary - sum.Modified = modified - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024) - msgCount := 0 - for scanner.Scan() { - var line jsonlLine - if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { - continue - } - switch line.Type { - case "session": - sum.Created = line.Created - case "session_info": - if line.Name != "" { - sum.Name = line.Name - } - case "message": - msgCount++ - if sum.FirstMessage == "" && line.Role == "user" { - sum.FirstMessage = extractTextContent(line.Content) - } - } + if reHex.MatchString(t) || reDigits.MatchString(t) { + return true } - sum.MessageCount = msgCount - return sum, nil + return false } -func extractTextContent(content any) string { - switch v := content.(type) { - case string: - return truncate(v, 120) - case []any: - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if m["type"] == "text" { - if t, ok := m["text"].(string); ok { - return truncate(t, 120) - } +func titleFromFirstUserMessage(text string) string { + const maxChars = 56 + cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " ")) + if cleaned == "" { + return "" + } + candidate := cleaned + if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" { + candidate = strings.TrimSpace(m[1]) + } + runes := []rune(candidate) + if len(runes) > maxChars { + candidate = strings.TrimRight(string(runes[:maxChars]), " ") + "…" + } + return candidate +} + +func extractPreview(msg sessionMessage) string { + if len(msg.Content) == 0 { + return "" + } + // content may be a string + var s string + if json.Unmarshal(msg.Content, &s) == nil { + return truncate(s, 200) + } + // or an array of blocks + var blocks []map[string]any + if json.Unmarshal(msg.Content, &blocks) == nil { + var sb strings.Builder + imageCount := 0 + for _, b := range blocks { + switch b["type"] { + case "text": + if t, ok := b["text"].(string); ok { + sb.WriteString(t) } + case "image": + imageCount++ } } + if txt := sb.String(); txt != "" { + return truncate(txt, 200) + } + if imageCount > 0 { + return "[" + itoa(imageCount) + " 张图片]" + } } return "" } -func truncate(s string, n int) string { - if len([]rune(s)) <= n { - return s +// readSessionSummary parses one session file into a summary (nil if not a session). +func readSessionSummary(path string) (models.SessionSummary, bool) { + data, err := os.ReadFile(path) + if err != nil { + return models.SessionSummary{}, false } - runes := []rune(s) - return string(runes[:n]) + "…" + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + return models.SessionSummary{}, false + } + + var header sessionLine + if json.Unmarshal([]byte(lines[0]), &header) != nil || header.Type != "session" { + return models.SessionSummary{}, false + } + + info, _ := os.Stat(path) + modified := "" + if info != nil { + modified = info.ModTime().UTC().Format(time.RFC3339) + } + + nameFromInfo := "" + messageCount := 0 + firstMessage := "" + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + var entry sessionLine + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + switch entry.Type { + case "session_info": + if entry.Name != "" { + n := strings.TrimSpace(entry.Name) + if n != "" && !isMachineSessionLabel(n, header.ID) { + nameFromInfo = n + } + } + case "message": + messageCount++ + if firstMessage == "" && len(entry.Message) > 0 { + var m sessionMessage + if json.Unmarshal(entry.Message, &m) == nil && m.Role == "user" { + firstMessage = extractPreview(m) + } + } + } + } + + name := nameFromInfo + if name == "" || isMachineSessionLabel(name, header.ID) { + name = titleFromFirstUserMessage(firstMessage) + } + + preview := firstMessage + if preview == "" { + preview = "(空)" + } + + return models.SessionSummary{ + Path: path, + Name: name, + Created: header.Timestamp, + Modified: modified, + MessageCount: messageCount, + FirstMessage: preview, + }, true } -// ReadSessionMessages reads all lines from a JSONL session file. +// BuildSessionList recursively reads all sessions and assembles the response. +func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) { + if _, err := os.Stat(sessionsDir); err != nil { + return nil, nil + } + + pinned, _ := database.GetPinnedSessions() + pinnedOrder := map[string]int{} + for i, p := range pinned { + pinnedOrder[filepath.Clean(p)] = i + } + + var summaries []models.SessionSummary + for _, f := range listSessionFiles(sessionsDir) { + sum, ok := readSessionSummary(f) + if !ok { + continue + } + _, sum.Pinned = pinnedOrder[filepath.Clean(f)] + summaries = append(summaries, sum) + } + + sort.SliceStable(summaries, func(i, j int) bool { + ci, iPinned := pinnedOrder[filepath.Clean(summaries[i].Path)] + cj, jPinned := pinnedOrder[filepath.Clean(summaries[j].Path)] + if iPinned && jPinned { + return ci < cj + } + if iPinned != jPinned { + return iPinned + } + return sessionSortKey(summaries[i]) > sessionSortKey(summaries[j]) + }) + return summaries, nil +} + +func sessionSortKey(s models.SessionSummary) string { + if s.Modified != "" { + return s.Modified + } + return s.Created +} + +// ReadSessionSummaryByPath returns a single session's summary (exported). +func ReadSessionSummaryByPath(path string) (models.SessionSummary, error) { + sum, ok := readSessionSummary(path) + if !ok { + return models.SessionSummary{Path: path}, nil + } + return sum, nil +} + +// ReadSessionMessages returns the `message` payload of each message line. func ReadSessionMessages(path string) ([]json.RawMessage, error) { - f, err := os.Open(path) + data, err := os.ReadFile(path) if err != nil { return nil, err } - defer f.Close() - - var lines []json.RawMessage - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024) - for scanner.Scan() { - raw := append([]byte(nil), scanner.Bytes()...) - lines = append(lines, raw) + var out []json.RawMessage + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var entry sessionLine + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + if entry.Type == "message" && len(entry.Message) > 0 { + out = append(out, entry.Message) + } } - return lines, nil + return out, nil } // AppendSessionName appends a session_info rename record to the JSONL file. func AppendSessionName(path, name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return os.ErrInvalid + } f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() - record := map[string]string{"type": "session_info", "name": name} + record := map[string]any{ + "type": "session_info", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "name": trimmed, + } b, err := json.Marshal(record) if err != nil { return err } - _, err = f.Write(append(b, '\n')) + _, err = f.Write(append([]byte("\n"), b...)) return err } + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/backend/internal/services/skills.go b/backend/internal/services/skills.go index 4dd11f0..4363f40 100644 --- a/backend/internal/services/skills.go +++ b/backend/internal/services/skills.go @@ -36,6 +36,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo { Name: name, Description: desc, Enabled: enabled, + Toggleable: true, Path: skillPath, }) } diff --git a/backend/internal/services/web_terminal.go b/backend/internal/services/web_terminal.go new file mode 100644 index 0000000..632c0ee --- /dev/null +++ b/backend/internal/services/web_terminal.go @@ -0,0 +1,140 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "sync" + + "github.com/Kodecable/crosspty" + "github.com/gorilla/websocket" +) + +type terminalWSMessage struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` +} + +// ServeWebTerminal attaches a PTY shell session to the websocket connection. +func ServeWebTerminal(conn *websocket.Conn, workDir string) error { + abs, err := filepath.Abs(workDir) + if err != nil { + return fmt.Errorf("解析目录失败: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("目录不存在: %s", abs) + } + if !info.IsDir() { + return fmt.Errorf("不是有效目录: %s", abs) + } + + 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}, + }) + if err != nil { + if err == crosspty.ErrConPTYNotSupported { + return fmt.Errorf("当前 Windows 版本不支持 ConPTY,需 Windows 10 1809 或更高版本") + } + return fmt.Errorf("启动终端失败: %w", err) + } + defer ptmx.Close() + + var writeMu sync.Mutex + writeJSON := func(v any) error { + writeMu.Lock() + defer writeMu.Unlock() + return conn.WriteJSON(v) + } + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + writeMu.Lock() + werr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]) + writeMu.Unlock() + if werr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + for { + msgType, payload, readErr := conn.ReadMessage() + if readErr != nil { + if websocket.IsCloseError(readErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + return nil + } + if readErr == io.EOF { + return nil + } + return readErr + } + + switch msgType { + case websocket.BinaryMessage: + if _, werr := ptmx.Write(payload); werr != nil { + return werr + } + case websocket.TextMessage: + var msg terminalWSMessage + if err := json.Unmarshal(payload, &msg); err != nil { + if _, werr := ptmx.Write(payload); werr != nil { + return werr + } + continue + } + switch msg.Type { + case "input": + if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil { + return werr + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = ptmx.Resize(crosspty.TermSize{Rows: msg.Rows, Cols: msg.Cols}) + } + case "ping": + if err := writeJSON(map[string]string{"type": "pong"}); err != nil { + return err + } + default: + if msg.Data != "" { + if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil { + return werr + } + } + } + } + } +} + +func defaultShellArgv() []string { + if runtime.GOOS == "windows" { + if comspec := os.Getenv("COMSPEC"); comspec != "" { + return []string{comspec} + } + return []string{"cmd.exe"} + } + if shell := os.Getenv("SHELL"); shell != "" { + return []string{shell, "-l"} + } + return []string{"/bin/bash", "-l"} +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6b2b984 --- /dev/null +++ b/config.yaml @@ -0,0 +1,659 @@ +mixed-port: 17890 +socks-port: 17891 +port: 17892 +allow-lan: true +bind-address: '0.0.0.0' +mode: rule +log-level: info +external-controller: '0.0.0.0:9191' +secret: "shumengya5201314" +external-ui: webui + +tcp-concurrent: true +find-process-mode: strict + +profile: + store-selected: true + store-fake-ip: true + +sniffer: + enable: true + sniff: + HTTP: + ports: [80, 8080-8880] + override-destination: true + TLS: + ports: [443, 8443] + QUIC: + ports: [443, 8443] + force-domain: + - "+.v2raya.org" + skip-domain: + - "+.baidu.com" + - "+.qq.com" + - "+.bilibili.com" + +tun: + enable: true + stack: mixed + dns-hijack: + - "any:53" + - "tcp://any:53" + auto-route: true + auto-detect-interface: true + strict-route: true + mtu: 9000 + gso: true + endpoint-independent-nat: true + +proxy-providers: + 魔戒机场: + url: "https://msub.xn--m7r52rosihxm.com/api/v1/client/subscribe?token=91b1ce0e60323fee6d3eb74f7cf3ae00" + type: http + interval: 86400 + health-check: + enable: true + url: "https://www.gstatic.com/generate_204" + interval: 300 + override: + additional-prefix: "mojie-" + + 良心机场: + url: "https://liangxin.xyz/api/v1/liangxin?OwO=ca922059df7a4b1c9f7afd3fcb3cadc6" + type: http + interval: 86400 + health-check: + enable: true + url: "https://www.gstatic.com/generate_204" + interval: 300 + override: + additional-prefix: "liangxin-" + + 宝可梦机场: + type: file + path: ./provider_baokemeng.yaml + health-check: + enable: true + url: "https://www.gstatic.com/generate_204" + interval: 300 + override: + additional-prefix: "baokemeng-" + + edgetunnel: + url: "https://cf-proxy.zavo.cc.cd/sub?token=8fb3b0004dded27483d20fa3e48575b7" + type: http + interval: 86400 + health-check: + enable: true + url: "https://www.gstatic.com/generate_204" + interval: 300 + override: + additional-prefix: "edgetunnel-" + +proxies: + - name: "直连" + type: direct + udp: true + +geodata-mode: true +geox-url: + geoip: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat" + geosite: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" + mmdb: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb" + asn: "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb" + +dns: + enable: true + ipv6: false + respect-rules: true + enhanced-mode: fake-ip + fake-ip-range: 198.18.0.1/16 + fake-ip-filter: + - "+.lan" + - "+.local" + - "+.market.xiaomi.com" + - "*.msftconnecttest.com" + - "*.msftncsi.com" + default-nameserver: + - 223.5.5.5 + - 223.6.6.6 + nameserver: + - https://223.6.6.6/dns-query + - https://223.5.5.5/dns-query + proxy-server-nameserver: + - https://223.6.6.6/dns-query + - https://223.5.5.5/dns-query + - https://dns.cloudflare.com/dns-query + - https://dns.google/dns-query + fallback: + - https://dns.cloudflare.com/dns-query + - https://dns.google/dns-query + fallback-filter: + geoip: true + geoip-code: CN + nameserver-policy: + "geosite:cn,private": + - https://223.6.6.6/dns-query + - https://223.5.5.5/dns-query + "geosite:geolocation-!cn": + - "https://dns.cloudflare.com/dns-query" + - "https://dns.google/dns-query" + +proxy-groups: + + - name: 默认 + type: select + proxies: [自动选择,香港,日本,新加坡,美国,谷歌,Github,AI,直连,全部节点] + + - name: 全部节点 + type: select + include-all: true + + - name: 自动选择 + type: url-test + use: [魔戒机场, 良心机场, 宝可梦机场, edgetunnel] + url: "https://www.gstatic.com/generate_204" + interval: 300 + tolerance: 10 + + - name: 香港 + type: url-test + use: [魔戒机场, 良心机场, 宝可梦机场, edgetunnel] + filter: "(?i)(香港|hk|hong\\s?kong|🇭🇰)" + url: "https://www.gstatic.com/generate_204" + interval: 300 + + - name: 日本 + type: url-test + use: [魔戒机场, 良心机场, 宝可梦机场, edgetunnel] + filter: "(?i)(日本|jp|japan|东京|大阪|🇯🇵)" + url: "https://www.gstatic.com/generate_204" + interval: 300 + + - name: 新加坡 + type: url-test + use: [魔戒机场, 良心机场, 宝可梦机场, edgetunnel] + filter: "(?i)(新加坡|狮城|sg|singapore|🇸🇬)" + url: "https://www.gstatic.com/generate_204" + interval: 300 + + - name: 美国 + type: url-test + use: [魔戒机场, 良心机场, 宝可梦机场, edgetunnel] + filter: "(?i)(美国|美國|us|united\\s?states|america|洛杉矶|硅谷|西雅图|纽约|🇺🇸)" + url: "https://www.gstatic.com/generate_204" + interval: 300 + + - name: Github + type: select + proxies: [香港,日本,新加坡,自动选择,直连,全部节点] + + - name: AI + type: select + proxies: [美国,日本,新加坡] + + - name: 谷歌 + type: select + proxies: [美国,新加坡,香港,自动选择,全部节点] + +rules: + + # ==================== 直连规则:国内大厂与常用服务 ==================== + + # --- 腾讯系 --- + - DOMAIN-SUFFIX,qq.com,DIRECT + - DOMAIN-SUFFIX,tencent.com,DIRECT + - DOMAIN-SUFFIX,qcloud.com,DIRECT + - DOMAIN-SUFFIX,qcloudimg.com,DIRECT + - DOMAIN-SUFFIX,qpic.cn,DIRECT + - DOMAIN-SUFFIX,gtimg.com,DIRECT + - DOMAIN-SUFFIX,idqqimg.com,DIRECT + - DOMAIN-SUFFIX,cdntips.net,DIRECT + - DOMAIN-SUFFIX,qqmail.com,DIRECT + - DOMAIN-SUFFIX,qqurl.com,DIRECT + - DOMAIN-SUFFIX,foxmail.com,DIRECT + - DOMAIN-SUFFIX,myqcloud.com,DIRECT + - DOMAIN-SUFFIX,myapp.com,DIRECT + - DOMAIN-SUFFIX,smtcdns.com,DIRECT + - DOMAIN-SUFFIX,smtcdns.net,DIRECT + - DOMAIN-SUFFIX,tenpay.com,DIRECT + - DOMAIN-SUFFIX,tencentmusic.com,DIRECT + - DOMAIN-SUFFIX,igamecj.com,DIRECT + - DOMAIN-SUFFIX,weiyun.com,DIRECT + - DOMAIN-SUFFIX,qlogo.cn,DIRECT + - DOMAIN-SUFFIX,tcdnos.com,DIRECT + - DOMAIN-SUFFIX,tfogc.com,DIRECT + - DOMAIN-SUFFIX,soso.com,DIRECT + - DOMAIN-SUFFIX,weixin.com,DIRECT + - DOMAIN-SUFFIX,wechat.com,DIRECT + - DOMAIN-SUFFIX,servicewechat.com,DIRECT + - DOMAIN-SUFFIX,weixinbridge.com,DIRECT + - DOMAIN-SUFFIX,my-imcloud.com,DIRECT + - DOMAIN-SUFFIX,tencent-cloud.com,DIRECT + - DOMAIN-SUFFIX,tencent-cloud.net,DIRECT + - DOMAIN-SUFFIX,tencentmind.com,DIRECT + + # --- 阿里系 --- + - DOMAIN-SUFFIX,taobao.com,DIRECT + - DOMAIN-SUFFIX,tmall.com,DIRECT + - DOMAIN-SUFFIX,alibaba.com,DIRECT + - DOMAIN-SUFFIX,alicdn.com,DIRECT + - DOMAIN-SUFFIX,aliyun.com,DIRECT + - DOMAIN-SUFFIX,aliyuncs.com,DIRECT + - DOMAIN-SUFFIX,alipay.com,DIRECT + - DOMAIN-SUFFIX,alipayobjects.com,DIRECT + - DOMAIN-SUFFIX,cainiao.com,DIRECT + - DOMAIN-SUFFIX,cainiao-inc.com,DIRECT + - DOMAIN-SUFFIX,dingtalk.com,DIRECT + - DOMAIN-SUFFIX,amap.com,DIRECT + - DOMAIN-SUFFIX,autonavi.com,DIRECT + - DOMAIN-SUFFIX,youku.com,DIRECT + - DOMAIN-SUFFIX,ykimg.com,DIRECT + - DOMAIN-SUFFIX,uc.cn,DIRECT + - DOMAIN-SUFFIX,ucweb.com,DIRECT + - DOMAIN-SUFFIX,goofish.com,DIRECT + - DOMAIN-SUFFIX,xianyu.mobi,DIRECT + - DOMAIN-SUFFIX,1688.com,DIRECT + - DOMAIN-SUFFIX,ele.me,DIRECT + - DOMAIN-SUFFIX,elemecdn.com,DIRECT + - DOMAIN-SUFFIX,fliggy.com,DIRECT + - DOMAIN-SUFFIX,alihealth.cn,DIRECT + - DOMAIN-SUFFIX,yuque.com,DIRECT + - DOMAIN-SUFFIX,alimama.com,DIRECT + - DOMAIN-SUFFIX,mmstat.com,DIRECT + - DOMAIN-SUFFIX,tanx.com,DIRECT + - DOMAIN-SUFFIX,umeng.com,DIRECT + + # --- 字节跳动 --- + - DOMAIN-SUFFIX,douyin.com,DIRECT + - DOMAIN-SUFFIX,douyincdn.com,DIRECT + - DOMAIN-SUFFIX,douyinpic.com,DIRECT + - DOMAIN-SUFFIX,douyinstatic.com,DIRECT + - DOMAIN-SUFFIX,douyinvod.com,DIRECT + - DOMAIN-SUFFIX,iesdouyin.com,DIRECT + - DOMAIN-SUFFIX,toutiao.com,DIRECT + - DOMAIN-SUFFIX,toutiaocdn.com,DIRECT + - DOMAIN-SUFFIX,toutiaovod.com,DIRECT + - DOMAIN-SUFFIX,toutiaoimg.com,DIRECT + - DOMAIN-SUFFIX,ixigua.com,DIRECT + - DOMAIN-SUFFIX,ixiguatek.com,DIRECT + - DOMAIN-SUFFIX,byteimg.com,DIRECT + - DOMAIN-SUFFIX,bytescm.com,DIRECT + - DOMAIN-SUFFIX,bytedance.com,DIRECT + - DOMAIN-SUFFIX,bytedance.net,DIRECT + - DOMAIN-SUFFIX,bytecdn.cn,DIRECT + - DOMAIN-SUFFIX,volces.com,DIRECT + - DOMAIN-SUFFIX,volccdn.com,DIRECT + - DOMAIN-SUFFIX,feishu.cn,DIRECT + - DOMAIN-SUFFIX,feishu.net,DIRECT + - DOMAIN-SUFFIX,larksuite.com,DIRECT + - DOMAIN-SUFFIX,marscode.cn,DIRECT + - DOMAIN-SUFFIX,coze.cn,DIRECT + - DOMAIN-SUFFIX,juejin.cn,DIRECT + - DOMAIN-SUFFIX,lanhuapp.com,DIRECT + - DOMAIN-SUFFIX,jianying.com,DIRECT + - DOMAIN-SUFFIX,capcut.com,DIRECT + + # --- 百度系 --- + - DOMAIN-SUFFIX,baidu.com,DIRECT + - DOMAIN-SUFFIX,baidu.cn,DIRECT + - DOMAIN-SUFFIX,baidupcs.com,DIRECT + - DOMAIN-SUFFIX,baidustatic.com,DIRECT + - DOMAIN-SUFFIX,bdimg.com,DIRECT + - DOMAIN-SUFFIX,bdstatic.com,DIRECT + - DOMAIN-SUFFIX,hao123.com,DIRECT + - DOMAIN-SUFFIX,iqiyi.com,DIRECT + - DOMAIN-SUFFIX,iqiyipic.com,DIRECT + - DOMAIN-SUFFIX,qiyi.com,DIRECT + - DOMAIN-SUFFIX,qiyipic.com,DIRECT + + # --- 京东 --- + - DOMAIN-SUFFIX,jd.com,DIRECT + - DOMAIN-SUFFIX,jd.cn,DIRECT + - DOMAIN-SUFFIX,jingxi.com,DIRECT + - DOMAIN-SUFFIX,jingdong.com,DIRECT + - DOMAIN-SUFFIX,jdpay.com,DIRECT + - DOMAIN-SUFFIX,360buy.com,DIRECT + - DOMAIN-SUFFIX,360buyimg.com,DIRECT + - DOMAIN-SUFFIX,jdcloud.com,DIRECT + - DOMAIN-SUFFIX,jcloudstatic.com,DIRECT + - DOMAIN-SUFFIX,jclps.com,DIRECT + - DOMAIN-SUFFIX,joybuy.com,DIRECT + + # --- 拼多多 --- + - DOMAIN-SUFFIX,pinduoduo.com,DIRECT + - DOMAIN-SUFFIX,pddpic.com,DIRECT + - DOMAIN-SUFFIX,pddugc.com,DIRECT + - DOMAIN-SUFFIX,pddcdn.com,DIRECT + - DOMAIN-SUFFIX,yangkeduo.com,DIRECT + - DOMAIN-SUFFIX,temu.com,DIRECT + + # --- 美团 --- + - DOMAIN-SUFFIX,meituan.com,DIRECT + - DOMAIN-SUFFIX,meituan.net,DIRECT + - DOMAIN-SUFFIX,dianping.com,DIRECT + - DOMAIN-SUFFIX,dpfile.com,DIRECT + + # --- 网易 --- + - DOMAIN-SUFFIX,163.com,DIRECT + - DOMAIN-SUFFIX,126.com,DIRECT + - DOMAIN-SUFFIX,yeah.net,DIRECT + - DOMAIN-SUFFIX,netease.com,DIRECT + - DOMAIN-SUFFIX,youdao.com,DIRECT + - DOMAIN-SUFFIX,ydstatic.com,DIRECT + - DOMAIN-SUFFIX,kaola.com,DIRECT + - DOMAIN-SUFFIX,yanxuan.com,DIRECT + - DOMAIN-SUFFIX,126.net,DIRECT + - DOMAIN-SUFFIX,lofter.com,DIRECT + + # --- 哔哩哔哩 --- + - DOMAIN-SUFFIX,bilibili.com,DIRECT + - DOMAIN-SUFFIX,bilibili.cn,DIRECT + - DOMAIN-SUFFIX,biliapi.com,DIRECT + - DOMAIN-SUFFIX,biliapi.net,DIRECT + - DOMAIN-SUFFIX,bilivideo.com,DIRECT + - DOMAIN-SUFFIX,bilivideo.cn,DIRECT + - DOMAIN-SUFFIX,hdslb.com,DIRECT + - DOMAIN-SUFFIX,acg.tv,DIRECT + - DOMAIN-SUFFIX,biligame.com,DIRECT + - DOMAIN-SUFFIX,bilicomic.com,DIRECT + - DOMAIN-SUFFIX,bilibili.tv,DIRECT + - DOMAIN-SUFFIX,biliintl.com,DIRECT + + # --- 小红书 --- + - DOMAIN-SUFFIX,xiaohongshu.com,DIRECT + - DOMAIN-SUFFIX,xiaohongshu.net,DIRECT + - DOMAIN-SUFFIX,xhscdn.com,DIRECT + - DOMAIN-SUFFIX,xhscdn.net,DIRECT + + # --- 知乎 --- + - DOMAIN-SUFFIX,zhihu.com,DIRECT + - DOMAIN-SUFFIX,zhihu.dev,DIRECT + - DOMAIN-SUFFIX,zhimg.com,DIRECT + + # --- 微博 --- + - DOMAIN-SUFFIX,weibo.com,DIRECT + - DOMAIN-SUFFIX,weibo.cn,DIRECT + - DOMAIN-SUFFIX,weibocdn.com,DIRECT + - DOMAIN-SUFFIX,sina.com,DIRECT + - DOMAIN-SUFFIX,sina.com.cn,DIRECT + - DOMAIN-SUFFIX,sina.cn,DIRECT + - DOMAIN-SUFFIX,sinaimg.cn,DIRECT + - DOMAIN-SUFFIX,sinaimg.com,DIRECT + - DOMAIN-SUFFIX,sinajs.cn,DIRECT + - DOMAIN-SUFFIX,t.cn,DIRECT + + # --- 搜狐 / 搜狗 --- + - DOMAIN-SUFFIX,sohu.com,DIRECT + - DOMAIN-SUFFIX,sohu.cn,DIRECT + - DOMAIN-SUFFIX,sohucs.com,DIRECT + - DOMAIN-SUFFIX,sogou.com,DIRECT + - DOMAIN-SUFFIX,sogoucdn.com,DIRECT + + # --- 360 --- + - DOMAIN-SUFFIX,360.cn,DIRECT + - DOMAIN-SUFFIX,360.com,DIRECT + - DOMAIN-SUFFIX,360safe.com,DIRECT + - DOMAIN-SUFFIX,qhimg.com,DIRECT + - DOMAIN-SUFFIX,qhmsg.com,DIRECT + - DOMAIN-SUFFIX,qihoo.com,DIRECT + - DOMAIN-SUFFIX,so.com,DIRECT + + # --- 快手 --- + - DOMAIN-SUFFIX,kuaishou.com,DIRECT + - DOMAIN-SUFFIX,kuaishou.cn,DIRECT + - DOMAIN-SUFFIX,kuaishoupay.com,DIRECT + - DOMAIN-SUFFIX,kspkg.com,DIRECT + - DOMAIN-SUFFIX,ksapisrv.com,DIRECT + - DOMAIN-SUFFIX,ks-live.com,DIRECT + - DOMAIN-SUFFIX,kssrv.com,DIRECT + + # --- 电信运营商 --- + - DOMAIN-SUFFIX,10010.com,DIRECT + - DOMAIN-SUFFIX,10086.cn,DIRECT + - DOMAIN-SUFFIX,189.cn,DIRECT + - DOMAIN-SUFFIX,chinaunicom.com,DIRECT + - DOMAIN-SUFFIX,chinamobile.com,DIRECT + - DOMAIN-SUFFIX,chinatelecom.com.cn,DIRECT + - DOMAIN-SUFFIX,wo.com.cn,DIRECT + + # --- 国内银行 / 支付 / 金融 --- + - DOMAIN-SUFFIX,icbc.com.cn,DIRECT + - DOMAIN-SUFFIX,ccb.com,DIRECT + - DOMAIN-SUFFIX,boc.cn,DIRECT + - DOMAIN-SUFFIX,abchina.com,DIRECT + - DOMAIN-SUFFIX,bankcomm.com,DIRECT + - DOMAIN-SUFFIX,psbc.com,DIRECT + - DOMAIN-SUFFIX,cmbchina.com,DIRECT + - DOMAIN-SUFFIX,cebbank.com,DIRECT + - DOMAIN-SUFFIX,cib.com.cn,DIRECT + - DOMAIN-SUFFIX,spdb.com.cn,DIRECT + - DOMAIN-SUFFIX,hxb.com.cn,DIRECT + - DOMAIN-SUFFIX,cmbc.com.cn,DIRECT + - DOMAIN-SUFFIX,pingan.com,DIRECT + - DOMAIN-SUFFIX,mybank.cn,DIRECT + - DOMAIN-SUFFIX,unionpay.com,DIRECT + - DOMAIN-SUFFIX,95516.com,DIRECT + + # --- 手机厂商 --- + - DOMAIN-SUFFIX,mi.com,DIRECT + - DOMAIN-SUFFIX,xiaomi.com,DIRECT + - DOMAIN-SUFFIX,mi-img.com,DIRECT + - DOMAIN-SUFFIX,miui.com,DIRECT + - DOMAIN-SUFFIX,huawei.com,DIRECT + - DOMAIN-SUFFIX,huaweicloud.com,DIRECT + - DOMAIN-SUFFIX,hicloud.com,DIRECT + - DOMAIN-SUFFIX,emui.com,DIRECT + - DOMAIN-SUFFIX,hihonor.com,DIRECT + - DOMAIN-SUFFIX,oppo.com,DIRECT + - DOMAIN-SUFFIX,coloros.com,DIRECT + - DOMAIN-SUFFIX,realme.com,DIRECT + - DOMAIN-SUFFIX,vivo.com,DIRECT + - DOMAIN-SUFFIX,vivo.com.cn,DIRECT + - DOMAIN-SUFFIX,bbk.cn,DIRECT + - DOMAIN-SUFFIX,iqoo.com,DIRECT + - DOMAIN-SUFFIX,oneplus.com,DIRECT + - DOMAIN-SUFFIX,meizu.com,DIRECT + - DOMAIN-SUFFIX,flyme.cn,DIRECT + - DOMAIN-SUFFIX,zte.com.cn,DIRECT + - DOMAIN-SUFFIX,nubia.cn,DIRECT + - DOMAIN-SUFFIX,apple.com.cn,DIRECT + - DOMAIN-SUFFIX,apple.cn,DIRECT + + # --- 出行 / 旅游 --- + - DOMAIN-SUFFIX,didiglobal.com,DIRECT + - DOMAIN-SUFFIX,didichuxing.com,DIRECT + - DOMAIN-SUFFIX,xiaojukeji.com,DIRECT + - DOMAIN-SUFFIX,ctrip.com,DIRECT + - DOMAIN-SUFFIX,ctrip.cn,DIRECT + - DOMAIN-SUFFIX,trip.com,DIRECT + - DOMAIN-SUFFIX,qunar.com,DIRECT + - DOMAIN-SUFFIX,elong.com,DIRECT + - DOMAIN-SUFFIX,ly.com,DIRECT + - DOMAIN-SUFFIX,12306.cn,DIRECT + + # --- 在线教育 / 办公 / 开发者 --- + - DOMAIN-SUFFIX,cnki.net,DIRECT + - DOMAIN-SUFFIX,wanfangdata.com.cn,DIRECT + - DOMAIN-SUFFIX,csdn.net,DIRECT + - DOMAIN-SUFFIX,gitee.com,DIRECT + - DOMAIN-SUFFIX,oschina.net,DIRECT + - DOMAIN-SUFFIX,segmentfault.com,DIRECT + - DOMAIN-SUFFIX,jianshu.com,DIRECT + - DOMAIN-SUFFIX,luogu.com.cn,DIRECT + - DOMAIN-SUFFIX,leetcode.cn,DIRECT + - DOMAIN-SUFFIX,nowcoder.com,DIRECT + - DOMAIN-SUFFIX,iconfont.cn,DIRECT + - DOMAIN-SUFFIX,processon.com,DIRECT + - DOMAIN-SUFFIX,mastergo.com,DIRECT + - DOMAIN-SUFFIX,modao.cc,DIRECT + - DOMAIN-SUFFIX,wps.cn,DIRECT + - DOMAIN-SUFFIX,wpscdn.cn,DIRECT + - DOMAIN-SUFFIX,kingsoft.com,DIRECT + + # --- 视频 / 直播 / 音频 --- + - DOMAIN-SUFFIX,douyu.com,DIRECT + - DOMAIN-SUFFIX,huya.com,DIRECT + - DOMAIN-SUFFIX,yy.com,DIRECT + - DOMAIN-SUFFIX,mgtv.com,DIRECT + - DOMAIN-SUFFIX,miguvideo.com,DIRECT + - DOMAIN-SUFFIX,migu.cn,DIRECT + - DOMAIN-SUFFIX,cctv.com,DIRECT + - DOMAIN-SUFFIX,cctvpic.cn,DIRECT + - DOMAIN-SUFFIX,yangshipin.cn,DIRECT + - DOMAIN-SUFFIX,1905.com,DIRECT + - DOMAIN-SUFFIX,kugou.com,DIRECT + - DOMAIN-SUFFIX,kuwo.cn,DIRECT + - DOMAIN-SUFFIX,ximalaya.com,DIRECT + - DOMAIN-SUFFIX,xmcdn.com,DIRECT + + # --- 电商 / 物流 --- + - DOMAIN-SUFFIX,vip.com,DIRECT + - DOMAIN-SUFFIX,dangdang.com,DIRECT + - DOMAIN-SUFFIX,amazon.cn,DIRECT + - DOMAIN-SUFFIX,sf-express.com,DIRECT + - DOMAIN-SUFFIX,sf-express.cn,DIRECT + - DOMAIN-SUFFIX,ems.com.cn,DIRECT + - DOMAIN-SUFFIX,yto.net.cn,DIRECT + - DOMAIN-SUFFIX,zto.com,DIRECT + - DOMAIN-SUFFIX,sto.cn,DIRECT + - DOMAIN-SUFFIX,best.com.cn,DIRECT + - DOMAIN-SUFFIX,deppon.com,DIRECT + + # --- 房产 / 招聘 / 生活服务 --- + - DOMAIN-SUFFIX,anjuke.com,DIRECT + - DOMAIN-SUFFIX,lianjia.com,DIRECT + - DOMAIN-SUFFIX,ke.com,DIRECT + - DOMAIN-SUFFIX,58.com,DIRECT + - DOMAIN-SUFFIX,ganji.com,DIRECT + - DOMAIN-SUFFIX,autohome.com.cn,DIRECT + - DOMAIN-SUFFIX,che168.com,DIRECT + - DOMAIN-SUFFIX,bitauto.com,DIRECT + - DOMAIN-SUFFIX,douban.com,DIRECT + - DOMAIN-SUFFIX,doubanio.com,DIRECT + - DOMAIN-SUFFIX,zhaopin.com,DIRECT + - DOMAIN-SUFFIX,51job.com,DIRECT + - DOMAIN-SUFFIX,liepin.com,DIRECT + + # --- 新闻 / 门户 --- + - DOMAIN-SUFFIX,people.com.cn,DIRECT + - DOMAIN-SUFFIX,xinhuanet.com,DIRECT + - DOMAIN-SUFFIX,ifeng.com,DIRECT + - DOMAIN-SUFFIX,ifengimg.com,DIRECT + - DOMAIN-SUFFIX,chinadaily.com.cn,DIRECT + - DOMAIN-SUFFIX,ce.cn,DIRECT + - DOMAIN-SUFFIX,cnr.cn,DIRECT + - DOMAIN-SUFFIX,china.com,DIRECT + - DOMAIN-SUFFIX,china.com.cn,DIRECT + + # --- 游戏 --- + - DOMAIN-SUFFIX,4399.com,DIRECT + - DOMAIN-SUFFIX,7k7k.com,DIRECT + - DOMAIN-SUFFIX,17173.com,DIRECT + - DOMAIN-SUFFIX,3dmgame.com,DIRECT + - DOMAIN-SUFFIX,ali213.net,DIRECT + - DOMAIN-SUFFIX,gamersky.com,DIRECT + - DOMAIN-SUFFIX,taptap.cn,DIRECT + - DOMAIN-SUFFIX,taptap.com,DIRECT + - DOMAIN-SUFFIX,9game.cn,DIRECT + + # --- 中国政府及公共服务 --- + - DOMAIN-SUFFIX,gov.cn,DIRECT + - DOMAIN-SUFFIX,org.cn,DIRECT + - DOMAIN-SUFFIX,edu.cn,DIRECT + - DOMAIN-SUFFIX,ac.cn,DIRECT + - DOMAIN-SUFFIX,mil.cn,DIRECT + - DOMAIN-SUFFIX,12377.cn,DIRECT + - DOMAIN-SUFFIX,12321.cn,DIRECT + - DOMAIN-SUFFIX,12315.cn,DIRECT + - DOMAIN-SUFFIX,chinapost.com.cn,DIRECT + - DOMAIN-SUFFIX,weather.com.cn,DIRECT + + # --- 其他补充 --- + - DOMAIN-SUFFIX,2345.com,DIRECT + - DOMAIN-SUFFIX,haosou.com,DIRECT + - DOMAIN-SUFFIX,nuomi.com,DIRECT + - DOMAIN-SUFFIX,icloud.com.cn,DIRECT + + # 树萌芽の专属域名 + - DOMAIN-SUFFIX,shumengya.top,DIRECT + - DOMAIN-SUFFIX,smyhub.com,DIRECT + - DOMAIN-SUFFIX,smy.pub,DIRECT + + # OpenCode 直连 + - DOMAIN-SUFFIX,opencode.ai,DIRECT + - DOMAIN-SUFFIX,opencode.com,DIRECT + + # 树萌芽の专属ip + - IP-CIDR,47.90.229.147/32,DIRECT,no-resolve + - IP-CIDR,47.76.191.104/32,DIRECT,no-resolve + + + #Github加速 + - DOMAIN-SUFFIX,github.com,Github + - DOMAIN-SUFFIX,githubusercontent.com,Github + - DOMAIN-SUFFIX,githubassets.com,Github + - DOMAIN-SUFFIX,github.io,Github + - DOMAIN-SUFFIX,github.dev,Github + - DOMAIN-SUFFIX,raw.githubusercontent.com,Github + - DOMAIN-SUFFIX,api.github.com,Github + + #AI代理 (OpenAI / ChatGPT) + - DOMAIN-SUFFIX,chatgpt.com,AI + - DOMAIN-SUFFIX,openai.com,AI + - DOMAIN-SUFFIX,ai.com,AI + - DOMAIN-SUFFIX,oaistatic.com,AI + - DOMAIN-SUFFIX,oaiusercontent.com,AI + - DOMAIN-SUFFIX,openaiapi-site.azureedge.net,AI + - DOMAIN-SUFFIX,openaicom.imgix.net,AI + - DOMAIN-SUFFIX,chatgpt.livekit.cloud,AI + - DOMAIN-SUFFIX,host.livekit.cloud,AI + - DOMAIN-SUFFIX,turn.livekit.cloud,AI + - DOMAIN-SUFFIX,auth0.com,AI + - DOMAIN-SUFFIX,featuregates.org,AI + - DOMAIN-SUFFIX,intercom.io,AI + - DOMAIN-SUFFIX,intercomcdn.com,AI + - DOMAIN-SUFFIX,segment.io,AI + - DOMAIN-SUFFIX,sentry.io,AI + - DOMAIN-SUFFIX,stripe.com,AI + - DOMAIN-SUFFIX,client-api.arkoselabs.com,AI + - DOMAIN-SUFFIX,openai-api.arkoselabs.com,AI + - DOMAIN-SUFFIX,api.statsig.com,AI + - DOMAIN-SUFFIX,events.statsigapi.net,AI + - DOMAIN-SUFFIX,browser-intake-datadoghq.com,AI + - DOMAIN-SUFFIX,static.cloudflareinsights.com,AI + - DOMAIN-KEYWORD,openai,AI + + #Claude代理 (Anthropic) + - DOMAIN-SUFFIX,claude.ai,AI + - DOMAIN-SUFFIX,anthropic.com,AI + - DOMAIN-SUFFIX,claudeusercontent.com,AI + - DOMAIN-SUFFIX,cdn.usefathom.com,AI + + #Grok代理 (xAI) + - DOMAIN-SUFFIX,grok.com,AI + - DOMAIN-SUFFIX,x.ai,AI + + #Google / Gemini / YouTube 全系 + - DOMAIN-SUFFIX,google.com,谷歌 + - DOMAIN-SUFFIX,google.dev,谷歌 + - DOMAIN-SUFFIX,googleapis.com,谷歌 + - DOMAIN-SUFFIX,gstatic.com,谷歌 + - DOMAIN-SUFFIX,googlevideo.com,谷歌 + - DOMAIN-SUFFIX,youtube.com,谷歌 + - DOMAIN-SUFFIX,ytimg.com,谷歌 + - DOMAIN-SUFFIX,gmail.com,谷歌 + - DOMAIN-SUFFIX,deepmind.com,谷歌 + - DOMAIN-SUFFIX,google,谷歌 + + #私有域名走直连 + - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve + - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve + - IP-CIDR,172.16.0.0/12,DIRECT,no-resolve + - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve + + + - GEOSITE,CN,DIRECT + - GEOSITE,geolocation-!cn,默认 + - GEOIP,CN,DIRECT + - MATCH,默认 diff --git a/dev.bat b/dev.bat new file mode 100644 index 0000000..c2cbdc6 --- /dev/null +++ b/dev.bat @@ -0,0 +1,4 @@ +@echo off + +start "sproutclaw-web Frontend" cmd /k "%~dp0run-frontend.bat" +start "sproutclaw-web Backend" cmd /k "%~dp0run-backend.bat" diff --git a/frontend/.gitignore b/frontend/.gitignore index 9139156..4c8abf3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,3 @@ node_modules dist/ -dist-desketop/ +dist-desketop/ \ No newline at end of file diff --git a/frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf b/frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf new file mode 100644 index 0000000..78f40c8 Binary files /dev/null and b/frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b785a0..04fa2b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "sproutclaw-webui-frontend", "version": "0.0.0", "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "github-markdown-css": "^5.8.1", "highlight.js": "11.11.1", "marked": "15.0.12", "react": "19.2.6", @@ -15,7 +19,6 @@ "react-router-dom": "7.15.1" }, "devDependencies": { - "@mogeko/maple-mono-cn": "7.9.0", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "4.7.0", @@ -2630,13 +2633,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mogeko/maple-mono-cn": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@mogeko/maple-mono-cn/-/maple-mono-cn-7.9.0.tgz", - "integrity": "sha512-NZls63+8Q4+17saZqhN5sFB9UqIfyI73q+NNyXNQFmdzivzSYAKLCuo2yvheyEm95kuH8VEhwTl4YvbMoqIbFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3237,6 +3233,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4284,6 +4304,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-markdown-css": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", + "integrity": "sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fe915d..a6dd081 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,13 +5,18 @@ "type": "module", "scripts": { "generate-icons": "node scripts/generate-icons.mjs", + "prepare-font": "node scripts/prepare-font.mjs", "dev": "vite", - "build": "npm run generate-icons && tsc -b && vite build && vite build --mode desktop", - "build:web": "npm run generate-icons && tsc -b && vite build", - "build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop", + "build": "npm run generate-icons && npm run prepare-font && tsc -b && vite build && vite build --mode desktop", + "build:web": "npm run generate-icons && npm run prepare-font && tsc -b && vite build", + "build:desktop": "npm run generate-icons && npm run prepare-font && tsc -b && vite build --mode desktop", "preview": "vite preview" }, "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "github-markdown-css": "^5.8.1", "highlight.js": "11.11.1", "marked": "15.0.12", "react": "19.2.6", @@ -19,7 +24,6 @@ "react-router-dom": "7.15.1" }, "devDependencies": { - "@mogeko/maple-mono-cn": "7.9.0", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "4.7.0", diff --git a/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf b/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf new file mode 100644 index 0000000..78f40c8 Binary files /dev/null and b/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf differ diff --git a/frontend/public/fonts/lxgwwenkaimono-medium.woff2 b/frontend/public/fonts/lxgwwenkaimono-medium.woff2 new file mode 100644 index 0000000..ee5baa5 Binary files /dev/null and b/frontend/public/fonts/lxgwwenkaimono-medium.woff2 differ diff --git a/frontend/scripts/prepare-font.mjs b/frontend/scripts/prepare-font.mjs new file mode 100644 index 0000000..1b43571 --- /dev/null +++ b/frontend/scripts/prepare-font.mjs @@ -0,0 +1,63 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fontsDir = join(__dirname, "..", "public", "fonts"); +const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2"); + +const sourceCandidates = [ + join(fontsDir, "LXGWWenKaiMono-Medium.ttf"), + join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf"), +]; + +const sourceTtf = sourceCandidates.find((path) => existsSync(path)); +const MIN_WOFF2_BYTES = 5_000_000; + +if (!sourceTtf) { + console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping"); + process.exit(0); +} + +mkdirSync(fontsDir, { recursive: true }); + +function woff2LooksValid() { + if (!existsSync(outputWoff2)) return false; + return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES; +} + +if (woff2LooksValid()) { + const sourceMtime = statSync(sourceTtf).mtimeMs; + const outputMtime = statSync(outputWoff2).mtimeMs; + if (outputMtime >= sourceMtime) { + const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2); + console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB)`); + process.exit(0); + } +} + +if (existsSync(outputWoff2) && !woff2LooksValid()) { + console.warn("[prepare-font] removing invalid woff2 (< 5 MB, likely subset or corrupt)"); + unlinkSync(outputWoff2); +} + +console.log("[prepare-font] converting full font to woff2 (no glyph subsetting, may take a few minutes)..."); + +const py = [ + "from fontTools.ttLib.woff2 import compress", + "import sys", + "compress(sys.argv[1], sys.argv[2])", +].join("\n"); + +execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" }); + +const sizeBytes = readFileSync(outputWoff2).length; +if (sizeBytes < MIN_WOFF2_BYTES) { + throw new Error( + `[prepare-font] woff2 output too small (${sizeBytes} bytes); conversion likely failed`, + ); +} + +const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2); +console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set)`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 244f79e..55ea98e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt"; import { ChatLayout } from "./components/layout/ChatLayout"; @@ -5,7 +6,8 @@ import { AvatarProvider } from "./context/AvatarContext"; import { BootstrapProvider } from "./context/BootstrapContext"; import { ChatProvider } from "./context/ChatContext"; import { ChatPage } from "./routes/ChatPage"; -import { SettingsPage } from "./routes/SettingsPage"; + +const SettingsPage = lazy(() => import("./routes/SettingsPage")); export function App() { return ( @@ -17,7 +19,14 @@ export function App() { }> } /> - } /> + + + + } + /> diff --git a/frontend/src/api/base.ts b/frontend/src/api/base.ts index 3943a09..3cf2ae2 100644 --- a/frontend/src/api/base.ts +++ b/frontend/src/api/base.ts @@ -11,3 +11,16 @@ export function apiUrl(path: string): string { export function getApiBase(): string { return API_BASE; } + +/** Resolve a WebSocket URL for an API path. */ +export function wsUrl(path: string): string { + const httpPath = apiUrl(path); + if (httpPath.startsWith("https://")) { + return `wss://${httpPath.slice("https://".length)}`; + } + if (httpPath.startsWith("http://")) { + return `ws://${httpPath.slice("http://".length)}`; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${httpPath}`; +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index cb58dac..b1fe0c3 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -54,3 +54,7 @@ export function saveAvatars(avatars: AvatarSettings): Promise { return apiGet("/api/environment"); } + +export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> { + return apiGet("/api/terminal"); +} diff --git a/frontend/src/components/session/SessionList.module.css b/frontend/src/components/session/SessionList.module.css index f98d674..0dd3aa8 100644 --- a/frontend/src/components/session/SessionList.module.css +++ b/frontend/src/components/session/SessionList.module.css @@ -20,6 +20,13 @@ text-align: center; } +.emptyInline { + padding: 10px 10px 4px; + color: #ccc; + font-size: 12px; + text-align: center; +} + .item { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -48,6 +55,16 @@ background: #fef3c7; } +.ephemeral { + margin-bottom: 6px; + border-bottom: 1px solid #f0f0f0; + padding-bottom: 6px; +} + +.ephemeral .main { + cursor: pointer; +} + .main { min-width: 0; text-align: left; diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 0e13cf8..a840aaf 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -5,8 +5,16 @@ import { formatSessionTitle } from "../../utils/sessionTitle"; import styles from "./SessionList.module.css"; export function SessionList() { - const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } = - useChatContext(); + const { + sessions, + activeSessionPath, + isEphemeralSession, + loadSession, + newTemporarySession, + deleteSession, + renameSession, + toggleSessionPin, + } = useChatContext(); const [editingPath, setEditingPath] = useState(null); const [editingValue, setEditingValue] = useState(""); @@ -29,102 +37,109 @@ export function SessionList() { setEditingValue(""); }; - if (!sessions.length) { - return
暂无历史会话
; - } - return (
- {sessions.map((s) => { - const isActive = activeSessionPath === s.path; - const isPinned = Boolean(s.pinned); - const title = formatSessionTitle(s.name); - const isEditing = editingPath === s.path; - return ( -
- +
+ + {sessions.length === 0 ? ( +
暂无历史会话
+ ) : ( + sessions.map((s) => { + const isActive = !isEphemeralSession && activeSessionPath === s.path; + const isPinned = Boolean(s.pinned); + const title = formatSessionTitle(s.name); + const isEditing = editingPath === s.path; + return ( +
+ +
+ + +
-
- {s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)} -
- -
- - -
-
- ); - })} + ); + }) + )}
); } diff --git a/frontend/src/components/terminal/WebTerminal.module.css b/frontend/src/components/terminal/WebTerminal.module.css new file mode 100644 index 0000000..4925430 --- /dev/null +++ b/frontend/src/components/terminal/WebTerminal.module.css @@ -0,0 +1,134 @@ +.shell { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + background: #0d1117; + overflow: hidden; +} + +.toolbar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #30363d; + background: #161b22; + color: #c9d1d9; +} + +.toolbarTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.statusLine { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 12px; +} + +.statusText { + color: #c9d1d9; +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #6e7681; + flex-shrink: 0; +} + +.statusDot.connecting { + background: #d29922; +} + +.statusDot.connected { + background: #3fb950; +} + +.statusDot.error { + background: #f85149; +} + +.reconnectBtn { + height: 30px; + padding: 0 12px; + border: 1px solid #30363d; + border-radius: 6px; + background: #21262d; + color: #c9d1d9; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; +} + +.reconnectBtn:hover:not(:disabled) { + background: #30363d; +} + +.reconnectBtn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.terminalHost { + position: relative; + flex: 1 1 auto; + min-height: 260px; + overflow: hidden; + background: #0d1117; +} + +.terminalHost :global(.xterm) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 10px 12px; + box-sizing: border-box; +} + +.terminalHost :global(.xterm-viewport) { + overflow-y: auto !important; + overflow-x: hidden !important; + width: 100% !important; +} + +.terminalHost :global(.xterm-screen) { + width: 100% !important; +} + +.terminalHost :global(.xterm-rows) { + width: 100% !important; +} + +.terminalHost :global(.xterm-helper-textarea) { + position: absolute; + opacity: 0; + left: 0; + top: 0; + width: 0; + height: 0; + z-index: -1; +} + +@media (max-width: 768px) { + .toolbar { + padding: 8px 10px; + } + + .terminalHost { + min-height: min(360px, calc(var(--app-height) - 220px)); + } + + .terminalHost :global(.xterm) { + padding: 8px 10px; + } +} diff --git a/frontend/src/components/terminal/WebTerminal.tsx b/frontend/src/components/terminal/WebTerminal.tsx new file mode 100644 index 0000000..b8b31ca --- /dev/null +++ b/frontend/src/components/terminal/WebTerminal.tsx @@ -0,0 +1,201 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Terminal } from "@xterm/xterm"; +import { useEffect, useRef, useState } from "react"; +import { wsUrl } from "../../api/base"; +import styles from "./WebTerminal.module.css"; +import "@xterm/xterm/css/xterm.css"; + +type ConnectionState = "idle" | "connecting" | "connected" | "error"; + +function normalizeTerminalInput(data: string, platform?: string): string { + if (platform !== "windows") return data; + return data.replace(/\r(?!\n)/g, "\r\n"); +} + +function terminalFontSize(width: number): number { + if (width < 360) return 11; + if (width < 480) return 12; + return 13; +} + +export function WebTerminal({ + active, + platform, +}: { + active: boolean; + platform?: string; +}) { + const shellRef = useRef(null); + const containerRef = useRef(null); + const [connectionState, setConnectionState] = useState("idle"); + const [sessionKey, setSessionKey] = useState(0); + + useEffect(() => { + if (!active) { + setConnectionState("idle"); + return; + } + + const container = containerRef.current; + const shell = shellRef.current; + if (!container || !shell) return; + + let disposed = false; + let resizeTimer: number | undefined; + + const term = new Terminal({ + cursorBlink: true, + fontSize: terminalFontSize(container.clientWidth), + lineHeight: 1.2, + letterSpacing: 0, + fontFamily: '"LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace', + scrollback: 5000, + allowProposedApi: true, + theme: { + background: "#0d1117", + foreground: "#c9d1d9", + cursor: "#58a6ff", + selectionBackground: "#264f78", + }, + }); + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + term.open(container); + + setConnectionState("connecting"); + + const ws = new WebSocket(wsUrl("/api/terminal/ws")); + ws.binaryType = "arraybuffer"; + + const sendResize = () => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + }; + + const fit = () => { + if (disposed) return; + const width = container.clientWidth; + const height = container.clientHeight; + if (width < 24 || height < 24) return; + + const nextFontSize = terminalFontSize(width); + if (term.options.fontSize !== nextFontSize) { + term.options.fontSize = nextFontSize; + } + + try { + fitAddon.fit(); + } catch { + return; + } + sendResize(); + }; + + const scheduleFit = () => { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + requestAnimationFrame(fit); + }, 32); + }; + + const resizeObserver = new ResizeObserver(scheduleFit); + resizeObserver.observe(shell); + resizeObserver.observe(container); + window.addEventListener("resize", scheduleFit); + + ws.onopen = () => { + if (disposed) return; + setConnectionState("connected"); + scheduleFit(); + window.setTimeout(scheduleFit, 120); + term.focus(); + }; + + ws.onmessage = (event) => { + if (disposed) return; + if (typeof event.data === "string") { + try { + const payload = JSON.parse(event.data) as { type?: string; error?: string }; + if (payload.type === "error" && payload.error) { + setConnectionState("error"); + term.writeln(`\r\n\x1b[31m${payload.error}\x1b[0m`); + } + } catch { + term.write(event.data); + } + return; + } + term.write(new Uint8Array(event.data)); + }; + + ws.onerror = () => { + if (disposed) return; + setConnectionState("error"); + }; + + ws.onclose = () => { + if (disposed) return; + setConnectionState("idle"); + term.writeln("\r\n\x1b[90m[连接已断开]\x1b[0m"); + }; + + const dataDisposable = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data: normalizeTerminalInput(data, platform) })); + } + }); + + const resizeDisposable = term.onResize(() => { + sendResize(); + }); + + scheduleFit(); + + return () => { + disposed = true; + window.clearTimeout(resizeTimer); + window.removeEventListener("resize", scheduleFit); + dataDisposable.dispose(); + resizeDisposable.dispose(); + resizeObserver.disconnect(); + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + term.dispose(); + }; + }, [active, platform, sessionKey]); + + const statusLabel = + connectionState === "connecting" + ? "连接中…" + : connectionState === "connected" + ? "已连接" + : connectionState === "error" + ? "连接失败" + : "未连接"; + + return ( +
+
+
+
+ + {statusLabel} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/context/BootstrapContext.tsx b/frontend/src/context/BootstrapContext.tsx index 3f9a9c0..331861f 100644 --- a/frontend/src/context/BootstrapContext.tsx +++ b/frontend/src/context/BootstrapContext.tsx @@ -12,6 +12,7 @@ import { useLocation } from "react-router-dom"; import { SplashScreen } from "../components/pwa/SplashScreen"; const MIN_SPLASH_MS = 400; +const MAX_SPLASH_MS = 8000; const SKIP_SPLASH_KEY = "sproutclaw-warm-boot"; type BootstrapRoute = "chat" | "settings"; @@ -44,6 +45,14 @@ export function BootstrapProvider({ children }: { children: ReactNode }) { mountTime.current = Date.now(); }, [route]); + useEffect(() => { + if (readyRoutes[route]) return; + const timer = window.setTimeout(() => { + setReadyRoutes((prev) => (prev[route] ? prev : { ...prev, [route]: true })); + }, MAX_SPLASH_MS); + return () => window.clearTimeout(timer); + }, [route, readyRoutes]); + useEffect(() => { if (!readyRoutes[route]) return; diff --git a/frontend/src/context/ChatContext.tsx b/frontend/src/context/ChatContext.tsx index 75ec205..6ad0689 100644 --- a/frontend/src/context/ChatContext.tsx +++ b/frontend/src/context/ChatContext.tsx @@ -26,6 +26,7 @@ import { formatToolCall, getToolCalls, buildToolCallUpdateText, + preferToolCallMessage, } from "../utils/toolCall"; import { formatSessionTitle, @@ -65,6 +66,8 @@ interface ChatContextValue { renameSession: (path: string, currentTitle: string) => Promise; toggleSessionPin: (path: string, pinned: boolean) => Promise; newSession: () => void; + newTemporarySession: () => void; + isEphemeralSession: boolean; sendMessage: ( text: string, images?: ImageContent[], @@ -85,6 +88,20 @@ const ChatContext = createContext(null); const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; const LAST_ACTIVE_SESSION_KEY = "webui:lastActiveSessionPath"; +const EPHEMERAL_SESSION_ID = "__ephemeral__"; +const EPHEMERAL_CLEANUP_KEY = "webui:ephemeralSessionPath"; + +function isActiveSessionBound( + isEphemeral: boolean, + activePath: string | null, + backendPath: string | null, +): boolean { + if (isEphemeral) { + return Boolean(backendPath); + } + if (!activePath) return false; + return backendPath === activePath; +} /** Survives React StrictMode remount so boot only runs once per page load. */ let sharedSessionBootPromise: Promise | null = null; @@ -364,6 +381,9 @@ function finalizeAssistantMessageEnd( } function messageDedupeSignature(msg: ChatMessage): string | null { + if (msg.role === "tool_call" && msg.toolCallId) { + return `tool_call:${msg.toolCallId}`; + } if (msg.role !== "assistant" && msg.role !== "thinking" && msg.role !== "tool_call") { return null; } @@ -437,12 +457,24 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] { const existingIndex = turnSignatures.get(signature); if (existingIndex === undefined) { + if (msg.role === "tool_call") { + const idIdx = result.findIndex((m) => m.id === msg.id); + if (idIdx >= 0) { + result[idIdx] = preferToolCallMessage(result[idIdx], msg); + turnSignatures.set(signature, idIdx); + continue; + } + } turnSignatures.set(signature, result.length); result.push(msg); continue; } const existing = result[existingIndex]; + if (msg.role === "tool_call") { + result[existingIndex] = preferToolCallMessage(existing, msg); + continue; + } if (existing.streaming && !msg.streaming) { result[existingIndex] = msg; } @@ -451,6 +483,11 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] { return collapseStreamingPrefixOrphans(result); } +function isIgnorableExtensionError(error: string | undefined): boolean { + if (!error) return false; + return error.includes("extension ctx is stale after session replacement or reload"); +} + export function ChatProvider({ children }: { children: ReactNode }) { const { markRouteReady } = useBootstrap(); const [connected, setConnected] = useState(false); @@ -475,8 +512,12 @@ export function ChatProvider({ children }: { children: ReactNode }) { const [extensionStatuses, setExtensionStatuses] = useState>({}); const [extensionDialog, setExtensionDialog] = useState(null); const [toolsExpanded, setToolsExpanded] = useState(false); + const [isEphemeralSession, setIsEphemeralSession] = useState(false); const sessionCache = useRef(new Map()); + const isEphemeralRef = useRef(false); + const ephemeralSessionPathRef = useRef(null); + const suppressStreamMessagesRef = useRef(false); const sessionActivationPromise = useRef | null>(null); const newSessionPromise = useRef | null>(null); const ensureSessionCreatePromise = useRef | null>(null); @@ -550,11 +591,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel); if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming); - if (!activeSessionPathRef.current) { - return; - } - - if (backendSessionPathRef.current !== activeSessionPathRef.current) { + if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) { return; } @@ -574,20 +611,18 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming); if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting); - if (!activeSessionPathRef.current) { - return; - } - - if (backendSessionPathRef.current !== activeSessionPathRef.current) { + if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) { return; } const sid = data.sessionId != null ? String(data.sessionId) : ""; - const pathKey = activeSessionPathRef.current; + const pathKey = isEphemeralRef.current ? backendSessionPathRef.current : activeSessionPathRef.current; setSessions((currentSessions) => { const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined; if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) { setHeaderTitle(formatSessionTitle(data.sessionName)); + } else if (isEphemeralRef.current) { + setHeaderTitle("临时对话"); } else if (row) { if (row.name && !isMachineSessionLabel(row.name, sid)) { setHeaderTitle(formatSessionTitle(row.name)); @@ -627,26 +662,89 @@ export function ChatProvider({ children }: { children: ReactNode }) { const loadSessions = useCallback(async () => { try { const data = await sessionsApi.fetchSessions(); - setSessions(data.sessions || []); + const ephemeralPath = ephemeralSessionPathRef.current; + const list = (data.sessions || []).filter((s) => s.path !== ephemeralPath); + setSessions(list); } catch (err) { addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`); } }, [addSystemMessage]); - const createAndBindNewSession = useCallback(async (): Promise => { - const data = await chatApi.createNewSession(); - if (data.error) { - throw new Error(data.error); + const cleanupEphemeralSessionOnBoot = useCallback(async () => { + try { + const path = sessionStorage.getItem(EPHEMERAL_CLEANUP_KEY)?.trim(); + if (!path) return; + sessionStorage.removeItem(EPHEMERAL_CLEANUP_KEY); + await sessionsApi.deleteSession(path); + } catch { + /* ignore cleanup errors */ } - const path = data.sessionFile?.trim() || (await syncBackendSessionPathFromState()); - if (!path) { - throw new Error("无法获取会话路径"); + }, []); + + const discardEphemeralSession = useCallback(async () => { + if (!isEphemeralRef.current && !ephemeralSessionPathRef.current) return; + + const path = ephemeralSessionPathRef.current; + isEphemeralRef.current = false; + ephemeralSessionPathRef.current = null; + setIsEphemeralSession(false); + + try { + sessionStorage.removeItem(EPHEMERAL_CLEANUP_KEY); + } catch { + /* ignore */ } - setBackendSessionPath(path); - void persistLastActiveSession(path); - void loadSessions(); - return path; - }, [syncBackendSessionPathFromState, persistLastActiveSession, loadSessions]); + + if (!path) return; + + try { + await sessionsApi.deleteSession(path); + } catch { + /* ignore delete errors */ + } + sessionCache.current.delete(path); + setSessions((prev) => prev.filter((s) => s.path !== path)); + }, []); + + const createAndBindNewSession = useCallback( + async (options?: { persist?: boolean; refreshList?: boolean }): Promise => { + const { persist = true, refreshList = true } = options ?? {}; + const data = await chatApi.createNewSession(); + if (data.error) { + throw new Error(data.error); + } + const path = data.sessionFile?.trim() || (await syncBackendSessionPathFromState()); + if (!path) { + throw new Error("无法获取会话路径"); + } + setBackendSessionPath(path); + if (persist) { + void persistLastActiveSession(path); + } + if (refreshList) { + void loadSessions(); + } + return path; + }, + [syncBackendSessionPathFromState, persistLastActiveSession, loadSessions], + ); + + const syncMessagesForPath = useCallback(async (path: string) => { + try { + const payload = await sessionsApi.fetchSessionHistory(path); + if (payload.error) { + invalidateTurnEvents(); + setMessages([]); + return; + } + sessionCache.current.set(path, payload); + invalidateTurnEvents(); + setMessages(historyToMessages(payload)); + } catch { + invalidateTurnEvents(); + setMessages([]); + } + }, [invalidateTurnEvents]); const clearChat = useCallback( (title = "聊天") => { @@ -661,6 +759,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { const loadSession = useCallback( async (path: string, options?: { skipDashboardRefresh?: boolean }) => { + await discardEphemeralSession(); + if (!compactionReloadPendingRef.current) { invalidateTurnEvents(); } @@ -731,15 +831,19 @@ export function ChatProvider({ children }: { children: ReactNode }) { addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`); } }, - [addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull], + [addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull, discardEphemeralSession], ); - const restoreSessionOnBoot = useCallback(() => { + const restoreSessionOnBoot = useCallback((onSessionsReady?: () => void) => { + const notifySessionsReady = () => onSessionsReady?.(); if (!sharedSessionBootPromise) { sharedSessionBootPromise = (async () => { + await cleanupEphemeralSessionOnBoot(); + const data = await sessionsApi.fetchSessions(); const list = data.sessions || []; setSessions(list); + notifySessionsReady(); let targetPath: string | null = null; try { @@ -756,24 +860,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { } if (targetPath) { - let activatedState: SessionState | null = null; - await Promise.all([ - loadSession(targetPath, { skipDashboardRefresh: true }), - (async () => { - try { - activatedState = await activateSessionBackend(targetPath); - } catch (err) { - addSystemMessage( - `恢复上次会话失败: ${err instanceof Error ? err.message : String(err)}`, - ); - } - })(), - ]); - if (activatedState) { - applySessionState(activatedState); - } else { - await fetchSessionStateFull(); - } + await loadSession(targetPath, { skipDashboardRefresh: true }); return; } @@ -789,17 +876,18 @@ export function ChatProvider({ children }: { children: ReactNode }) { addSystemMessage(`初始化会话失败: ${err instanceof Error ? err.message : String(err)}`); throw err; }); + } else if (onSessionsReady) { + void sharedSessionBootPromise.then(notifySessionsReady).catch(notifySessionsReady); } sessionBootRef.current = sharedSessionBootPromise; return sharedSessionBootPromise; }, [ loadSession, - activateSessionBackend, - applySessionState, fetchSessionStateFull, addSystemMessage, createAndBindNewSession, invalidateTurnEvents, + cleanupEphemeralSessionOnBoot, ]); const deleteSessionHandler = useCallback( @@ -816,7 +904,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { await sessionsApi.deleteSession(path); sessionCache.current.delete(path); setSessions((prev) => prev.filter((s) => s.path !== path)); - if (activeSessionPathRef.current === path) { + if (activeSessionPathRef.current === path || (isEphemeralRef.current && ephemeralSessionPathRef.current === path)) { clearChat(); setBackendSessionPath(null); sessionActivationPromise.current = null; @@ -881,15 +969,19 @@ export function ChatProvider({ children }: { children: ReactNode }) { } if (newSessionPromise.current) return; - setBackendSessionPath(null); - setActiveSessionPath(null); - sessionActivationPromise.current = null; - setHeaderMeta(""); - clearChat("新会话"); - void persistLastActiveSession(null); - const promise = (async () => { - await createAndBindNewSession(); + await discardEphemeralSession(); + + suppressStreamMessagesRef.current = true; + setBackendSessionPath(null); + setActiveSessionPath(null); + sessionActivationPromise.current = null; + setHeaderMeta(""); + clearChat("新会话"); + void persistLastActiveSession(null); + + const path = await createAndBindNewSession(); + await syncMessagesForPath(path); await fetchSessionStateFull(); })(); @@ -900,9 +992,68 @@ export function ChatProvider({ children }: { children: ReactNode }) { addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`); }) .finally(() => { + suppressStreamMessagesRef.current = false; newSessionPromise.current = null; }); - }, [addSystemMessage, clearChat, createAndBindNewSession, fetchSessionStateFull, persistLastActiveSession]); + }, [addSystemMessage, clearChat, createAndBindNewSession, discardEphemeralSession, fetchSessionStateFull, persistLastActiveSession, syncMessagesForPath]); + + const newTemporarySession = useCallback(() => { + if (isStreamingRef.current) { + addSystemMessage("正在回复中,暂不能创建临时对话"); + return; + } + if (newSessionPromise.current) return; + + const promise = (async () => { + await discardEphemeralSession(); + + suppressStreamMessagesRef.current = true; + setSidebarOpen(false); + setBackendSessionPath(null); + sessionActivationPromise.current = null; + setHeaderMeta(""); + clearChat("临时对话"); + void persistLastActiveSession(null); + + const path = await createAndBindNewSession({ persist: false, refreshList: false }); + + isEphemeralRef.current = true; + ephemeralSessionPathRef.current = path; + setIsEphemeralSession(true); + setActiveSessionPath(EPHEMERAL_SESSION_ID); + + try { + sessionStorage.setItem(EPHEMERAL_CLEANUP_KEY, path); + } catch { + /* ignore */ + } + + await syncMessagesForPath(path); + await fetchSessionStateFull(); + })(); + + newSessionPromise.current = promise; + void promise + .catch((err) => { + isEphemeralRef.current = false; + ephemeralSessionPathRef.current = null; + setIsEphemeralSession(false); + setHeaderMeta(""); + addSystemMessage(`创建临时对话失败: ${err instanceof Error ? err.message : String(err)}`); + }) + .finally(() => { + suppressStreamMessagesRef.current = false; + newSessionPromise.current = null; + }); + }, [ + addSystemMessage, + clearChat, + createAndBindNewSession, + discardEphemeralSession, + fetchSessionStateFull, + persistLastActiveSession, + syncMessagesForPath, + ]); const ensureSessionReady = useCallback(async (): Promise => { if (newSessionPromise.current) { @@ -939,7 +1090,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!created) return false; } - if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) { + if ( + !isEphemeralRef.current && + activeSessionPathRef.current && + backendSessionPathRef.current !== activeSessionPathRef.current + ) { setHeaderMeta("正在切换会话..."); if (sessionActivationPromise.current) { try { @@ -986,6 +1141,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (path) { await loadSession(path); } else { + await discardEphemeralSession(); clearChat(); await fetchSessionStateFull(); } @@ -1005,6 +1161,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { [ addSystemMessage, clearChat, + discardEphemeralSession, loadSessions, loadSession, fetchSessionStateFull, @@ -1062,6 +1219,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { } const persistPathAfterSend = () => { + if (isEphemeralRef.current) return; const persistPath = resolvePersistSessionPath( backendSessionPathRef.current, activeSessionPathRef.current, @@ -1318,6 +1476,18 @@ export function ChatProvider({ children }: { children: ReactNode }) { const processStreamEvent = useCallback( (ev: SseEvent) => { + if ( + suppressStreamMessagesRef.current && + (ev.type === "message_start" || + ev.type === "message_update" || + ev.type === "message_end" || + ev.type === "tool_execution_start" || + ev.type === "tool_execution_update" || + ev.type === "tool_execution_end") + ) { + return; + } + switch (ev.type) { case "agent_start": setIsStreaming(true); @@ -1470,7 +1640,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (ev.errorMessage) addSystemMessage(ev.errorMessage); break; } - const activePath = activeSessionPathRef.current; + const activePath = isEphemeralRef.current + ? backendSessionPathRef.current + : activeSessionPathRef.current; const summaryText = ev.result?.summary?.trim(); if (summaryText) { setMessages((prev) => [ @@ -1522,9 +1694,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { break; case "extension_error": - addSystemMessage( - `扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`, - ); + if (!isIgnorableExtensionError(ev.error)) { + addSystemMessage( + `扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`, + ); + } break; case "bash_update": { @@ -1557,6 +1731,18 @@ export function ChatProvider({ children }: { children: ReactNode }) { setIsStreaming(ev.isStreaming); } for (const replayEv of ev.replay ?? []) { + if (suppressStreamMessagesRef.current) { + if ( + replayEv.type === "message_start" || + replayEv.type === "message_update" || + replayEv.type === "message_end" || + replayEv.type === "tool_execution_start" || + replayEv.type === "tool_execution_update" || + replayEv.type === "tool_execution_end" + ) { + continue; + } + } if (replayEv.type === "extension_ui_request") { handleExtensionUiRequest(replayEv as Record); } else { @@ -1594,6 +1780,15 @@ export function ChatProvider({ children }: { children: ReactNode }) { ], ); + const markRouteReadyRef = useRef(markRouteReady); + const handleSseEventRef = useRef(handleSseEvent); + const addSystemMessageRef = useRef(addSystemMessage); + const restoreSessionOnBootRef = useRef(restoreSessionOnBoot); + markRouteReadyRef.current = markRouteReady; + handleSseEventRef.current = handleSseEvent; + addSystemMessageRef.current = addSystemMessage; + restoreSessionOnBootRef.current = restoreSessionOnBoot; + useEffect(() => { syncViewportHeight(); const onResize = () => { @@ -1605,24 +1800,42 @@ export function ChatProvider({ children }: { children: ReactNode }) { window.visualViewport?.addEventListener("scroll", syncViewportHeight); window.addEventListener("orientationchange", syncViewportHeight); + let cancelled = false; const bootstrap = { sessions: false, models: false, - sessionState: false, - sse: false, }; const tryMarkBootstrapReady = () => { - if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) { - markRouteReady("chat"); + if (cancelled) return; + if (bootstrap.sessions && bootstrap.models) { + markRouteReadyRef.current("chat"); } }; + void (async () => { + try { + await restoreSessionOnBootRef.current(() => { + bootstrap.sessions = true; + tryMarkBootstrapReady(); + }); + } catch { + bootstrap.sessions = true; + tryMarkBootstrapReady(); + } + })(); + void chatApi .fetchModels() - .then((data) => setAvailableModels(data.models || [])) + .then((data) => { + if (!cancelled) setAvailableModels(data.models || []); + }) .catch((err) => { - addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`); + if (!cancelled) { + addSystemMessageRef.current( + `模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } }) .finally(() => { bootstrap.models = true; @@ -1631,31 +1844,18 @@ export function ChatProvider({ children }: { children: ReactNode }) { let eventSource: EventSource | null = null; let reconnectTimer: ReturnType | null = null; - let sseBootstrapTimer: ReturnType | null = null; - - const finishSseBootstrap = () => { - if (bootstrap.sse) return; - bootstrap.sse = true; - tryMarkBootstrapReady(); - }; - - sseBootstrapTimer = setTimeout(() => { - if (!bootstrap.sse) { - addSystemMessage("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问"); - finishSseBootstrap(); - } - }, 10_000); + let sseNoticeTimer: ReturnType | null = null; + let sseNoticeShown = false; const connect = () => { if (eventSource) eventSource.close(); eventSource = new EventSource(apiUrl("/api/events")); eventSource.onopen = () => { setConnected(true); - finishSseBootstrap(); }; eventSource.onmessage = (e) => { try { - handleSseEvent(JSON.parse(e.data) as SseEvent); + handleSseEventRef.current(JSON.parse(e.data) as SseEvent); } catch { /* ignore */ } @@ -1669,25 +1869,23 @@ export function ChatProvider({ children }: { children: ReactNode }) { connect(); - void restoreSessionOnBoot() - .catch(() => {}) - .finally(() => { - bootstrap.sessions = true; - bootstrap.sessionState = true; - tryMarkBootstrapReady(); - }); + sseNoticeTimer = setTimeout(() => { + if (cancelled || sseNoticeShown) return; + sseNoticeShown = true; + addSystemMessageRef.current("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问"); + }, 12_000); return () => { + cancelled = true; window.removeEventListener("resize", onResize); window.visualViewport?.removeEventListener("resize", syncViewportHeight); window.visualViewport?.removeEventListener("scroll", syncViewportHeight); window.removeEventListener("orientationchange", syncViewportHeight); if (reconnectTimer) clearTimeout(reconnectTimer); - if (sseBootstrapTimer) clearTimeout(sseBootstrapTimer); + if (sseNoticeTimer) clearTimeout(sseNoticeTimer); eventSource?.close(); - sharedSessionBootPromise = null; }; - }, [restoreSessionOnBoot, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady]); + }, []); const value: ChatContextValue = { connected, @@ -1720,6 +1918,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { renameSession: renameSessionHandler, toggleSessionPin: toggleSessionPinHandler, newSession, + newTemporarySession, + isEphemeralSession, sendMessage, runBashCommand, abortStream, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 65e6feb..a0f64ae 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,21 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import "@mogeko/maple-mono-cn/dist/font/result.css"; import { App } from "./App"; import "./styles/global.css"; +function scheduleFontLoad() { + const load = () => { + void import("./styles/fonts.css"); + }; + if (typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(load); + } else { + setTimeout(load, 200); + } +} + +scheduleFontLoad(); + createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/routes/SettingsPage.module.css b/frontend/src/routes/SettingsPage.module.css index fb5a58a..b9a9322 100644 --- a/frontend/src/routes/SettingsPage.module.css +++ b/frontend/src/routes/SettingsPage.module.css @@ -181,6 +181,55 @@ word-break: break-all; } +.panelHeaderActions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.headerBtnActive { + border-color: #2563eb; + color: #2563eb; + background: #eff6ff; +} + +.markdownPreview { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + background: #fff; +} + +.markdownBody { + max-width: none; + min-height: 100%; + box-sizing: border-box; + padding: 24px 28px; +} + +.markdownBody :global(pre.assistant-pre) { + margin: 0.5em 0 0.6em; + padding: 0; + background: transparent; + border: 1px solid #d0d7de; + border-radius: 6px; + overflow-x: auto; + line-height: 1.45; +} + +.markdownBody :global(pre.assistant-pre code.hljs) { + display: block; + padding: 16px; + background: #f6f8fa; + border: none; + color: inherit; + font-size: 13px; + line-height: 1.45; + word-break: normal; + tab-size: 2; +} + .primaryBtn, .secondaryBtn, .linkBtn { @@ -256,6 +305,20 @@ box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18); } +.textareaInvalid:focus { + box-shadow: inset 0 0 0 2px rgba(220, 38, 38, 0.35); +} + +.jsonError { + flex: 0 0 auto; + padding: 8px 14px; + color: #b91c1c; + background: #fef2f2; + border-top: 1px solid #fecaca; + font-size: 13px; + line-height: 1.45; +} + .formBody { flex: 1 1 auto; min-height: 0; @@ -453,17 +516,23 @@ .envValue { flex: 1; min-width: 0; - font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace; + font-family: var(--font-mono); font-size: 12px; color: #111827; word-break: break-all; background: none; } -@media (max-width: 480px) { - .extensionGroupList { - grid-template-columns: 1fr; - } +.terminalPanel { + min-height: 420px; +} + +.terminalPanel .panelHeader { + border-bottom-color: #30363d; +} + +.terminalPanel .panelHeader p { + color: #8b949e; } .extensionHeader { @@ -807,51 +876,170 @@ @media (max-width: 768px) { .pageHeader { - align-items: flex-start; + align-items: center; padding-top: max(12px, env(safe-area-inset-top)); padding-bottom: 12px; padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); } + .pageTitle h1 { + font-size: 17px; + } + .layout { flex-direction: column; + min-height: 0; } .sidebar { width: 100%; + flex: 0 0 auto; flex-direction: row; + flex-wrap: nowrap; align-items: stretch; - gap: 8px; - padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right)); + gap: 6px; + padding: 8px max(10px, env(safe-area-inset-left)) 8px max(10px, env(safe-area-inset-right)); border-right: none; border-bottom: 1px solid #e5e7eb; overflow-x: auto; + overflow-y: hidden; -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; scrollbar-width: none; } .sidebar::-webkit-scrollbar { - height: 0; - width: 0; + display: none; } .navBtn { - flex: 1 1 0; - min-width: 0; + flex: 0 0 auto; + width: auto; + min-width: auto; justify-content: center; text-align: center; - padding: 12px 8px; + white-space: nowrap; + padding: 9px 14px; font-size: 13px; + scroll-snap-align: start; } .panes { flex: 1; min-height: 0; - padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right)); + padding: 10px max(10px, env(safe-area-inset-left)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right)); + } + + .panel { + border-radius: 10px; + } + + .panelHeader { + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px; + } + + .panelHeaderActions { + width: 100%; + justify-content: flex-end; + } + + .panelHeader h2 { + font-size: 15px; + } + + .panelHeader p { + font-size: 11px; + line-height: 1.45; + word-break: break-word; + } + + .markdownBody { + padding: 16px 14px; + } + + .envRow { + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 10px 12px; + } + + .envLabel { + width: auto; + } + + .mcpServerHead { + flex-direction: column; + align-items: stretch; + } + + .mcpServerBadges { + justify-content: flex-start; + } + + .titleRow { + flex-wrap: wrap; + align-items: flex-start; + } + + .skillActions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .extensionTitleRow { + flex-wrap: wrap; + align-items: flex-start; + gap: 6px; + } + + .terminalPanel { + min-height: min(420px, calc(var(--app-height) - 180px)); } .textarea { font-size: 12px; + padding: 12px; + } + + .formBody { + padding: 12px; + } + + .list { + padding: 6px; + } +} + +@media (max-width: 480px) { + .extensionGroupList { + grid-template-columns: 1fr; + } + + .navBtn { + padding: 8px 12px; + font-size: 12px; + } + + .primaryBtn, + .secondaryBtn, + .linkBtn { + height: 34px; + padding: 0 14px; + font-size: 13px; + } + + .panelHeaderActions { + flex-wrap: wrap; + } + + .panelHeaderActions .primaryBtn, + .panelHeaderActions .secondaryBtn { + flex: 1 1 auto; + min-width: 88px; } } diff --git a/frontend/src/routes/SettingsPage.tsx b/frontend/src/routes/SettingsPage.tsx index dbe0bc1..948331f 100644 --- a/frontend/src/routes/SettingsPage.tsx +++ b/frontend/src/routes/SettingsPage.tsx @@ -1,13 +1,18 @@ -import { useCallback, useEffect, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; 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 { McpToolInfo } from "../types/events"; +import { renderMarkdown } from "../utils/markdown"; import styles from "./SettingsPage.module.css"; -const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "env"]; +const WebTerminal = lazy(() => + import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })), +); + +const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "terminal", "env"]; function isNpmSkill(skill: SkillInfo): boolean { if (skill.toggleable === false) return true; @@ -28,6 +33,41 @@ function formatSkillMeta(skill: SkillInfo): string { } const SETTINGS_PANE_KEY = "sproutclaw-settings-pane"; +function validateModelsConfigJson(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed) { + return "JSON 不能为空"; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + const message = err instanceof SyntaxError ? err.message : String(err); + return `JSON 语法错误:${message}`; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return "模型配置必须是 JSON 对象"; + } + + const root = parsed as Record; + if (!("providers" in root)) { + return '缺少必填字段 "providers"'; + } + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) { + return '"providers" 必须是对象'; + } + + for (const [name, provider] of Object.entries(root.providers as Record)) { + if (!provider || typeof provider !== "object" || Array.isArray(provider)) { + return `"providers.${name}" 必须是对象`; + } + } + + return null; +} + function renderNameList(label: string, items: string[] | undefined) { if (!Array.isArray(items) || !items.length) return null; return ( @@ -56,6 +96,13 @@ function extensionTogglePath(extension: ExtensionInfo): string { return extension.resolvedPath || extension.path || ""; } +// Only local extensions can be enabled/disabled (by moving the directory between +// extensions/ and extensions-disabled/). npm extensions are not managed here. +function extensionToggleable(extension: ExtensionInfo): boolean { + if (extension.toggleable === false) return false; + return extensionCategory(extension) === "local"; +} + function ExtensionCard({ extension, toggling, @@ -67,10 +114,11 @@ function ExtensionCard({ disabled: boolean; onToggle: (enabled: boolean) => void; }) { - const meta = - extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · "); + const meta = [extension.kind, extension.scope, extension.source].filter(Boolean).join(" · "); const enabled = extension.enabled !== false; + const toggleable = extensionToggleable(extension); const togglePath = extensionTogglePath(extension); + const versionLabel = extension.version ? `v${extension.version}` : ""; const counts = ( [ ["命令", extension.commands?.length || 0], @@ -89,19 +137,23 @@ function ExtensionCard({
{extension.name || extension.path || "未命名"}
- + {toggleable ? ( + + ) : versionLabel ? ( +
{versionLabel}
+ ) : null}
{meta ?
{meta}
: null}
@@ -248,6 +300,7 @@ export function SettingsPage() { const [activePane, setActivePane] = useState("prompt"); const [systemPrompt, setSystemPrompt] = useState(""); const [systemPromptPath, setSystemPromptPath] = useState(""); + const [systemPromptPreview, setSystemPromptPreview] = useState(false); const [modelsConfig, setModelsConfig] = useState(""); const [modelsConfigPath, setModelsConfigPath] = useState(""); const [userAvatarUrl, setUserAvatarUrl] = useState(""); @@ -269,6 +322,9 @@ export function SettingsPage() { const [togglingExtensionPath, setTogglingExtensionPath] = useState(null); const [envInfo, setEnvInfo] = useState(null); const [loadingEnv, setLoadingEnv] = useState(false); + const [terminalPlatform, setTerminalPlatform] = useState(""); + const [terminalRequested, setTerminalRequested] = useState(false); + const sidebarRef = useRef(null); useEffect(() => { let initial: SettingsPaneId = "prompt"; @@ -290,6 +346,9 @@ export function SettingsPage() { const showPane = (paneId: SettingsPaneId) => { setActivePane(paneId); + if (paneId !== "prompt") { + setSystemPromptPreview(false); + } try { sessionStorage.setItem(SETTINGS_PANE_KEY, paneId); } catch { @@ -338,6 +397,17 @@ export function SettingsPage() { } }; + const loadTerminalInfo = async () => { + try { + const data = await settingsApi.fetchTerminalInfo(); + if (data.platform) { + setTerminalPlatform(data.platform); + } + } catch (err) { + setStatus(`加载终端信息失败: ${err instanceof Error ? err.message : String(err)}`, "error"); + } + }; + const loadEnvInfo = async () => { setLoadingEnv(true); try { @@ -354,6 +424,17 @@ export function SettingsPage() { if (activePane === "env" && !envInfo && !loadingEnv) { void loadEnvInfo(); } + if (activePane === "terminal" && !terminalPlatform) { + void loadTerminalInfo(); + } + if (activePane === "terminal") { + setTerminalRequested(true); + } + }, [activePane]); + + useEffect(() => { + const activeTab = sidebarRef.current?.querySelector('[aria-selected="true"]'); + activeTab?.scrollIntoView({ behavior: "smooth", inline: "nearest", block: "nearest" }); }, [activePane]); const toggleSkillEnabled = async (path: string, enabled: boolean) => { @@ -471,6 +552,12 @@ export function SettingsPage() { }; const saveModelsConfig = async () => { + const validationError = validateModelsConfigJson(modelsConfig); + if (validationError) { + setStatus(validationError, "error"); + return; + } + setSavingModels(true); setStatus("保存中..."); try { @@ -521,6 +608,11 @@ export function SettingsPage() { const toggleableSkills = skills.filter(isSkillToggleable); const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length; const enabledExtensionCount = extensions.filter((extension) => extension.enabled !== false).length; + const modelsConfigError = useMemo(() => validateModelsConfigJson(modelsConfig), [modelsConfig]); + const systemPromptHtml = useMemo( + () => (systemPromptPreview ? renderMarkdown(systemPrompt) : ""), + [systemPromptPreview, systemPrompt], + ); const statusClass = statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : ""; @@ -541,7 +633,7 @@ export function SettingsPage() {
-
- +
+ + +
-