first commit
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
backend/sproutclaw-web
|
backend/sproutclaw-web
|
||||||
backend/sproutclaw-web.exe
|
backend/sproutclaw-web.exe
|
||||||
|
backend/data/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/dist-desktop/
|
frontend/dist-desktop/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"sproutclaw-web/internal/config"
|
"sproutclaw-web/internal/config"
|
||||||
@@ -18,11 +17,10 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.New()
|
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)
|
// Database (stores in backend/data)
|
||||||
dataDir := filepath.Join(cfg.AgentDir, "data", "sproutclaw-web")
|
database, err := db.Init(cfg.DataDir)
|
||||||
database, err := db.Init(dataDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("db init: %v", err)
|
log.Fatalf("db init: %v", err)
|
||||||
}
|
}
|
||||||
@@ -30,7 +28,7 @@ func main() {
|
|||||||
log.Printf("[sproutclaw-web] db: %s", database.DbPath)
|
log.Printf("[sproutclaw-web] db: %s", database.DbPath)
|
||||||
|
|
||||||
// pi CLI RPC client
|
// 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 {
|
if code != 0 {
|
||||||
log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code)
|
log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -56,13 +54,14 @@ func main() {
|
|||||||
handlers.RegisterCommands(api, cfg)
|
handlers.RegisterCommands(api, cfg)
|
||||||
handlers.RegisterWebuiConfig(api, database)
|
handlers.RegisterWebuiConfig(api, database)
|
||||||
handlers.RegisterSettings(api, cfg, database, piClient)
|
handlers.RegisterSettings(api, cfg, database, piClient)
|
||||||
|
handlers.RegisterTerminal(api, cfg)
|
||||||
handlers.RegisterExtensionUI(api, piClient)
|
handlers.RegisterExtensionUI(api, piClient)
|
||||||
|
|
||||||
// Static files (production: frontend/dist/)
|
// Static files (production: frontend/dist/)
|
||||||
static.Register(r, cfg.FrontendDist)
|
static.Register(r, cfg.FrontendDist)
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", cfg.Port)
|
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 {
|
if err := r.Run(addr); err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
module sproutclaw-web
|
module sproutclaw-web
|
||||||
|
|
||||||
go 1.23
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Kodecable/crosspty v1.0.0
|
||||||
github.com/gin-gonic/gin v1.10.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
|
modernc.org/sqlite v1.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // 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/crypto v0.23.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||||
golang.org/x/net v0.25.0 // 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
|
golang.org/x/text v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -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 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
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/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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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=
|
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/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
AgentDir string
|
AgentDir string
|
||||||
|
RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录)
|
||||||
|
DataDir string // sproutclaw-web 自己的数据目录(SQLite 等)
|
||||||
PiCmd string // pi CLI 启动命令(完整路径)
|
PiCmd string // pi CLI 启动命令(完整路径)
|
||||||
PiArgs []string
|
PiArgs []string
|
||||||
|
|
||||||
@@ -29,11 +32,13 @@ type Config struct {
|
|||||||
func New() *Config {
|
func New() *Config {
|
||||||
port := flag.Int("port", 19133, "HTTP port")
|
port := flag.Int("port", 19133, "HTTP port")
|
||||||
agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if *agentDir == "" {
|
if *agentDir == "" {
|
||||||
// 默认值:当前目录的 .pi/agent
|
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
*agentDir = filepath.Join(cwd, ".pi", "agent")
|
*agentDir = filepath.Join(cwd, ".pi", "agent")
|
||||||
}
|
}
|
||||||
@@ -41,9 +46,34 @@ func New() *Config {
|
|||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: *port,
|
Port: *port,
|
||||||
AgentDir: *agentDir,
|
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()
|
cfg.derivePaths()
|
||||||
|
|
||||||
|
// Override frontend dist if explicitly specified
|
||||||
|
if *frontendDist != "" {
|
||||||
|
cfg.FrontendDist = *frontendDist
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate images
|
|
||||||
if len(req.Images) > 0 {
|
if len(req.Images) > 0 {
|
||||||
if err := services.ValidateImages(req.Images); err != nil {
|
if err := services.ValidateImages(req.Images); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
@@ -39,51 +38,40 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try slash command dispatch
|
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
|
||||||
result := services.TrySlashDispatch(req.Message)
|
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
|
||||||
if result.Handled {
|
req.StreamingBehavior = ""
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular prompt
|
// Submit prompt; agent output streams back over SSE.
|
||||||
if err := piClient.SubmitPrompt(req); err != nil {
|
if err := piClient.SubmitPrompt(req); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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 {
|
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req models.SteerRequest
|
var req models.ChatRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := piClient.SubmitPrompt(models.ChatRequest{
|
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||||
Message: req.Message,
|
Type: "steer",
|
||||||
StreamingBehavior: "steer",
|
Message: req.Message,
|
||||||
|
Images: req.Images,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "steer 失败")})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.StreamingBehavior = "follow_up"
|
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||||
if err := piClient.SubmitPrompt(req); err != nil {
|
Type: "follow_up",
|
||||||
|
Message: req.Message,
|
||||||
|
Images: req.Images,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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})
|
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,21 +108,24 @@ func handleBash(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||||
Type: "bash",
|
Type: "bash",
|
||||||
Args: req.Command,
|
Command: req.Command,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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 {
|
func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort"})
|
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort"}); err != nil {
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,8 +135,7 @@ func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
|
|
||||||
func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"})
|
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"}); err != nil {
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,16 +145,16 @@ func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
|
|
||||||
func handleMessages(piClient *rpc.Client) gin.HandlerFunc {
|
func handleMessages(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req models.MessagesRequest
|
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snap := piClient.GetSnapshot()
|
if !resp.Success {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||||
"sessionFile": snap.SessionFile,
|
return
|
||||||
"events": snap.ReplayEvents,
|
}
|
||||||
})
|
c.JSON(http.StatusOK, jsonRaw(resp.Data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +171,7 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan []byte, 64)
|
ch := make(chan []byte, 256)
|
||||||
client := &rpc.SSEClient{
|
client := &rpc.SSEClient{
|
||||||
Ch: ch,
|
Ch: ch,
|
||||||
Done: c.Request.Context().Done(),
|
Done: c.Request.Context().Done(),
|
||||||
@@ -181,19 +179,19 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
piClient.RegisterSSEClient(client)
|
piClient.RegisterSSEClient(client)
|
||||||
defer piClient.UnregisterSSEClient(client)
|
defer piClient.UnregisterSSEClient(client)
|
||||||
|
|
||||||
// send initial keepalive
|
// initial comment to open the stream
|
||||||
_, _ = io.WriteString(c.Writer, ": keepalive\n\n")
|
_, _ = io.WriteString(c.Writer, ": connected\n\n")
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.Request.Context().Done():
|
case <-c.Request.Context().Done():
|
||||||
return
|
return
|
||||||
case raw, ok := <-ch:
|
case frame, ok := <-ch:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
frame := rpc.FormatSSEEvent(raw)
|
// frame is already a fully-formatted "data: ...\n\n" SSE frame
|
||||||
if _, err := c.Writer.Write(frame); err != nil {
|
if _, err := c.Writer.Write(frame); err != nil {
|
||||||
return
|
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 {
|
func jsonRaw(raw json.RawMessage) any {
|
||||||
if raw == nil {
|
if len(raw) == 0 {
|
||||||
return nil
|
return gin.H{}
|
||||||
}
|
}
|
||||||
var v any
|
var v any
|
||||||
_ = json.Unmarshal(raw, &v)
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return gin.H{}
|
||||||
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orDefault(s, fallback string) string {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"sproutclaw-web/internal/config"
|
"sproutclaw-web/internal/config"
|
||||||
@@ -13,27 +11,23 @@ import (
|
|||||||
|
|
||||||
// RegisterModels registers model-related routes.
|
// RegisterModels registers model-related routes.
|
||||||
func RegisterModels(r *gin.RouterGroup, cfg *config.Config, piClient *rpc.Client) {
|
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("/model", handleSetModel(piClient))
|
||||||
r.POST("/thinking", handleSetThinking(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) {
|
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 {
|
if err != nil {
|
||||||
// fallback: read models.json
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
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)
|
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||||
Type: "set_thinking",
|
Type: "set_thinking_level",
|
||||||
Budget: req.Budget,
|
Level: req.Level,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -47,7 +48,24 @@ func handleNewSession(piClient *rpc.Client) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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"})
|
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lines, err := services.ReadSessionMessages(req.Path)
|
sw, err := piClient.SendCmd(models.RPCCommand{
|
||||||
if err != nil {
|
Type: "switch_session",
|
||||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
SessionPath: req.Path,
|
||||||
return
|
CwdOverride: cfg.RepoRoot,
|
||||||
}
|
|
||||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
|
||||||
Type: "switch_session",
|
|
||||||
Path: req.Path,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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"})
|
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
sw, err := piClient.SendCmd(models.RPCCommand{
|
||||||
Type: "switch_session",
|
Type: "switch_session",
|
||||||
Path: req.Path,
|
SessionPath: req.Path,
|
||||||
|
CwdOverride: cfg.RepoRoot,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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 {
|
func handleSessionState(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
snap := piClient.GetSnapshot()
|
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"})
|
||||||
c.JSON(http.StatusOK, models.SessionStateResponse{
|
if err != nil {
|
||||||
IsStreaming: snap.IsStreaming,
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
SessionFile: snap.SessionFile,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"sproutclaw-web/internal/config"
|
"sproutclaw-web/internal/config"
|
||||||
@@ -17,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// RegisterSettings registers all settings-related routes.
|
// RegisterSettings registers all settings-related routes.
|
||||||
func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) {
|
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/skills/toggle", handleToggleSkill(cfg, piClient))
|
||||||
r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient))
|
r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient))
|
||||||
r.POST("/settings/mcp/server/toggle", handleToggleMCPServer(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))
|
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) {
|
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)
|
systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile)
|
||||||
modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile)
|
modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile)
|
||||||
|
userAvatar, _ := database.GetConfig("userAvatarUrl")
|
||||||
|
agentAvatar, _ := database.GetConfig("agentAvatarUrl")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.SettingsResponse{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
Skills: skills,
|
"systemPrompt": systemPrompt,
|
||||||
Extensions: extensions,
|
"systemPromptPath": cfg.SystemPromptFile,
|
||||||
MCPServers: mcpServers,
|
"modelsConfig": modelsConfig,
|
||||||
SystemPrompt: systemPrompt,
|
"modelsConfigPath": cfg.ModelsConfigFile,
|
||||||
ModelsConfig: modelsConfig,
|
"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 {
|
func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req models.ToggleRequest
|
var req models.SkillToggleRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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 {
|
func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req models.ToggleRequest
|
var req models.ExtensionToggleRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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 {
|
if err := services.ToggleMCPServer(cfg.McpConfigFile, req.Server, req.Enabled); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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 {
|
if err := services.ToggleMCPTool(cfg.McpConfigFile, req.Server, req.Tool, req.Enabled); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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 {
|
func handleReload(piClient *rpc.Client) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
reloadAgent(piClient)
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadAgent(piClient)
|
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()})
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.UserAvatar != "" {
|
userURL := normalizeAvatarURL(req.UserAvatarURL)
|
||||||
_ = database.SetConfig("userAvatar", req.UserAvatar)
|
agentURL := normalizeAvatarURL(req.AgentAvatarURL)
|
||||||
}
|
_ = database.SetConfig("userAvatarUrl", userURL)
|
||||||
if req.AssistantAvatar != "" {
|
_ = database.SetConfig("agentAvatarUrl", agentURL)
|
||||||
_ = database.SetConfig("assistantAvatar", req.AssistantAvatar)
|
c.JSON(http.StatusOK, gin.H{"ok": true, "userAvatarUrl": userURL, "agentAvatarUrl": agentURL})
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
lanAddrs := getLANAddresses()
|
cwd, _ := os.Getwd()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"platform": runtime.GOOS,
|
"nodeVersion": runtime.Version(),
|
||||||
"arch": runtime.GOARCH,
|
"platform": runtime.GOOS,
|
||||||
"hostname": hostname,
|
"arch": runtime.GOARCH,
|
||||||
"version": runtime.Version(),
|
"hostname": hostname,
|
||||||
"port": cfg.Port,
|
"pid": os.Getpid(),
|
||||||
"lan": lanAddrs,
|
"cwd": cwd,
|
||||||
|
"port": cfg.Port,
|
||||||
|
"repoRoot": cfg.RepoRoot,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,31 +216,3 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
|||||||
func reloadAgent(piClient *rpc.Client) {
|
func reloadAgent(piClient *rpc.Client) {
|
||||||
_, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"})
|
_, _ = 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
|
|
||||||
}
|
|
||||||
|
|||||||
44
backend/internal/handlers/terminal.go
Normal file
44
backend/internal/handlers/terminal.go
Normal file
@@ -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()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,11 @@ func RegisterWebuiConfig(r *gin.RouterGroup, database *db.DB) {
|
|||||||
|
|
||||||
func handleGetAvatars(database *db.DB) gin.HandlerFunc {
|
func handleGetAvatars(database *db.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
user, _ := database.GetConfig("userAvatar")
|
user, _ := database.GetConfig("userAvatarUrl")
|
||||||
assistant, _ := database.GetConfig("assistantAvatar")
|
agent, _ := database.GetConfig("agentAvatarUrl")
|
||||||
c.JSON(http.StatusOK, models.AvatarsResponse{
|
c.JSON(http.StatusOK, models.AvatarsResponse{
|
||||||
UserAvatar: normalizeAvatarURL(user),
|
UserAvatarURL: normalizeAvatarURL(user),
|
||||||
AssistantAvatar: normalizeAvatarURL(assistant),
|
AgentAvatarURL: normalizeAvatarURL(agent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ func CORS() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
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" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(204)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,29 +7,33 @@ import "encoding/json"
|
|||||||
type RPCCommand struct {
|
type RPCCommand struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
// prompt fields
|
// prompt / steer / follow_up
|
||||||
Text string `json:"text,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Images []ChatImage `json:"images,omitempty"`
|
Images []ChatImage `json:"images,omitempty"`
|
||||||
// model / thinking fields
|
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
||||||
Provider string `json:"provider,omitempty"`
|
// model / thinking
|
||||||
ModelID string `json:"modelId,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
Budget *int `json:"budget,omitempty"`
|
ModelID string `json:"modelId,omitempty"`
|
||||||
// session fields
|
Level json.RawMessage `json:"level,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
// session
|
||||||
Name string `json:"name,omitempty"`
|
SessionPath string `json:"sessionPath,omitempty"`
|
||||||
// streaming behaviour
|
CwdOverride string `json:"cwdOverride,omitempty"`
|
||||||
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
// slash dispatch passthrough
|
// bash
|
||||||
Args string `json:"args,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
// extension ui
|
ExcludeFromContext bool `json:"excludeFromContext,omitempty"`
|
||||||
Response json.RawMessage `json:"response,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPCResponse mirrors the pi CLI response frame:
|
||||||
|
//
|
||||||
|
// {"type":"response","id":"req_1","command":"get_state","success":true,"data":{...},"error":"..."}
|
||||||
type RPCResponse struct {
|
type RPCResponse struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Error string `json:"error,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
|
// Raw event line forwarded to SSE clients as-is
|
||||||
@@ -130,7 +134,7 @@ type SetModelRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SetThinkingRequest struct {
|
type SetThinkingRequest struct {
|
||||||
Budget *int `json:"budget"` // nil = disable
|
Level json.RawMessage `json:"level"` // string ("high"/"off"...) or number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||||
@@ -148,8 +152,8 @@ type CommandsResponse struct {
|
|||||||
// ── WebUI Config ──────────────────────────────────────────────────────────────
|
// ── WebUI Config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type AvatarsResponse struct {
|
type AvatarsResponse struct {
|
||||||
UserAvatar string `json:"userAvatar,omitempty"`
|
UserAvatarURL string `json:"userAvatarUrl"`
|
||||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
AgentAvatarURL string `json:"agentAvatarUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebuiConfigResponse struct {
|
type WebuiConfigResponse struct {
|
||||||
@@ -171,46 +175,53 @@ type WebuiConfigDeleteRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||||
|
// Field names mirror the frontend SettingsData contract (frontend/src/types/events.ts).
|
||||||
|
|
||||||
type SkillInfo struct {
|
type SkillInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Path string `json:"path"`
|
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 {
|
type ExtensionInfo struct {
|
||||||
ID string `json:"id"`
|
Name string `json:"name"`
|
||||||
Name string `json:"name"`
|
Path string `json:"path"`
|
||||||
Description string `json:"description,omitempty"`
|
Category string `json:"category,omitempty"` // local | npm
|
||||||
Version string `json:"version,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Version string `json:"version,omitempty"`
|
||||||
Source string `json:"source"` // local | npm
|
Enabled bool `json:"enabled"`
|
||||||
Commands []string `json:"commands,omitempty"`
|
Toggleable bool `json:"toggleable"` // only local extensions can be toggled
|
||||||
|
Commands []string `json:"commands,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MCPTool struct {
|
type MCPTool struct {
|
||||||
|
Server string `json:"server,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Disabled bool `json:"disabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MCPServer struct {
|
type MCPServer struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Disabled bool `json:"disabled"`
|
Configured bool `json:"configured"`
|
||||||
Tools []MCPTool `json:"tools,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
|
ToolCount int `json:"toolCount"`
|
||||||
|
EnabledToolCount int `json:"enabledToolCount"`
|
||||||
|
Tools []MCPTool `json:"tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsResponse struct {
|
// SkillToggleRequest / ExtensionToggleRequest identify the target by path.
|
||||||
Skills []SkillInfo `json:"skills"`
|
type SkillToggleRequest struct {
|
||||||
Extensions []ExtensionInfo `json:"extensions"`
|
Path string `json:"path"`
|
||||||
MCPServers []MCPServer `json:"mcpServers"`
|
Enabled bool `json:"enabled"`
|
||||||
SystemPrompt string `json:"systemPrompt,omitempty"`
|
|
||||||
ModelsConfig string `json:"modelsConfig,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToggleRequest struct {
|
type ExtensionToggleRequest struct {
|
||||||
ID string `json:"id"`
|
Path string `json:"path"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,24 +236,19 @@ type MCPToolToggleRequest struct {
|
|||||||
Enabled bool `json:"enabled"`
|
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 {
|
type SystemPromptRequest struct {
|
||||||
Content string `json:"content"`
|
SystemPrompt *string `json:"systemPrompt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelsConfigRequest struct {
|
type ModelsConfigRequest struct {
|
||||||
Content string `json:"content"`
|
ModelsConfig *string `json:"modelsConfig"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AvatarsSetRequest struct {
|
type AvatarsSetRequest struct {
|
||||||
UserAvatar string `json:"userAvatar,omitempty"`
|
UserAvatarURL string `json:"userAvatarUrl"`
|
||||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
AgentAvatarURL string `json:"agentAvatarUrl"`
|
||||||
}
|
|
||||||
|
|
||||||
type EnvironmentResponse struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Arch string `json:"arch"`
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension UI ──────────────────────────────────────────────────────────────
|
// ── Extension UI ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"sproutclaw-web/internal/models"
|
"sproutclaw-web/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,10 +21,29 @@ const (
|
|||||||
promptTimeout = 30 * time.Second
|
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.
|
// SSEClient represents a connected browser SSE client.
|
||||||
type SSEClient struct {
|
type SSEClient struct {
|
||||||
Ch chan []byte
|
Ch chan []byte
|
||||||
Done <-chan struct{}
|
Done <-chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSnapshot holds a snapshot of the current pi agent run state.
|
// 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
|
// Client manages a pi CLI subprocess and bridges its JSON-RPC protocol
|
||||||
// to HTTP handlers and SSE clients.
|
// to HTTP handlers and SSE clients.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
pending map[string]chan models.RPCResponse
|
pending map[string]chan models.RPCResponse
|
||||||
sseClients map[*SSEClient]struct{}
|
sseClients map[*SSEClient]struct{}
|
||||||
replay []json.RawMessage // events in current agent run
|
replay []json.RawMessage // events in current agent run
|
||||||
isRunning bool
|
isRunning bool
|
||||||
sessionFile string
|
sessionFile string
|
||||||
|
reqMu sync.Mutex
|
||||||
|
reqCounter int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient starts the pi CLI subprocess and begins reading its stdout.
|
// NewClient starts the pi CLI subprocess and begins reading its stdout.
|
||||||
// onExit is called with the exit code when the subprocess terminates.
|
// repoRoot is used as the subprocess working directory; onExit is called
|
||||||
func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) (*Client, error) {
|
// with the exit code when the subprocess terminates.
|
||||||
|
func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) {
|
||||||
if piCmd == "" {
|
if piCmd == "" {
|
||||||
return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
|
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 := exec.Command(piCmd, args...)
|
||||||
cmd.Dir = agentDir
|
cmd.Dir = repoRoot
|
||||||
cmd.Env = append(cmd.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
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()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,12 +92,15 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int))
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||||
}
|
}
|
||||||
cmd.Stderr = log.Writer()
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("start pi cli: %w", err)
|
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{
|
c := &Client{
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
stdin: stdin,
|
stdin: stdin,
|
||||||
@@ -86,82 +115,103 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int))
|
|||||||
if cmd.ProcessState != nil {
|
if cmd.ProcessState != nil {
|
||||||
code = cmd.ProcessState.ExitCode()
|
code = cmd.ProcessState.ExitCode()
|
||||||
}
|
}
|
||||||
|
log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code)
|
||||||
onExit(code)
|
onExit(code)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return c, nil
|
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.
|
// readLoop reads stdout line-by-line and dispatches messages.
|
||||||
func (c *Client) readLoop(r io.Reader) {
|
func (c *Client) readLoop(r io.Reader) {
|
||||||
scanner := bufio.NewScanner(r)
|
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() {
|
for scanner.Scan() {
|
||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
if len(line) == 0 {
|
if len(line) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.dispatch(line)
|
c.dispatch(append([]byte(nil), line...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatch parses one JSON line from stdout and routes it.
|
// dispatch parses one JSON line from stdout and routes it.
|
||||||
func (c *Client) dispatch(raw []byte) {
|
func (c *Client) dispatch(raw []byte) {
|
||||||
var msg struct {
|
var head struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
if err := json.Unmarshal(raw, &head); err != nil {
|
||||||
return
|
return // ignore non-JSON lines
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
// Response frame: resolve the pending request.
|
||||||
defer c.mu.Unlock()
|
if head.Type == "response" && head.ID != "" {
|
||||||
|
|
||||||
switch msg.Type {
|
|
||||||
case "response":
|
|
||||||
var resp models.RPCResponse
|
var resp models.RPCResponse
|
||||||
if err := json.Unmarshal(raw, &resp); err == nil {
|
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)
|
delete(c.pending, resp.ID)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok {
|
||||||
ch <- resp
|
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":
|
case "agent_start":
|
||||||
c.isRunning = true
|
c.isRunning = true
|
||||||
c.replay = nil
|
c.replay = []json.RawMessage{raw}
|
||||||
// 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)
|
|
||||||
|
|
||||||
case "agent_end":
|
case "agent_end":
|
||||||
|
c.replay = append(c.replay, raw)
|
||||||
c.isRunning = false
|
c.isRunning = false
|
||||||
rawCopy := append([]byte(nil), raw...)
|
c.replay = nil
|
||||||
c.replay = append(c.replay, rawCopy)
|
|
||||||
c.broadcastLocked(rawCopy)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
rawCopy := append([]byte(nil), raw...)
|
if c.isRunning && bufferedEventTypes[typ] {
|
||||||
if c.isRunning {
|
c.replay = append(c.replay, raw)
|
||||||
c.replay = append(c.replay, rawCopy)
|
|
||||||
}
|
}
|
||||||
c.broadcastLocked(rawCopy)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu.
|
// broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu.
|
||||||
func (c *Client) broadcastLocked(raw []byte) {
|
func (c *Client) broadcastLocked(raw []byte) {
|
||||||
|
frame := formatSSEData(raw)
|
||||||
for client := range c.sseClients {
|
for client := range c.sseClients {
|
||||||
select {
|
select {
|
||||||
case client.Ch <- raw:
|
case client.Ch <- frame:
|
||||||
default:
|
default:
|
||||||
// slow client: drop
|
// slow client: drop
|
||||||
}
|
}
|
||||||
@@ -176,15 +226,13 @@ func (c *Client) send(cmd models.RPCCommand) error {
|
|||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
_, err = fmt.Fprintf(c.stdin, "%s\n", b)
|
_, err = c.stdin.Write(append(b, '\n'))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendCmd sends a command and waits for the matching response.
|
// SendCmd sends a command and waits for the matching response.
|
||||||
func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
||||||
if cmd.ID == "" {
|
cmd.ID = c.nextReqID()
|
||||||
cmd.ID = uuid.New().String()
|
|
||||||
}
|
|
||||||
ch := make(chan models.RPCResponse, 1)
|
ch := make(chan models.RPCResponse, 1)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -205,20 +253,50 @@ func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.pending, cmd.ID)
|
delete(c.pending, cmd.ID)
|
||||||
c.mu.Unlock()
|
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 {
|
func (c *Client) SubmitPrompt(req models.ChatRequest) error {
|
||||||
|
id := c.nextReqID()
|
||||||
cmd := models.RPCCommand{
|
cmd := models.RPCCommand{
|
||||||
Type: "prompt",
|
Type: "prompt",
|
||||||
ID: uuid.New().String(),
|
ID: id,
|
||||||
Text: req.Message,
|
Message: req.Message,
|
||||||
Images: req.Images,
|
Images: req.Images,
|
||||||
StreamingBehavior: req.StreamingBehavior,
|
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.
|
// 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) {
|
func (c *Client) RegisterSSEClient(client *SSEClient) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.sseClients[client] = struct{}{}
|
c.sseClients[client] = struct{}{}
|
||||||
// replay current run events
|
|
||||||
replay := make([]json.RawMessage, len(c.replay))
|
replay := make([]json.RawMessage, len(c.replay))
|
||||||
copy(replay, c.replay)
|
copy(replay, c.replay)
|
||||||
|
isStreaming := c.isRunning
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
// send replay in a goroutine so we don't hold the lock
|
connected := map[string]any{
|
||||||
go func() {
|
"type": "connected",
|
||||||
for _, ev := range replay {
|
"isStreaming": isStreaming,
|
||||||
select {
|
"replay": replay,
|
||||||
case client.Ch <- ev:
|
}
|
||||||
case <-client.Done:
|
b, _ := json.Marshal(connected)
|
||||||
return
|
select {
|
||||||
}
|
case client.Ch <- formatSSEData(b):
|
||||||
}
|
case <-client.Done:
|
||||||
}()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterSSEClient removes a client from the broadcast set.
|
// 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.
|
// 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 {
|
func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error {
|
||||||
return c.send(models.RPCCommand{
|
merged := map[string]any{"type": "extension_ui_response", "id": id}
|
||||||
Type: "extension_ui_response",
|
var extra map[string]any
|
||||||
ID: id,
|
if json.Unmarshal(response, &extra) == nil {
|
||||||
Response: response,
|
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.
|
// 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.
|
// IsRunning returns true when the agent is actively streaming.
|
||||||
func (c *Client) IsRunning() bool {
|
func (c *Client) IsRunning() bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -296,9 +375,8 @@ func (c *Client) IsRunning() bool {
|
|||||||
return c.isRunning
|
return c.isRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatSSEEvent formats a raw JSON payload as an SSE data line.
|
// formatSSEData wraps a raw JSON payload as an SSE "data:" frame.
|
||||||
func FormatSSEEvent(raw []byte) []byte {
|
func formatSSEData(raw []byte) []byte {
|
||||||
// strip newlines so the SSE frame stays on one logical line
|
trimmed := strings.TrimRight(string(raw), "\r\n")
|
||||||
trimmed := strings.TrimRight(string(raw), "\n\r")
|
|
||||||
return []byte("data: " + trimmed + "\n\n")
|
return []byte("data: " + trimmed + "\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,43 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sproutclaw-web/internal/models"
|
"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 {
|
func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||||
extDir := filepath.Join(agentDir, "extensions")
|
var exts []models.ExtensionInfo
|
||||||
entries, err := os.ReadDir(extDir)
|
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 {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var exts []models.ExtensionInfo
|
var exts []models.ExtensionInfo
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
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)
|
exts = append(exts, ext)
|
||||||
}
|
}
|
||||||
return exts
|
return exts
|
||||||
@@ -30,13 +46,11 @@ func ListExtensions(agentDir string) []models.ExtensionInfo {
|
|||||||
|
|
||||||
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||||
info := models.ExtensionInfo{
|
info := models.ExtensionInfo{
|
||||||
ID: id,
|
Name: id,
|
||||||
Name: id,
|
Path: path,
|
||||||
Enabled: true, // assume enabled unless configured otherwise
|
|
||||||
Source: "local",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try package.json for version/name
|
// try package.json for name/version
|
||||||
pkgPath := filepath.Join(path, "package.json")
|
pkgPath := filepath.Join(path, "package.json")
|
||||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||||
var pkg struct {
|
var pkg struct {
|
||||||
@@ -48,58 +62,149 @@ func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
|||||||
info.Name = pkg.Name
|
info.Name = pkg.Name
|
||||||
}
|
}
|
||||||
info.Version = pkg.Version
|
info.Version = pkg.Version
|
||||||
// detect npm packages
|
|
||||||
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
|
|
||||||
info.Source = "npm"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleExtension is a placeholder – full implementation requires settings.json management.
|
func listNpmExtensions(agentDir string) []models.ExtensionInfo {
|
||||||
func ToggleExtension(agentDir, id string, enable bool) error {
|
sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json"))
|
||||||
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
|
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)
|
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 {
|
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 err
|
||||||
}
|
}
|
||||||
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
return os.Rename(src, dst)
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/internal/services/extensions_test.go
Normal file
56
backend/internal/services/extensions_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -7,68 +7,80 @@ import (
|
|||||||
"sproutclaw-web/internal/models"
|
"sproutclaw-web/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mcpConfig struct {
|
// mcp.json is shared with pi-mcp-adapter, which only reads the top-level
|
||||||
MCPServers map[string]any `json:"mcpServers"`
|
// "mcpServers", "imports" and "settings" keys. To disable a server we move its
|
||||||
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
|
// full definition into the adapter-ignored "mcpServersDisabled" object, so the
|
||||||
ExcludeTools []string `json:"excludeTools,omitempty"`
|
// 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.
|
// 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 {
|
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
|
||||||
cfg := loadMCPConfig(mcpConfigPath)
|
raw := loadRawMCP(mcpConfigPath)
|
||||||
cache := loadMCPCache(mcpCachePath)
|
cache := loadMCPCache(mcpCachePath)
|
||||||
|
|
||||||
disabledSet := map[string]bool{}
|
|
||||||
for _, s := range cfg.MCPServersDisabled {
|
|
||||||
disabledSet[s] = true
|
|
||||||
}
|
|
||||||
excludeSet := map[string]bool{}
|
excludeSet := map[string]bool{}
|
||||||
for _, t := range cfg.ExcludeTools {
|
for _, t := range toStringSlice(raw["excludeTools"]) {
|
||||||
excludeSet[t] = true
|
excludeSet[t] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var servers []models.MCPServer
|
var servers []models.MCPServer
|
||||||
for name := range cfg.MCPServers {
|
appendServer := func(name string, enabled bool) {
|
||||||
srv := models.MCPServer{
|
srv := models.MCPServer{Name: name, Configured: true, Enabled: enabled}
|
||||||
Name: name,
|
for _, t := range cache[name] {
|
||||||
Disabled: disabledSet[name],
|
toolEnabled := !(excludeSet[name+"/"+t] || excludeSet[t])
|
||||||
}
|
srv.Tools = append(srv.Tools, models.MCPTool{Server: name, Name: t, Enabled: toolEnabled})
|
||||||
// add tools from cache
|
srv.ToolCount++
|
||||||
if toolNames, ok := cache[name]; ok {
|
if toolEnabled {
|
||||||
for _, t := range toolNames {
|
srv.EnabledToolCount++
|
||||||
srv.Tools = append(srv.Tools, models.MCPTool{
|
|
||||||
Name: t,
|
|
||||||
Disabled: excludeSet[name+"/"+t] || excludeSet[t],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
servers = append(servers, srv)
|
servers = append(servers, srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for name := range asMap(raw["mcpServers"]) {
|
||||||
|
appendServer(name, true)
|
||||||
|
}
|
||||||
|
for name := range asMap(raw["mcpServersDisabled"]) {
|
||||||
|
appendServer(name, false)
|
||||||
|
}
|
||||||
return servers
|
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 {
|
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
|
||||||
cfg := loadMCPConfig(mcpConfigPath)
|
raw := loadRawMCP(mcpConfigPath)
|
||||||
set := map[string]bool{}
|
servers := asMap(raw["mcpServers"])
|
||||||
for _, s := range cfg.MCPServersDisabled {
|
disabled := asMap(raw["mcpServersDisabled"])
|
||||||
set[s] = true
|
|
||||||
}
|
|
||||||
if enable {
|
if enable {
|
||||||
delete(set, serverName)
|
if def, ok := disabled[serverName]; ok {
|
||||||
|
servers[serverName] = def
|
||||||
|
delete(disabled, serverName)
|
||||||
|
}
|
||||||
} else {
|
} 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 {
|
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
|
||||||
cfg := loadMCPConfig(mcpConfigPath)
|
raw := loadRawMCP(mcpConfigPath)
|
||||||
key := serverName + "/" + toolName
|
key := serverName + "/" + toolName
|
||||||
|
|
||||||
set := map[string]bool{}
|
set := map[string]bool{}
|
||||||
for _, t := range cfg.ExcludeTools {
|
for _, t := range toStringSlice(raw["excludeTools"]) {
|
||||||
set[t] = true
|
set[t] = true
|
||||||
}
|
}
|
||||||
if enable {
|
if enable {
|
||||||
@@ -76,31 +88,68 @@ func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) erro
|
|||||||
} else {
|
} else {
|
||||||
set[key] = true
|
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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mcpConfig{MCPServers: map[string]any{}}
|
return map[string]any{}
|
||||||
}
|
}
|
||||||
var cfg mcpConfig
|
var raw map[string]any
|
||||||
_ = json.Unmarshal(data, &cfg)
|
if err := json.Unmarshal(data, &raw); err != nil || raw == nil {
|
||||||
if cfg.MCPServers == nil {
|
return map[string]any{}
|
||||||
cfg.MCPServers = map[string]any{}
|
|
||||||
}
|
}
|
||||||
return cfg
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveMCPConfig(path string, cfg mcpConfig) error {
|
func saveRawMCP(path string, raw map[string]any) error {
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(raw, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, append(data, '\n'), 0o644)
|
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 {
|
func loadMCPCache(path string) map[string][]string {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,11 +172,3 @@ func loadMCPCache(path string) map[string][]string {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,159 +13,308 @@ import (
|
|||||||
"sproutclaw-web/internal/models"
|
"sproutclaw-web/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jsonlLine is a minimal parse of one line in a session JSONL file.
|
// sessionLine is one parsed line of a session JSONL file.
|
||||||
type jsonlLine struct {
|
type sessionLine struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// session header
|
ID string `json:"id"`
|
||||||
ID string `json:"id"`
|
Timestamp string `json:"timestamp"`
|
||||||
Created string `json:"created"`
|
Name string `json:"name"`
|
||||||
// message
|
Message json.RawMessage `json:"message"`
|
||||||
Role string `json:"role"`
|
|
||||||
Content any `json:"content"`
|
|
||||||
// session_info
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
|
type sessionMessage struct {
|
||||||
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
|
Role string `json:"role"`
|
||||||
entries, err := os.ReadDir(sessionsDir)
|
Content json.RawMessage `json:"content"`
|
||||||
if err != nil {
|
}
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pinned, _ := database.GetPinnedSessions()
|
var (
|
||||||
pinnedSet := map[string]struct{}{}
|
reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
|
||||||
for _, p := range pinned {
|
reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
|
||||||
pinnedSet[p] = struct{}{}
|
reWS = regexp.MustCompile(`\s+`)
|
||||||
}
|
reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`)
|
||||||
|
)
|
||||||
|
|
||||||
var summaries []models.SessionSummary
|
// listSessionFiles recursively collects every *.jsonl under sessionsDir.
|
||||||
for _, e := range entries {
|
// pi stores sessions in cwd-encoded subdirectories, e.g.
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
// sessions/--D--SmyProjects-AI-sproutclaw--/<id>.jsonl
|
||||||
continue
|
func listSessionFiles(sessionsDir string) []string {
|
||||||
}
|
var files []string
|
||||||
path := filepath.Join(sessionsDir, e.Name())
|
_ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error {
|
||||||
sum, err := readSessionSummary(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
_, sum.Pinned = pinnedSet[path]
|
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
|
||||||
sum.Path = path
|
files = append(files, 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
|
|
||||||
}
|
}
|
||||||
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) {
|
func isMachineSessionLabel(text, headerID string) bool {
|
||||||
f, err := os.Open(path)
|
t := strings.TrimSpace(text)
|
||||||
if err != nil {
|
if t == "" {
|
||||||
return models.SessionSummary{}, err
|
return true
|
||||||
}
|
}
|
||||||
defer f.Close()
|
if headerID != "" && t == headerID {
|
||||||
|
return true
|
||||||
info, _ := f.Stat()
|
|
||||||
modified := ""
|
|
||||||
if info != nil {
|
|
||||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
|
||||||
}
|
}
|
||||||
|
if reHex.MatchString(t) || reDigits.MatchString(t) {
|
||||||
var sum models.SessionSummary
|
return true
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sum.MessageCount = msgCount
|
return false
|
||||||
return sum, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractTextContent(content any) string {
|
func titleFromFirstUserMessage(text string) string {
|
||||||
switch v := content.(type) {
|
const maxChars = 56
|
||||||
case string:
|
cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " "))
|
||||||
return truncate(v, 120)
|
if cleaned == "" {
|
||||||
case []any:
|
return ""
|
||||||
for _, item := range v {
|
}
|
||||||
if m, ok := item.(map[string]any); ok {
|
candidate := cleaned
|
||||||
if m["type"] == "text" {
|
if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" {
|
||||||
if t, ok := m["text"].(string); ok {
|
candidate = strings.TrimSpace(m[1])
|
||||||
return truncate(t, 120)
|
}
|
||||||
}
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
// readSessionSummary parses one session file into a summary (nil if not a session).
|
||||||
if len([]rune(s)) <= n {
|
func readSessionSummary(path string) (models.SessionSummary, bool) {
|
||||||
return s
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return models.SessionSummary{}, false
|
||||||
}
|
}
|
||||||
runes := []rune(s)
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
return string(runes[: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) {
|
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
|
||||||
f, err := os.Open(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
var out []json.RawMessage
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||||
var lines []json.RawMessage
|
if strings.TrimSpace(line) == "" {
|
||||||
scanner := bufio.NewScanner(f)
|
continue
|
||||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
}
|
||||||
for scanner.Scan() {
|
var entry sessionLine
|
||||||
raw := append([]byte(nil), scanner.Bytes()...)
|
if json.Unmarshal([]byte(line), &entry) != nil {
|
||||||
lines = append(lines, raw)
|
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.
|
// AppendSessionName appends a session_info rename record to the JSONL file.
|
||||||
func AppendSessionName(path, name string) error {
|
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)
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
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)
|
b, err := json.Marshal(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = f.Write(append(b, '\n'))
|
_, err = f.Write(append([]byte("\n"), b...))
|
||||||
return err
|
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:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
|
Toggleable: true,
|
||||||
Path: skillPath,
|
Path: skillPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
140
backend/internal/services/web_terminal.go
Normal file
140
backend/internal/services/web_terminal.go
Normal file
@@ -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"}
|
||||||
|
}
|
||||||
659
config.yaml
Normal file
659
config.yaml
Normal file
@@ -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,默认
|
||||||
4
dev.bat
Normal file
4
dev.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
start "sproutclaw-web Frontend" cmd /k "%~dp0run-frontend.bat"
|
||||||
|
start "sproutclaw-web Backend" cmd /k "%~dp0run-backend.bat"
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
dist-desketop/
|
dist-desketop/
|
||||||
BIN
frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
BIN
frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
Binary file not shown.
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -8,6 +8,10 @@
|
|||||||
"name": "sproutclaw-webui-frontend",
|
"name": "sproutclaw-webui-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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",
|
"highlight.js": "11.11.1",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
@@ -15,7 +19,6 @@
|
|||||||
"react-router-dom": "7.15.1"
|
"react-router-dom": "7.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mogeko/maple-mono-cn": "7.9.0",
|
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
@@ -2630,13 +2633,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"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"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -4284,6 +4304,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||||
|
|||||||
@@ -5,13 +5,18 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate-icons": "node scripts/generate-icons.mjs",
|
"generate-icons": "node scripts/generate-icons.mjs",
|
||||||
|
"prepare-font": "node scripts/prepare-font.mjs",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run generate-icons && tsc -b && vite build && 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 && tsc -b && vite build",
|
"build:web": "npm run generate-icons && npm run prepare-font && tsc -b && vite build",
|
||||||
"build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop",
|
"build:desktop": "npm run generate-icons && npm run prepare-font && tsc -b && vite build --mode desktop",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"highlight.js": "11.11.1",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
@@ -19,7 +24,6 @@
|
|||||||
"react-router-dom": "7.15.1"
|
"react-router-dom": "7.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mogeko/maple-mono-cn": "7.9.0",
|
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
|||||||
BIN
frontend/public/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
BIN
frontend/public/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/lxgwwenkaimono-medium.woff2
Normal file
BIN
frontend/public/fonts/lxgwwenkaimono-medium.woff2
Normal file
Binary file not shown.
63
frontend/scripts/prepare-font.mjs
Normal file
63
frontend/scripts/prepare-font.mjs
Normal file
@@ -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)`);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||||
import { ChatLayout } from "./components/layout/ChatLayout";
|
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||||
@@ -5,7 +6,8 @@ import { AvatarProvider } from "./context/AvatarContext";
|
|||||||
import { BootstrapProvider } from "./context/BootstrapContext";
|
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||||
import { ChatProvider } from "./context/ChatContext";
|
import { ChatProvider } from "./context/ChatContext";
|
||||||
import { ChatPage } from "./routes/ChatPage";
|
import { ChatPage } from "./routes/ChatPage";
|
||||||
import { SettingsPage } from "./routes/SettingsPage";
|
|
||||||
|
const SettingsPage = lazy(() => import("./routes/SettingsPage"));
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +19,14 @@ export function App() {
|
|||||||
<Route path="/" element={<ChatLayout />}>
|
<Route path="/" element={<ChatLayout />}>
|
||||||
<Route index element={<ChatPage />} />
|
<Route index element={<ChatPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SettingsPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
<PwaUpdatePrompt />
|
<PwaUpdatePrompt />
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
|
|||||||
@@ -11,3 +11,16 @@ export function apiUrl(path: string): string {
|
|||||||
export function getApiBase(): string {
|
export function getApiBase(): string {
|
||||||
return API_BASE;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,3 +54,7 @@ export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & {
|
|||||||
export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
||||||
return apiGet("/api/environment");
|
return apiGet("/api/environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
|
||||||
|
return apiGet("/api/terminal");
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emptyInline {
|
||||||
|
padding: 10px 10px 4px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -48,6 +55,16 @@
|
|||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ephemeral {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral .main {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import { formatSessionTitle } from "../../utils/sessionTitle";
|
|||||||
import styles from "./SessionList.module.css";
|
import styles from "./SessionList.module.css";
|
||||||
|
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
const {
|
||||||
useChatContext();
|
sessions,
|
||||||
|
activeSessionPath,
|
||||||
|
isEphemeralSession,
|
||||||
|
loadSession,
|
||||||
|
newTemporarySession,
|
||||||
|
deleteSession,
|
||||||
|
renameSession,
|
||||||
|
toggleSessionPin,
|
||||||
|
} = useChatContext();
|
||||||
const [editingPath, setEditingPath] = useState<string | null>(null);
|
const [editingPath, setEditingPath] = useState<string | null>(null);
|
||||||
const [editingValue, setEditingValue] = useState("");
|
const [editingValue, setEditingValue] = useState("");
|
||||||
|
|
||||||
@@ -29,102 +37,109 @@ export function SessionList() {
|
|||||||
setEditingValue("");
|
setEditingValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!sessions.length) {
|
|
||||||
return <div className={styles.empty}>暂无历史会话</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{sessions.map((s) => {
|
<div className={`${styles.item} ${isEphemeralSession ? styles.active : ""} ${styles.ephemeral}`}>
|
||||||
const isActive = activeSessionPath === s.path;
|
<button type="button" className={styles.main} onClick={newTemporarySession}>
|
||||||
const isPinned = Boolean(s.pinned);
|
<div className={styles.name}>临时对话</div>
|
||||||
const title = formatSessionTitle(s.name);
|
<div className={styles.meta}>不保存 · 刷新后消失</div>
|
||||||
const isEditing = editingPath === s.path;
|
</button>
|
||||||
return (
|
</div>
|
||||||
<div
|
|
||||||
key={s.path}
|
{sessions.length === 0 ? (
|
||||||
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
<div className={styles.emptyInline}>暂无历史会话</div>
|
||||||
>
|
) : (
|
||||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
sessions.map((s) => {
|
||||||
<div className={styles.titleRow}>
|
const isActive = !isEphemeralSession && activeSessionPath === s.path;
|
||||||
{isEditing ? (
|
const isPinned = Boolean(s.pinned);
|
||||||
<input
|
const title = formatSessionTitle(s.name);
|
||||||
className={styles.renameInput}
|
const isEditing = editingPath === s.path;
|
||||||
value={editingValue}
|
return (
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
<div
|
||||||
onKeyDown={(e) => {
|
key={s.path}
|
||||||
if (e.key === "Enter") {
|
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||||
e.preventDefault();
|
>
|
||||||
confirmRename(s.path);
|
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||||
} else if (e.key === "Escape") {
|
<div className={styles.titleRow}>
|
||||||
cancelEditing();
|
{isEditing ? (
|
||||||
}
|
<input
|
||||||
}}
|
className={styles.renameInput}
|
||||||
onBlur={() => confirmRename(s.path)}
|
value={editingValue}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
autoFocus
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key === "Enter") {
|
||||||
) : (
|
e.preventDefault();
|
||||||
<div className={styles.name}>{title}</div>
|
confirmRename(s.path);
|
||||||
)}
|
} else if (e.key === "Escape") {
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => confirmRename(s.path)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.name}>{title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
||||||
|
title={isPinned ? "取消置顶" : "置顶会话"}
|
||||||
|
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleSessionPin(s.path, !isPinned);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path d="M12 17v5" />
|
||||||
|
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.renameBtn}
|
||||||
|
title="重命名会话"
|
||||||
|
aria-label={`重命名 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEditing) {
|
||||||
|
cancelEditing();
|
||||||
|
} else {
|
||||||
|
startEditing(s.path, s.name || title);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteBtn}
|
||||||
|
title="删除会话"
|
||||||
|
aria-label={`删除 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void deleteSession(s.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.meta}>
|
|
||||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
|
||||||
title={isPinned ? "取消置顶" : "置顶会话"}
|
|
||||||
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void toggleSessionPin(s.path, !isPinned);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
||||||
<path d="M12 17v5" />
|
|
||||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.renameBtn}
|
|
||||||
title="重命名会话"
|
|
||||||
aria-label={`重命名 ${title}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isEditing) {
|
|
||||||
cancelEditing();
|
|
||||||
} else {
|
|
||||||
startEditing(s.path, s.name || title);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.deleteBtn}
|
|
||||||
title="删除会话"
|
|
||||||
aria-label={`删除 ${title}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void deleteSession(s.path);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
134
frontend/src/components/terminal/WebTerminal.module.css
Normal file
134
frontend/src/components/terminal/WebTerminal.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
frontend/src/components/terminal/WebTerminal.tsx
Normal file
201
frontend/src/components/terminal/WebTerminal.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [connectionState, setConnectionState] = useState<ConnectionState>("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 (
|
||||||
|
<div ref={shellRef} className={styles.shell}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.toolbarTop}>
|
||||||
|
<div className={styles.statusLine}>
|
||||||
|
<span className={`${styles.statusDot} ${styles[connectionState]}`} aria-hidden />
|
||||||
|
<span className={styles.statusText}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.reconnectBtn}
|
||||||
|
disabled={!active || connectionState === "connecting"}
|
||||||
|
onClick={() => setSessionKey((key) => key + 1)}
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} className={styles.terminalHost} tabIndex={0} aria-label="Web 终端" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useLocation } from "react-router-dom";
|
|||||||
import { SplashScreen } from "../components/pwa/SplashScreen";
|
import { SplashScreen } from "../components/pwa/SplashScreen";
|
||||||
|
|
||||||
const MIN_SPLASH_MS = 400;
|
const MIN_SPLASH_MS = 400;
|
||||||
|
const MAX_SPLASH_MS = 8000;
|
||||||
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
||||||
|
|
||||||
type BootstrapRoute = "chat" | "settings";
|
type BootstrapRoute = "chat" | "settings";
|
||||||
@@ -44,6 +45,14 @@ export function BootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
mountTime.current = Date.now();
|
mountTime.current = Date.now();
|
||||||
}, [route]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!readyRoutes[route]) return;
|
if (!readyRoutes[route]) return;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
formatToolCall,
|
formatToolCall,
|
||||||
getToolCalls,
|
getToolCalls,
|
||||||
buildToolCallUpdateText,
|
buildToolCallUpdateText,
|
||||||
|
preferToolCallMessage,
|
||||||
} from "../utils/toolCall";
|
} from "../utils/toolCall";
|
||||||
import {
|
import {
|
||||||
formatSessionTitle,
|
formatSessionTitle,
|
||||||
@@ -65,6 +66,8 @@ interface ChatContextValue {
|
|||||||
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||||
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||||
newSession: () => void;
|
newSession: () => void;
|
||||||
|
newTemporarySession: () => void;
|
||||||
|
isEphemeralSession: boolean;
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
text: string,
|
text: string,
|
||||||
images?: ImageContent[],
|
images?: ImageContent[],
|
||||||
@@ -85,6 +88,20 @@ const ChatContext = createContext<ChatContextValue | null>(null);
|
|||||||
|
|
||||||
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||||
const LAST_ACTIVE_SESSION_KEY = "webui:lastActiveSessionPath";
|
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. */
|
/** Survives React StrictMode remount so boot only runs once per page load. */
|
||||||
let sharedSessionBootPromise: Promise<void> | null = null;
|
let sharedSessionBootPromise: Promise<void> | null = null;
|
||||||
@@ -364,6 +381,9 @@ function finalizeAssistantMessageEnd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function messageDedupeSignature(msg: ChatMessage): string | null {
|
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") {
|
if (msg.role !== "assistant" && msg.role !== "thinking" && msg.role !== "tool_call") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -437,12 +457,24 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
|
|
||||||
const existingIndex = turnSignatures.get(signature);
|
const existingIndex = turnSignatures.get(signature);
|
||||||
if (existingIndex === undefined) {
|
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);
|
turnSignatures.set(signature, result.length);
|
||||||
result.push(msg);
|
result.push(msg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = result[existingIndex];
|
const existing = result[existingIndex];
|
||||||
|
if (msg.role === "tool_call") {
|
||||||
|
result[existingIndex] = preferToolCallMessage(existing, msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (existing.streaming && !msg.streaming) {
|
if (existing.streaming && !msg.streaming) {
|
||||||
result[existingIndex] = msg;
|
result[existingIndex] = msg;
|
||||||
}
|
}
|
||||||
@@ -451,6 +483,11 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
return collapseStreamingPrefixOrphans(result);
|
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 }) {
|
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||||
const { markRouteReady } = useBootstrap();
|
const { markRouteReady } = useBootstrap();
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
@@ -475,8 +512,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
|
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
|
||||||
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
|
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
|
||||||
const [toolsExpanded, setToolsExpanded] = useState(false);
|
const [toolsExpanded, setToolsExpanded] = useState(false);
|
||||||
|
const [isEphemeralSession, setIsEphemeralSession] = useState(false);
|
||||||
|
|
||||||
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||||
|
const isEphemeralRef = useRef(false);
|
||||||
|
const ephemeralSessionPathRef = useRef<string | null>(null);
|
||||||
|
const suppressStreamMessagesRef = useRef(false);
|
||||||
const sessionActivationPromise = useRef<Promise<SessionState | null> | null>(null);
|
const sessionActivationPromise = useRef<Promise<SessionState | null> | null>(null);
|
||||||
const newSessionPromise = useRef<Promise<void> | null>(null);
|
const newSessionPromise = useRef<Promise<void> | null>(null);
|
||||||
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
|
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
|
||||||
@@ -550,11 +591,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||||
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
||||||
|
|
||||||
if (!activeSessionPathRef.current) {
|
if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,20 +611,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
||||||
if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting);
|
if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting);
|
||||||
|
|
||||||
if (!activeSessionPathRef.current) {
|
if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||||
const pathKey = activeSessionPathRef.current;
|
const pathKey = isEphemeralRef.current ? backendSessionPathRef.current : activeSessionPathRef.current;
|
||||||
setSessions((currentSessions) => {
|
setSessions((currentSessions) => {
|
||||||
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
||||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||||
|
} else if (isEphemeralRef.current) {
|
||||||
|
setHeaderTitle("临时对话");
|
||||||
} else if (row) {
|
} else if (row) {
|
||||||
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
||||||
setHeaderTitle(formatSessionTitle(row.name));
|
setHeaderTitle(formatSessionTitle(row.name));
|
||||||
@@ -627,26 +662,89 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await sessionsApi.fetchSessions();
|
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) {
|
} catch (err) {
|
||||||
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}, [addSystemMessage]);
|
}, [addSystemMessage]);
|
||||||
|
|
||||||
const createAndBindNewSession = useCallback(async (): Promise<string> => {
|
const cleanupEphemeralSessionOnBoot = useCallback(async () => {
|
||||||
const data = await chatApi.createNewSession();
|
try {
|
||||||
if (data.error) {
|
const path = sessionStorage.getItem(EPHEMERAL_CLEANUP_KEY)?.trim();
|
||||||
throw new Error(data.error);
|
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);
|
if (!path) return;
|
||||||
void loadSessions();
|
|
||||||
return path;
|
try {
|
||||||
}, [syncBackendSessionPathFromState, persistLastActiveSession, loadSessions]);
|
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<string> => {
|
||||||
|
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(
|
const clearChat = useCallback(
|
||||||
(title = "聊天") => {
|
(title = "聊天") => {
|
||||||
@@ -661,6 +759,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const loadSession = useCallback(
|
const loadSession = useCallback(
|
||||||
async (path: string, options?: { skipDashboardRefresh?: boolean }) => {
|
async (path: string, options?: { skipDashboardRefresh?: boolean }) => {
|
||||||
|
await discardEphemeralSession();
|
||||||
|
|
||||||
if (!compactionReloadPendingRef.current) {
|
if (!compactionReloadPendingRef.current) {
|
||||||
invalidateTurnEvents();
|
invalidateTurnEvents();
|
||||||
}
|
}
|
||||||
@@ -731,15 +831,19 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
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) {
|
if (!sharedSessionBootPromise) {
|
||||||
sharedSessionBootPromise = (async () => {
|
sharedSessionBootPromise = (async () => {
|
||||||
|
await cleanupEphemeralSessionOnBoot();
|
||||||
|
|
||||||
const data = await sessionsApi.fetchSessions();
|
const data = await sessionsApi.fetchSessions();
|
||||||
const list = data.sessions || [];
|
const list = data.sessions || [];
|
||||||
setSessions(list);
|
setSessions(list);
|
||||||
|
notifySessionsReady();
|
||||||
|
|
||||||
let targetPath: string | null = null;
|
let targetPath: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -756,24 +860,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetPath) {
|
if (targetPath) {
|
||||||
let activatedState: SessionState | null = null;
|
await loadSession(targetPath, { skipDashboardRefresh: true });
|
||||||
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();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,17 +876,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
addSystemMessage(`初始化会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
addSystemMessage(`初始化会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
} else if (onSessionsReady) {
|
||||||
|
void sharedSessionBootPromise.then(notifySessionsReady).catch(notifySessionsReady);
|
||||||
}
|
}
|
||||||
sessionBootRef.current = sharedSessionBootPromise;
|
sessionBootRef.current = sharedSessionBootPromise;
|
||||||
return sharedSessionBootPromise;
|
return sharedSessionBootPromise;
|
||||||
}, [
|
}, [
|
||||||
loadSession,
|
loadSession,
|
||||||
activateSessionBackend,
|
|
||||||
applySessionState,
|
|
||||||
fetchSessionStateFull,
|
fetchSessionStateFull,
|
||||||
addSystemMessage,
|
addSystemMessage,
|
||||||
createAndBindNewSession,
|
createAndBindNewSession,
|
||||||
invalidateTurnEvents,
|
invalidateTurnEvents,
|
||||||
|
cleanupEphemeralSessionOnBoot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const deleteSessionHandler = useCallback(
|
const deleteSessionHandler = useCallback(
|
||||||
@@ -816,7 +904,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
await sessionsApi.deleteSession(path);
|
await sessionsApi.deleteSession(path);
|
||||||
sessionCache.current.delete(path);
|
sessionCache.current.delete(path);
|
||||||
setSessions((prev) => prev.filter((s) => s.path !== path));
|
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||||
if (activeSessionPathRef.current === path) {
|
if (activeSessionPathRef.current === path || (isEphemeralRef.current && ephemeralSessionPathRef.current === path)) {
|
||||||
clearChat();
|
clearChat();
|
||||||
setBackendSessionPath(null);
|
setBackendSessionPath(null);
|
||||||
sessionActivationPromise.current = null;
|
sessionActivationPromise.current = null;
|
||||||
@@ -881,15 +969,19 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
if (newSessionPromise.current) return;
|
if (newSessionPromise.current) return;
|
||||||
|
|
||||||
setBackendSessionPath(null);
|
|
||||||
setActiveSessionPath(null);
|
|
||||||
sessionActivationPromise.current = null;
|
|
||||||
setHeaderMeta("");
|
|
||||||
clearChat("新会话");
|
|
||||||
void persistLastActiveSession(null);
|
|
||||||
|
|
||||||
const promise = (async () => {
|
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();
|
await fetchSessionStateFull();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -900,9 +992,68 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
suppressStreamMessagesRef.current = false;
|
||||||
newSessionPromise.current = null;
|
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<boolean> => {
|
const ensureSessionReady = useCallback(async (): Promise<boolean> => {
|
||||||
if (newSessionPromise.current) {
|
if (newSessionPromise.current) {
|
||||||
@@ -939,7 +1090,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!created) return false;
|
if (!created) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
|
if (
|
||||||
|
!isEphemeralRef.current &&
|
||||||
|
activeSessionPathRef.current &&
|
||||||
|
backendSessionPathRef.current !== activeSessionPathRef.current
|
||||||
|
) {
|
||||||
setHeaderMeta("正在切换会话...");
|
setHeaderMeta("正在切换会话...");
|
||||||
if (sessionActivationPromise.current) {
|
if (sessionActivationPromise.current) {
|
||||||
try {
|
try {
|
||||||
@@ -986,6 +1141,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (path) {
|
if (path) {
|
||||||
await loadSession(path);
|
await loadSession(path);
|
||||||
} else {
|
} else {
|
||||||
|
await discardEphemeralSession();
|
||||||
clearChat();
|
clearChat();
|
||||||
await fetchSessionStateFull();
|
await fetchSessionStateFull();
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1161,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
[
|
[
|
||||||
addSystemMessage,
|
addSystemMessage,
|
||||||
clearChat,
|
clearChat,
|
||||||
|
discardEphemeralSession,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
loadSession,
|
loadSession,
|
||||||
fetchSessionStateFull,
|
fetchSessionStateFull,
|
||||||
@@ -1062,6 +1219,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const persistPathAfterSend = () => {
|
const persistPathAfterSend = () => {
|
||||||
|
if (isEphemeralRef.current) return;
|
||||||
const persistPath = resolvePersistSessionPath(
|
const persistPath = resolvePersistSessionPath(
|
||||||
backendSessionPathRef.current,
|
backendSessionPathRef.current,
|
||||||
activeSessionPathRef.current,
|
activeSessionPathRef.current,
|
||||||
@@ -1318,6 +1476,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const processStreamEvent = useCallback(
|
const processStreamEvent = useCallback(
|
||||||
(ev: SseEvent) => {
|
(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) {
|
switch (ev.type) {
|
||||||
case "agent_start":
|
case "agent_start":
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
@@ -1470,7 +1640,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
|
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const activePath = activeSessionPathRef.current;
|
const activePath = isEphemeralRef.current
|
||||||
|
? backendSessionPathRef.current
|
||||||
|
: activeSessionPathRef.current;
|
||||||
const summaryText = ev.result?.summary?.trim();
|
const summaryText = ev.result?.summary?.trim();
|
||||||
if (summaryText) {
|
if (summaryText) {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
@@ -1522,9 +1694,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "extension_error":
|
case "extension_error":
|
||||||
addSystemMessage(
|
if (!isIgnorableExtensionError(ev.error)) {
|
||||||
`扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`,
|
addSystemMessage(
|
||||||
);
|
`扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "bash_update": {
|
case "bash_update": {
|
||||||
@@ -1557,6 +1731,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsStreaming(ev.isStreaming);
|
setIsStreaming(ev.isStreaming);
|
||||||
}
|
}
|
||||||
for (const replayEv of ev.replay ?? []) {
|
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") {
|
if (replayEv.type === "extension_ui_request") {
|
||||||
handleExtensionUiRequest(replayEv as Record<string, unknown>);
|
handleExtensionUiRequest(replayEv as Record<string, unknown>);
|
||||||
} else {
|
} 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(() => {
|
useEffect(() => {
|
||||||
syncViewportHeight();
|
syncViewportHeight();
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
@@ -1605,24 +1800,42 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||||
window.addEventListener("orientationchange", syncViewportHeight);
|
window.addEventListener("orientationchange", syncViewportHeight);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
const bootstrap = {
|
const bootstrap = {
|
||||||
sessions: false,
|
sessions: false,
|
||||||
models: false,
|
models: false,
|
||||||
sessionState: false,
|
|
||||||
sse: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryMarkBootstrapReady = () => {
|
const tryMarkBootstrapReady = () => {
|
||||||
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
|
if (cancelled) return;
|
||||||
markRouteReady("chat");
|
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
|
void chatApi
|
||||||
.fetchModels()
|
.fetchModels()
|
||||||
.then((data) => setAvailableModels(data.models || []))
|
.then((data) => {
|
||||||
|
if (!cancelled) setAvailableModels(data.models || []);
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
if (!cancelled) {
|
||||||
|
addSystemMessageRef.current(
|
||||||
|
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
bootstrap.models = true;
|
bootstrap.models = true;
|
||||||
@@ -1631,31 +1844,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
let eventSource: EventSource | null = null;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sseBootstrapTimer: ReturnType<typeof setTimeout> | null = null;
|
let sseNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let sseNoticeShown = false;
|
||||||
const finishSseBootstrap = () => {
|
|
||||||
if (bootstrap.sse) return;
|
|
||||||
bootstrap.sse = true;
|
|
||||||
tryMarkBootstrapReady();
|
|
||||||
};
|
|
||||||
|
|
||||||
sseBootstrapTimer = setTimeout(() => {
|
|
||||||
if (!bootstrap.sse) {
|
|
||||||
addSystemMessage("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问");
|
|
||||||
finishSseBootstrap();
|
|
||||||
}
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (eventSource) eventSource.close();
|
if (eventSource) eventSource.close();
|
||||||
eventSource = new EventSource(apiUrl("/api/events"));
|
eventSource = new EventSource(apiUrl("/api/events"));
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
finishSseBootstrap();
|
|
||||||
};
|
};
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
handleSseEvent(JSON.parse(e.data) as SseEvent);
|
handleSseEventRef.current(JSON.parse(e.data) as SseEvent);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -1669,25 +1869,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
void restoreSessionOnBoot()
|
sseNoticeTimer = setTimeout(() => {
|
||||||
.catch(() => {})
|
if (cancelled || sseNoticeShown) return;
|
||||||
.finally(() => {
|
sseNoticeShown = true;
|
||||||
bootstrap.sessions = true;
|
addSystemMessageRef.current("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问");
|
||||||
bootstrap.sessionState = true;
|
}, 12_000);
|
||||||
tryMarkBootstrapReady();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
||||||
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
||||||
window.removeEventListener("orientationchange", syncViewportHeight);
|
window.removeEventListener("orientationchange", syncViewportHeight);
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
if (sseBootstrapTimer) clearTimeout(sseBootstrapTimer);
|
if (sseNoticeTimer) clearTimeout(sseNoticeTimer);
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
sharedSessionBootPromise = null;
|
|
||||||
};
|
};
|
||||||
}, [restoreSessionOnBoot, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady]);
|
}, []);
|
||||||
|
|
||||||
const value: ChatContextValue = {
|
const value: ChatContextValue = {
|
||||||
connected,
|
connected,
|
||||||
@@ -1720,6 +1918,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
renameSession: renameSessionHandler,
|
renameSession: renameSessionHandler,
|
||||||
toggleSessionPin: toggleSessionPinHandler,
|
toggleSessionPin: toggleSessionPinHandler,
|
||||||
newSession,
|
newSession,
|
||||||
|
newTemporarySession,
|
||||||
|
isEphemeralSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
runBashCommand,
|
runBashCommand,
|
||||||
abortStream,
|
abortStream,
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "@mogeko/maple-mono-cn/dist/font/result.css";
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles/global.css";
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -181,6 +181,55 @@
|
|||||||
word-break: break-all;
|
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,
|
.primaryBtn,
|
||||||
.secondaryBtn,
|
.secondaryBtn,
|
||||||
.linkBtn {
|
.linkBtn {
|
||||||
@@ -256,6 +305,20 @@
|
|||||||
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
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 {
|
.formBody {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -453,17 +516,23 @@
|
|||||||
.envValue {
|
.envValue {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.terminalPanel {
|
||||||
.extensionGroupList {
|
min-height: 420px;
|
||||||
grid-template-columns: 1fr;
|
}
|
||||||
}
|
|
||||||
|
.terminalPanel .panelHeader {
|
||||||
|
border-bottom-color: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalPanel .panelHeader p {
|
||||||
|
color: #8b949e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extensionHeader {
|
.extensionHeader {
|
||||||
@@ -807,51 +876,170 @@
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
padding-top: max(12px, env(safe-area-inset-top));
|
padding-top: max(12px, env(safe-area-inset-top));
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
padding-left: max(12px, env(safe-area-inset-left));
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
padding-right: max(12px, env(safe-area-inset-right));
|
padding-right: max(12px, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pageTitle h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
padding: 8px max(10px, env(safe-area-inset-left)) 8px max(10px, env(safe-area-inset-right));
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar {
|
.sidebar::-webkit-scrollbar {
|
||||||
height: 0;
|
display: none;
|
||||||
width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navBtn {
|
.navBtn {
|
||||||
flex: 1 1 0;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 12px 8px;
|
white-space: nowrap;
|
||||||
|
padding: 9px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panes {
|
.panes {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.textarea {
|
||||||
font-size: 12px;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link } from "react-router-dom";
|
||||||
import * as settingsApi from "../api/settings";
|
import * as settingsApi from "../api/settings";
|
||||||
import { useAvatars } from "../context/AvatarContext";
|
import { useAvatars } from "../context/AvatarContext";
|
||||||
import { useBootstrap } from "../context/BootstrapContext";
|
import { useBootstrap } from "../context/BootstrapContext";
|
||||||
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||||
import type { McpToolInfo } from "../types/events";
|
import type { McpToolInfo } from "../types/events";
|
||||||
|
import { renderMarkdown } from "../utils/markdown";
|
||||||
import styles from "./SettingsPage.module.css";
|
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 {
|
function isNpmSkill(skill: SkillInfo): boolean {
|
||||||
if (skill.toggleable === false) return true;
|
if (skill.toggleable === false) return true;
|
||||||
@@ -28,6 +33,41 @@ function formatSkillMeta(skill: SkillInfo): string {
|
|||||||
}
|
}
|
||||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
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<string, unknown>;
|
||||||
|
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<string, unknown>)) {
|
||||||
|
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
||||||
|
return `"providers.${name}" 必须是对象`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderNameList(label: string, items: string[] | undefined) {
|
function renderNameList(label: string, items: string[] | undefined) {
|
||||||
if (!Array.isArray(items) || !items.length) return null;
|
if (!Array.isArray(items) || !items.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -56,6 +96,13 @@ function extensionTogglePath(extension: ExtensionInfo): string {
|
|||||||
return extension.resolvedPath || extension.path || "";
|
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({
|
function ExtensionCard({
|
||||||
extension,
|
extension,
|
||||||
toggling,
|
toggling,
|
||||||
@@ -67,10 +114,11 @@ function ExtensionCard({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onToggle: (enabled: boolean) => void;
|
onToggle: (enabled: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const meta =
|
const meta = [extension.kind, extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||||
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
|
|
||||||
const enabled = extension.enabled !== false;
|
const enabled = extension.enabled !== false;
|
||||||
|
const toggleable = extensionToggleable(extension);
|
||||||
const togglePath = extensionTogglePath(extension);
|
const togglePath = extensionTogglePath(extension);
|
||||||
|
const versionLabel = extension.version ? `v${extension.version}` : "";
|
||||||
const counts = (
|
const counts = (
|
||||||
[
|
[
|
||||||
["命令", extension.commands?.length || 0],
|
["命令", extension.commands?.length || 0],
|
||||||
@@ -89,19 +137,23 @@ function ExtensionCard({
|
|||||||
<div className={styles.extensionHeader}>
|
<div className={styles.extensionHeader}>
|
||||||
<div className={styles.extensionTitleRow}>
|
<div className={styles.extensionTitleRow}>
|
||||||
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
|
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
|
||||||
<label className={styles.skillToggle}>
|
{toggleable ? (
|
||||||
<input
|
<label className={styles.skillToggle}>
|
||||||
type="checkbox"
|
<input
|
||||||
checked={enabled}
|
type="checkbox"
|
||||||
disabled={!togglePath || toggling || disabled}
|
checked={enabled}
|
||||||
onChange={(e) => {
|
disabled={!togglePath || toggling || disabled}
|
||||||
if (!togglePath) return;
|
onChange={(e) => {
|
||||||
onToggle(e.target.checked);
|
if (!togglePath) return;
|
||||||
}}
|
onToggle(e.target.checked);
|
||||||
/>
|
}}
|
||||||
<span className={styles.skillToggleUi} aria-hidden />
|
/>
|
||||||
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
<span className={styles.skillToggleUi} aria-hidden />
|
||||||
</label>
|
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
||||||
|
</label>
|
||||||
|
) : versionLabel ? (
|
||||||
|
<div className={styles.itemMeta}>{versionLabel}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
|
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +300,7 @@ export function SettingsPage() {
|
|||||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||||
const [systemPrompt, setSystemPrompt] = useState("");
|
const [systemPrompt, setSystemPrompt] = useState("");
|
||||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||||
|
const [systemPromptPreview, setSystemPromptPreview] = useState(false);
|
||||||
const [modelsConfig, setModelsConfig] = useState("");
|
const [modelsConfig, setModelsConfig] = useState("");
|
||||||
const [modelsConfigPath, setModelsConfigPath] = useState("");
|
const [modelsConfigPath, setModelsConfigPath] = useState("");
|
||||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||||
@@ -269,6 +322,9 @@ export function SettingsPage() {
|
|||||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||||
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
||||||
const [loadingEnv, setLoadingEnv] = useState(false);
|
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||||
|
const [terminalPlatform, setTerminalPlatform] = useState("");
|
||||||
|
const [terminalRequested, setTerminalRequested] = useState(false);
|
||||||
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let initial: SettingsPaneId = "prompt";
|
let initial: SettingsPaneId = "prompt";
|
||||||
@@ -290,6 +346,9 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const showPane = (paneId: SettingsPaneId) => {
|
const showPane = (paneId: SettingsPaneId) => {
|
||||||
setActivePane(paneId);
|
setActivePane(paneId);
|
||||||
|
if (paneId !== "prompt") {
|
||||||
|
setSystemPromptPreview(false);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
||||||
} catch {
|
} 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 () => {
|
const loadEnvInfo = async () => {
|
||||||
setLoadingEnv(true);
|
setLoadingEnv(true);
|
||||||
try {
|
try {
|
||||||
@@ -354,6 +424,17 @@ export function SettingsPage() {
|
|||||||
if (activePane === "env" && !envInfo && !loadingEnv) {
|
if (activePane === "env" && !envInfo && !loadingEnv) {
|
||||||
void loadEnvInfo();
|
void loadEnvInfo();
|
||||||
}
|
}
|
||||||
|
if (activePane === "terminal" && !terminalPlatform) {
|
||||||
|
void loadTerminalInfo();
|
||||||
|
}
|
||||||
|
if (activePane === "terminal") {
|
||||||
|
setTerminalRequested(true);
|
||||||
|
}
|
||||||
|
}, [activePane]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeTab = sidebarRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
|
||||||
|
activeTab?.scrollIntoView({ behavior: "smooth", inline: "nearest", block: "nearest" });
|
||||||
}, [activePane]);
|
}, [activePane]);
|
||||||
|
|
||||||
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
||||||
@@ -471,6 +552,12 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveModelsConfig = async () => {
|
const saveModelsConfig = async () => {
|
||||||
|
const validationError = validateModelsConfigJson(modelsConfig);
|
||||||
|
if (validationError) {
|
||||||
|
setStatus(validationError, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSavingModels(true);
|
setSavingModels(true);
|
||||||
setStatus("保存中...");
|
setStatus("保存中...");
|
||||||
try {
|
try {
|
||||||
@@ -521,6 +608,11 @@ export function SettingsPage() {
|
|||||||
const toggleableSkills = skills.filter(isSkillToggleable);
|
const toggleableSkills = skills.filter(isSkillToggleable);
|
||||||
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
|
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
|
||||||
const enabledExtensionCount = extensions.filter((extension) => extension.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 =
|
const statusClass =
|
||||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||||
|
|
||||||
@@ -541,7 +633,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
|
<nav className={styles.sidebar} ref={sidebarRef} role="tablist" aria-label="设置分区">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["prompt", "系统提示词"],
|
["prompt", "系统提示词"],
|
||||||
@@ -550,6 +642,7 @@ export function SettingsPage() {
|
|||||||
["mcp", "MCP工具"],
|
["mcp", "MCP工具"],
|
||||||
["extensions", "插件扩展"],
|
["extensions", "插件扩展"],
|
||||||
["other", "其他设置"],
|
["other", "其他设置"],
|
||||||
|
["terminal", "终端"],
|
||||||
["env", "环境信息"],
|
["env", "环境信息"],
|
||||||
] as const
|
] as const
|
||||||
).map(([id, label]) => (
|
).map(([id, label]) => (
|
||||||
@@ -577,22 +670,44 @@ export function SettingsPage() {
|
|||||||
<h2>系统提示词</h2>
|
<h2>系统提示词</h2>
|
||||||
<p>{systemPromptPath}</p>
|
<p>{systemPromptPath}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className={styles.panelHeaderActions}>
|
||||||
type="button"
|
<button
|
||||||
className={styles.primaryBtn}
|
type="button"
|
||||||
disabled={saving}
|
className={`${styles.secondaryBtn} ${systemPromptPreview ? styles.headerBtnActive : ""}`}
|
||||||
onClick={() => void saveSystemPrompt()}
|
onClick={() => setSystemPromptPreview((preview) => !preview)}
|
||||||
>
|
>
|
||||||
保存
|
{systemPromptPreview ? "编辑" : "预览"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryBtn}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveSystemPrompt()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
{systemPromptPreview ? (
|
||||||
className={styles.textarea}
|
<div className={styles.markdownPreview}>
|
||||||
spellCheck={false}
|
{systemPrompt.trim() ? (
|
||||||
aria-label="系统提示词"
|
<div
|
||||||
value={systemPrompt}
|
className={`markdown-body ${styles.markdownBody}`}
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
dangerouslySetInnerHTML={{ __html: systemPromptHtml }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.empty} ${styles.emptyCompact}`}>暂无内容</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="系统提示词"
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{statusText && activePane === "prompt" ? (
|
{statusText && activePane === "prompt" ? (
|
||||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -611,19 +726,25 @@ export function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.primaryBtn}
|
className={styles.primaryBtn}
|
||||||
disabled={savingModels}
|
disabled={savingModels || Boolean(modelsConfigError)}
|
||||||
onClick={() => void saveModelsConfig()}
|
onClick={() => void saveModelsConfig()}
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className={styles.textarea}
|
className={`${styles.textarea} ${modelsConfigError ? styles.textareaInvalid : ""}`}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
aria-label="模型配置"
|
aria-label="模型配置"
|
||||||
|
aria-invalid={modelsConfigError ? true : undefined}
|
||||||
value={modelsConfig}
|
value={modelsConfig}
|
||||||
onChange={(e) => setModelsConfig(e.target.value)}
|
onChange={(e) => setModelsConfig(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{modelsConfigError ? (
|
||||||
|
<div className={styles.jsonError} role="alert">
|
||||||
|
{modelsConfigError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{statusText && activePane === "models" ? (
|
{statusText && activePane === "models" ? (
|
||||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -945,6 +1066,23 @@ export function SettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${styles.terminalPanel} ${activePane === "terminal" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "terminal"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>终端</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{terminalRequested ? (
|
||||||
|
<Suspense fallback={<div className={styles.status}>正在加载终端…</div>}>
|
||||||
|
<WebTerminal active={activePane === "terminal"} platform={terminalPlatform} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
|
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
@@ -953,7 +1091,7 @@ export function SettingsPage() {
|
|||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2>环境信息</h2>
|
<h2>环境信息</h2>
|
||||||
<p>Node.js 运行时 · 操作系统 · 进程信息</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -985,6 +1123,7 @@ export function SettingsPage() {
|
|||||||
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
|
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
|
||||||
["Node 路径", envInfo.execPath],
|
["Node 路径", envInfo.execPath],
|
||||||
["工作目录", envInfo.cwd],
|
["工作目录", envInfo.cwd],
|
||||||
|
["SproutClaw 根目录", envInfo.repoRoot],
|
||||||
] as [string, string | undefined][]
|
] as [string, string | undefined][]
|
||||||
)
|
)
|
||||||
.filter(([, v]) => v !== undefined && v !== "")
|
.filter(([, v]) => v !== undefined && v !== "")
|
||||||
@@ -1008,3 +1147,5 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
|
|||||||
13
frontend/src/styles/fonts.css
Normal file
13
frontend/src/styles/fonts.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "LXGW WenKai Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: optional;
|
||||||
|
src: url("/fonts/lxgwwenkaimono-medium.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-family: "LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace;
|
||||||
|
--font-ui: var(--font-family);
|
||||||
|
--font-mono: var(--font-family);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--app-height: 100vh;
|
--app-height: 100vh;
|
||||||
--font-family: "Maple Mono CN", ui-monospace, monospace;
|
--font-family: "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace;
|
||||||
--font-letter-spacing: -0.05em;
|
--font-letter-spacing: 0;
|
||||||
--font-ui: var(--font-family);
|
--font-ui: var(--font-family);
|
||||||
--font-mono: var(--font-family);
|
--font-mono: var(--font-family);
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export interface ExtensionInfo {
|
|||||||
location?: string;
|
location?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
toggleable?: boolean;
|
||||||
commands?: string[];
|
commands?: string[];
|
||||||
tools?: string[];
|
tools?: string[];
|
||||||
handlers?: string[];
|
handlers?: string[];
|
||||||
@@ -212,7 +213,7 @@ export interface AvatarSettings {
|
|||||||
agentAvatarUrl: string;
|
agentAvatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "env";
|
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env";
|
||||||
|
|
||||||
export interface EnvironmentInfo {
|
export interface EnvironmentInfo {
|
||||||
nodeVersion?: string;
|
nodeVersion?: string;
|
||||||
@@ -223,6 +224,7 @@ export interface EnvironmentInfo {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
repoRoot?: string;
|
||||||
uptime?: number;
|
uptime?: number;
|
||||||
totalMemMb?: number;
|
totalMemMb?: number;
|
||||||
freeMemMb?: number;
|
freeMemMb?: number;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): s
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${escHtml(title)}</title>
|
<title>${escHtml(title)}</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:"Maple Mono CN",ui-monospace,monospace;letter-spacing:-0.05em;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
body{font-family:"LXGW WenKai Mono","PingFang SC","Microsoft YaHei UI",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||||
.block{margin:0 0 1rem}
|
.block{margin:0 0 1rem}
|
||||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
formatToolCall,
|
formatToolCall,
|
||||||
formatToolResult,
|
formatToolResult,
|
||||||
getToolCalls,
|
getToolCalls,
|
||||||
|
upsertToolCallMessage,
|
||||||
} from "./toolCall";
|
} from "./toolCall";
|
||||||
|
|
||||||
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
||||||
@@ -31,11 +32,12 @@ function appendAssistantBlocks(m: { content?: string | unknown[] }, result: Chat
|
|||||||
} else if (b.type === "toolCall") {
|
} else if (b.type === "toolCall") {
|
||||||
const tc = block as ToolCallBlock;
|
const tc = block as ToolCallBlock;
|
||||||
const tid = tc.toolCallId || tc.id;
|
const tid = tc.toolCallId || tc.id;
|
||||||
result.push({
|
const tidStr = tid ? String(tid) : undefined;
|
||||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
upsertToolCallMessage(result, {
|
||||||
|
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||||
role: "tool_call",
|
role: "tool_call",
|
||||||
content: formatToolCall(tc),
|
content: formatToolCall(tc),
|
||||||
toolCallId: tid ? String(tid) : undefined,
|
toolCallId: tidStr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,11 +49,12 @@ function appendAssistantBlocks(m: { content?: string | unknown[] }, result: Chat
|
|||||||
}
|
}
|
||||||
for (const tc of getToolCalls(m.content as string | undefined)) {
|
for (const tc of getToolCalls(m.content as string | undefined)) {
|
||||||
const tid = tc.toolCallId || tc.id;
|
const tid = tc.toolCallId || tc.id;
|
||||||
result.push({
|
const tidStr = tid ? String(tid) : undefined;
|
||||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
upsertToolCallMessage(result, {
|
||||||
|
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||||
role: "tool_call",
|
role: "tool_call",
|
||||||
content: formatToolCall(tc),
|
content: formatToolCall(tc),
|
||||||
toolCallId: tid ? String(tid) : undefined,
|
toolCallId: tidStr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,11 +78,12 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
|
|||||||
} else if (m.role === "toolResult" || m.role === "tool") {
|
} else if (m.role === "toolResult" || m.role === "tool") {
|
||||||
const text = formatToolResult({ content: m.content });
|
const text = formatToolResult({ content: m.content });
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
result.push({
|
const tid = m.toolCallId ? String(m.toolCallId) : undefined;
|
||||||
id: m.toolCallId ? `tool-${m.toolCallId}` : nextMessageId(),
|
upsertToolCallMessage(result, {
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
role: "tool_call",
|
role: "tool_call",
|
||||||
content: `✅ tool\n${text}`,
|
content: `✅ tool\n${text}`,
|
||||||
toolCallId: m.toolCallId ? String(m.toolCallId) : undefined,
|
toolCallId: tid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (m.role === "bashExecution") {
|
} else if (m.role === "bashExecution") {
|
||||||
|
|||||||
@@ -1,4 +1,44 @@
|
|||||||
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
import type { ChatMessage, ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||||
|
|
||||||
|
export function isToolCallComplete(content: string): boolean {
|
||||||
|
const trimmed = content.trimStart();
|
||||||
|
return trimmed.startsWith("✅") || trimmed.startsWith("❌");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferToolCallMessage(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
|
||||||
|
const existingComplete = isToolCallComplete(existing.content);
|
||||||
|
const incomingComplete = isToolCallComplete(incoming.content);
|
||||||
|
let content = existing.content;
|
||||||
|
if (incomingComplete && !existingComplete) {
|
||||||
|
content = incoming.content;
|
||||||
|
} else if (incomingComplete === existingComplete) {
|
||||||
|
content =
|
||||||
|
incoming.content.length >= existing.content.length ? incoming.content : existing.content;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
content,
|
||||||
|
toolCallId: existing.toolCallId ?? incoming.toolCallId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertToolCallMessage(result: ChatMessage[], message: ChatMessage): void {
|
||||||
|
if (message.toolCallId) {
|
||||||
|
const idx = result.findIndex(
|
||||||
|
(msg) => msg.role === "tool_call" && msg.toolCallId === message.toolCallId,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
result[idx] = preferToolCallMessage(result[idx], message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const idIdx = result.findIndex((msg) => msg.id === message.id);
|
||||||
|
if (idIdx >= 0) {
|
||||||
|
result[idIdx] = preferToolCallMessage(result[idIdx], message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: "index.html",
|
navigateFallback: "index.html",
|
||||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
globPatterns: ["**/*.{js,css,html,png,ico}"],
|
||||||
|
globIgnores: ["**/fonts/**"],
|
||||||
runtimeCaching: isDesktop
|
runtimeCaching: isDesktop
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -70,10 +71,33 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
outDir: isDesktop ? "dist-desketop" : "dist",
|
outDir: isDesktop ? "dist-desketop" : "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/")) {
|
||||||
|
return "react-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("node_modules/react-router") || id.includes("node_modules/@remix-run/router")) {
|
||||||
|
return "router-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("node_modules/@xterm")) {
|
||||||
|
return "xterm-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("node_modules/highlight.js") || id.includes("node_modules/marked")) {
|
||||||
|
return "markdown-vendor";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:19133",
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:19133",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "sproutclaw-web",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
12
run-backend.bat
Normal file
12
run-backend.bat
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@echo off
|
||||||
|
rem ---- Config (edit if your sproutclaw path differs) ----
|
||||||
|
set SPROUTCLAW_DIR=D:\SmyProjects\AI\sproutclaw
|
||||||
|
set BACKEND_PORT=19133
|
||||||
|
rem -------------------------------------------------------
|
||||||
|
|
||||||
|
cd /d "%~dp0backend"
|
||||||
|
echo Starting Go backend on port %BACKEND_PORT%...
|
||||||
|
go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.pi\agent" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\src\cli.ts"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
pause >nul
|
||||||
8
run-frontend.bat
Normal file
8
run-frontend.bat
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0frontend"
|
||||||
|
if not exist node_modules (
|
||||||
|
echo Installing npm dependencies...
|
||||||
|
call npm install
|
||||||
|
)
|
||||||
|
echo Starting Vite dev server...
|
||||||
|
call npm run dev
|
||||||
Reference in New Issue
Block a user