feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
69
backend/cmd/server/main.go
Normal file
69
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/handlers"
|
||||
"sproutclaw-web/internal/middleware"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
"sproutclaw-web/internal/static"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.New()
|
||||
|
||||
log.Printf("[sproutclaw-web] port=%d agent-dir=%s", cfg.Port, cfg.AgentDir)
|
||||
|
||||
// Database (stores in agent data dir)
|
||||
dataDir := filepath.Join(cfg.AgentDir, "data", "sproutclaw-web")
|
||||
database, err := db.Init(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("db init: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
log.Printf("[sproutclaw-web] db: %s", database.DbPath)
|
||||
|
||||
// pi CLI RPC client
|
||||
piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.AgentDir, func(code int) {
|
||||
if code != 0 {
|
||||
log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("pi client: %v", err)
|
||||
}
|
||||
defer piClient.Stop()
|
||||
|
||||
// Gin router
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
handlers.RegisterChat(api, piClient)
|
||||
handlers.RegisterSessions(api, cfg, database, piClient)
|
||||
handlers.RegisterModels(api, cfg, piClient)
|
||||
handlers.RegisterCommands(api, cfg)
|
||||
handlers.RegisterWebuiConfig(api, database)
|
||||
handlers.RegisterSettings(api, cfg, database, piClient)
|
||||
handlers.RegisterExtensionUI(api, piClient)
|
||||
|
||||
// Static files (production: frontend/dist/)
|
||||
static.Register(r, cfg.FrontendDist)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", cfg.Port)
|
||||
log.Printf("[sproutclaw-web] listening on http://localhost:%d", cfg.Port)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
45
backend/go.mod
Normal file
45
backend/go.mod
Normal file
@@ -0,0 +1,45 @@
|
||||
module sproutclaw-web
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
modernc.org/sqlite v1.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
129
backend/go.sum
Normal file
129
backend/go.sum
Normal file
@@ -0,0 +1,129 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
|
||||
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
77
backend/internal/config/config.go
Normal file
77
backend/internal/config/config.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
AgentDir string
|
||||
PiCmd string // pi CLI 启动命令(完整路径)
|
||||
PiArgs []string
|
||||
|
||||
// 派生路径(从 AgentDir 推导)
|
||||
SessionsDir string
|
||||
SystemPromptFile string
|
||||
ModelsConfigFile string
|
||||
McpConfigFile string
|
||||
McpCacheFile string
|
||||
SettingsFile string
|
||||
SkillsDir string
|
||||
SkillsDisabledDir string
|
||||
ExtensionsDir string
|
||||
FrontendDist string // ../frontend/dist 相对于可执行文件
|
||||
}
|
||||
|
||||
func New() *Config {
|
||||
port := flag.Int("port", 19133, "HTTP port")
|
||||
agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)")
|
||||
piCmd := flag.String("pi-cmd", "", "pi CLI executable path")
|
||||
flag.Parse()
|
||||
|
||||
if *agentDir == "" {
|
||||
// 默认值:当前目录的 .pi/agent
|
||||
cwd, _ := os.Getwd()
|
||||
*agentDir = filepath.Join(cwd, ".pi", "agent")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: *port,
|
||||
AgentDir: *agentDir,
|
||||
PiCmd: *piCmd,
|
||||
}
|
||||
cfg.derivePaths()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (c *Config) derivePaths() {
|
||||
a := c.AgentDir
|
||||
c.SessionsDir = filepath.Join(a, "sessions")
|
||||
c.SystemPromptFile = filepath.Join(a, "AGENTS.md")
|
||||
c.ModelsConfigFile = filepath.Join(a, "models.json")
|
||||
c.McpConfigFile = filepath.Join(a, "mcp.json")
|
||||
c.McpCacheFile = filepath.Join(a, "mcp-cache.json")
|
||||
c.SettingsFile = filepath.Join(a, "settings.json")
|
||||
c.SkillsDir = filepath.Join(a, "skills")
|
||||
c.SkillsDisabledDir = filepath.Join(a, "skills-disabled")
|
||||
c.ExtensionsDir = filepath.Join(a, "extensions")
|
||||
|
||||
// frontend/dist 相对于二进制所在目录的上级
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
exe, _ = os.Getwd()
|
||||
}
|
||||
exeDir := filepath.Dir(exe)
|
||||
c.FrontendDist = filepath.Join(exeDir, "..", "frontend", "dist")
|
||||
}
|
||||
|
||||
func Platform() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func Arch() string {
|
||||
return runtime.GOARCH
|
||||
}
|
||||
122
backend/internal/db/db.go
Normal file
122
backend/internal/db/db.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
DbPath string
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS webui_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS webui_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func Init(dataDir string) (*DB, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
dbPath := filepath.Join(dataDir, "webui.db")
|
||||
sqlDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
}
|
||||
// ensure schema version
|
||||
_, _ = sqlDB.Exec(`INSERT OR IGNORE INTO webui_meta (key,value) VALUES ('schema_version','1')`)
|
||||
return &DB{sql: sqlDB, DbPath: dbPath}, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() { _ = d.sql.Close() }
|
||||
|
||||
// ── Generic config key-value ──────────────────────────────────────────────────
|
||||
|
||||
func (d *DB) GetConfig(key string) (string, bool) {
|
||||
var val string
|
||||
err := d.sql.QueryRow(`SELECT value FROM webui_config WHERE key=?`, key).Scan(&val)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func (d *DB) SetConfig(key, value string) error {
|
||||
_, err := d.sql.Exec(`INSERT OR REPLACE INTO webui_config (key,value) VALUES (?,?)`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteConfig(key string) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM webui_config WHERE key=?`, key)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) AllConfig() (map[string]string, error) {
|
||||
rows, err := d.sql.Query(`SELECT key,value FROM webui_config`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
m := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Pinned sessions (stored as JSON array in webui_config) ───────────────────
|
||||
|
||||
const pinnedKey = "__pinned_sessions__"
|
||||
|
||||
func (d *DB) GetPinnedSessions() ([]string, error) {
|
||||
val, ok := d.GetConfig(pinnedKey)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var paths []string
|
||||
if err := json.Unmarshal([]byte(val), &paths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetSessionPinned(path string, pinned bool) error {
|
||||
paths, _ := d.GetPinnedSessions()
|
||||
set := map[string]struct{}{}
|
||||
for _, p := range paths {
|
||||
set[p] = struct{}{}
|
||||
}
|
||||
if pinned {
|
||||
set[path] = struct{}{}
|
||||
} else {
|
||||
delete(set, path)
|
||||
}
|
||||
newPaths := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
newPaths = append(newPaths, p)
|
||||
}
|
||||
b, err := json.Marshal(newPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.SetConfig(pinnedKey, string(b))
|
||||
}
|
||||
214
backend/internal/handlers/chat.go
Normal file
214
backend/internal/handlers/chat.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
// RegisterChat registers all chat-related routes.
|
||||
func RegisterChat(r *gin.RouterGroup, piClient *rpc.Client) {
|
||||
r.POST("/chat", handleChat(piClient))
|
||||
r.POST("/steer", handleSteer(piClient))
|
||||
r.POST("/follow-up", handleFollowUp(piClient))
|
||||
r.POST("/bash", handleBash(piClient))
|
||||
r.POST("/abort", handleAbort(piClient))
|
||||
r.POST("/abort-retry", handleAbortRetry(piClient))
|
||||
r.POST("/messages", handleMessages(piClient))
|
||||
r.GET("/events", handleSSE(piClient))
|
||||
}
|
||||
|
||||
func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate images
|
||||
if len(req.Images) > 0 {
|
||||
if err := services.ValidateImages(req.Images); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try slash command dispatch
|
||||
result := services.TrySlashDispatch(req.Message)
|
||||
if result.Handled {
|
||||
if result.Message != "" {
|
||||
// unsupported command – return friendly message
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "message": result.Message})
|
||||
return
|
||||
}
|
||||
// supported built-in command – forward to pi CLI as a command type
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: result.Command,
|
||||
Args: result.Args,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "result": resp.Result})
|
||||
return
|
||||
}
|
||||
|
||||
// Regular prompt
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SteerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err := piClient.SubmitPrompt(models.ChatRequest{
|
||||
Message: req.Message,
|
||||
StreamingBehavior: "steer",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFollowUp(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
req.StreamingBehavior = "follow_up"
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBash(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.BashRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "bash",
|
||||
Args: req.Command,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleMessages(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sessionFile": snap.SessionFile,
|
||||
"events": snap.ReplayEvents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.String(http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan []byte, 64)
|
||||
client := &rpc.SSEClient{
|
||||
Ch: ch,
|
||||
Done: c.Request.Context().Done(),
|
||||
}
|
||||
piClient.RegisterSSEClient(client)
|
||||
defer piClient.UnregisterSSEClient(client)
|
||||
|
||||
// send initial keepalive
|
||||
_, _ = io.WriteString(c.Writer, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case raw, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
frame := rpc.FormatSSEEvent(raw)
|
||||
if _, err := c.Writer.Write(frame); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonRaw is a helper to return pre-serialized JSON.
|
||||
func jsonRaw(raw json.RawMessage) any {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v
|
||||
}
|
||||
18
backend/internal/handlers/commands.go
Normal file
18
backend/internal/handlers/commands.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
// RegisterCommands registers the slash commands list route.
|
||||
func RegisterCommands(r *gin.RouterGroup, cfg *config.Config) {
|
||||
r.GET("/commands", func(c *gin.Context) {
|
||||
cmds := services.ListSlashCommands(cfg.AgentDir)
|
||||
c.JSON(http.StatusOK, models.CommandsResponse{Commands: cmds})
|
||||
})
|
||||
}
|
||||
25
backend/internal/handlers/extension_ui.go
Normal file
25
backend/internal/handlers/extension_ui.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
)
|
||||
|
||||
// RegisterExtensionUI registers the extension UI response route.
|
||||
func RegisterExtensionUI(r *gin.RouterGroup, piClient *rpc.Client) {
|
||||
r.POST("/extension-ui-response", func(c *gin.Context) {
|
||||
var req models.ExtensionUIResponseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := piClient.SendExtensionUIResponse(req.ID, req.Response); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
})
|
||||
}
|
||||
77
backend/internal/handlers/models.go
Normal file
77
backend/internal/handlers/models.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
)
|
||||
|
||||
// RegisterModels registers model-related routes.
|
||||
func RegisterModels(r *gin.RouterGroup, cfg *config.Config, piClient *rpc.Client) {
|
||||
r.GET("/models", handleGetModels(cfg, piClient))
|
||||
r.POST("/model", handleSetModel(piClient))
|
||||
r.POST("/thinking", handleSetThinking(piClient))
|
||||
}
|
||||
|
||||
func handleGetModels(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "list_models"})
|
||||
if err != nil {
|
||||
// fallback: read models.json
|
||||
data, ferr := os.ReadFile(cfg.ModelsConfigFile)
|
||||
if ferr != nil {
|
||||
c.JSON(http.StatusOK, models.ModelsResponse{Models: []models.ModelEntry{}})
|
||||
return
|
||||
}
|
||||
var raw any
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
c.JSON(http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetModel(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SetModelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "set_model",
|
||||
Provider: req.Provider,
|
||||
ModelID: req.ModelID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetThinking(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SetThinkingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "set_thinking",
|
||||
Budget: req.Budget,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
191
backend/internal/handlers/sessions.go
Normal file
191
backend/internal/handlers/sessions.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
"sproutclaw-web/internal/services"
|
||||
"sproutclaw-web/internal/utils"
|
||||
)
|
||||
|
||||
// RegisterSessions registers all session management routes.
|
||||
func RegisterSessions(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) {
|
||||
r.GET("/sessions", handleListSessions(cfg, database))
|
||||
r.POST("/new-session", handleNewSession(piClient))
|
||||
r.POST("/sessions/history", handleSessionHistory(cfg))
|
||||
r.POST("/sessions/delete", handleDeleteSession(cfg, database))
|
||||
r.POST("/sessions/pin", handlePinSession(cfg, database))
|
||||
r.POST("/sessions/load", handleLoadSession(cfg, piClient))
|
||||
r.POST("/sessions/activate", handleActivateSession(cfg, piClient))
|
||||
r.POST("/sessions/name", handleNameSession(cfg))
|
||||
r.GET("/session-state", handleSessionState(piClient))
|
||||
}
|
||||
|
||||
func handleListSessions(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
summaries, err := services.BuildSessionList(cfg.SessionsDir, database)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []models.SessionSummary{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.SessionsResponse{Sessions: summaries})
|
||||
}
|
||||
}
|
||||
|
||||
func handleNewSession(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "new_session"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionHistory(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.HistoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
lines, err := services.ReadSessionMessages(req.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": lines})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteSession(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.DeleteSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(req.Path); err != nil && !os.IsNotExist(err) {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = database.SetSessionPinned(req.Path, false)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handlePinSession(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.PinSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := database.SetSessionPinned(req.Path, req.Pinned); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleLoadSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.LoadSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
lines, err := services.ReadSessionMessages(req.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": lines, "result": jsonRaw(resp.Result)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleActivateSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ActivateSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleNameSession(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.NameSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) {
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
if err := services.AppendSessionName(req.Path, req.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionState(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, models.SessionStateResponse{
|
||||
IsStreaming: snap.IsStreaming,
|
||||
SessionFile: snap.SessionFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
215
backend/internal/handlers/settings.go
Normal file
215
backend/internal/handlers/settings.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
"sproutclaw-web/internal/rpc"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
// RegisterSettings registers all settings-related routes.
|
||||
func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) {
|
||||
r.GET("/settings", handleGetSettings(cfg))
|
||||
r.POST("/settings/skills/toggle", handleToggleSkill(cfg, piClient))
|
||||
r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient))
|
||||
r.POST("/settings/mcp/server/toggle", handleToggleMCPServer(cfg, piClient))
|
||||
r.POST("/settings/mcp/tool/toggle", handleToggleMCPTool(cfg, piClient))
|
||||
r.POST("/settings/reload", handleReload(piClient))
|
||||
r.POST("/settings/models-config", handleSetModelsConfig(cfg, piClient))
|
||||
r.POST("/settings/system-prompt", handleSetSystemPrompt(cfg, piClient))
|
||||
r.POST("/settings/avatars", handleSetAvatars(database))
|
||||
r.GET("/environment", handleEnvironment(cfg))
|
||||
}
|
||||
|
||||
func handleGetSettings(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
skills := services.ListSkills(cfg.AgentDir)
|
||||
extensions := services.ListExtensions(cfg.AgentDir)
|
||||
mcpServers := services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile)
|
||||
systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile)
|
||||
modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile)
|
||||
|
||||
c.JSON(http.StatusOK, models.SettingsResponse{
|
||||
Skills: skills,
|
||||
Extensions: extensions,
|
||||
MCPServers: mcpServers,
|
||||
SystemPrompt: systemPrompt,
|
||||
ModelsConfig: modelsConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleSkill(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleExtension(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleMCPServer(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.MCPServerToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleMCPServer(cfg.McpConfigFile, req.Server, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleMCPTool(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.MCPToolToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleMCPTool(cfg.McpConfigFile, req.Server, req.Tool, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleReload(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetModelsConfig(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ModelsConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.WriteModelsConfig(cfg.ModelsConfigFile, req.Content); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetSystemPrompt(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SystemPromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.WriteSystemPrompt(cfg.SystemPromptFile, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetAvatars(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.AvatarsSetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.UserAvatar != "" {
|
||||
_ = database.SetConfig("userAvatar", req.UserAvatar)
|
||||
}
|
||||
if req.AssistantAvatar != "" {
|
||||
_ = database.SetConfig("assistantAvatar", req.AssistantAvatar)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
lanAddrs := getLANAddresses()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
"version": runtime.Version(),
|
||||
"port": cfg.Port,
|
||||
"lan": lanAddrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func reloadAgent(piClient *rpc.Client) {
|
||||
_, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"})
|
||||
}
|
||||
|
||||
func getLANAddresses() []string {
|
||||
// simplified – returns first non-loopback IPv4
|
||||
var addrs []string
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return addrs
|
||||
}
|
||||
_ = hostname
|
||||
// Use a simple approach: read network interfaces via fmt
|
||||
// For a full implementation, net.InterfaceAddrs() would be used
|
||||
ifaces := getNetworkAddresses()
|
||||
for _, a := range ifaces {
|
||||
if !strings.HasPrefix(a, "127.") && !strings.HasPrefix(a, "::") {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func getNetworkAddresses() []string {
|
||||
var result []string
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname != "" {
|
||||
result = append(result, fmt.Sprintf("%s (hostname)", hostname))
|
||||
}
|
||||
return result
|
||||
}
|
||||
92
backend/internal/handlers/webui_config.go
Normal file
92
backend/internal/handlers/webui_config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// RegisterWebuiConfig registers WebUI config and avatar routes.
|
||||
func RegisterWebuiConfig(r *gin.RouterGroup, database *db.DB) {
|
||||
r.GET("/avatars", handleGetAvatars(database))
|
||||
r.GET("/webui/config", handleGetWebuiConfig(database))
|
||||
r.POST("/webui/config", handleSetWebuiConfig(database))
|
||||
r.POST("/webui/config/delete", handleDeleteWebuiConfig(database))
|
||||
}
|
||||
|
||||
func handleGetAvatars(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, _ := database.GetConfig("userAvatar")
|
||||
assistant, _ := database.GetConfig("assistantAvatar")
|
||||
c.JSON(http.StatusOK, models.AvatarsResponse{
|
||||
UserAvatar: normalizeAvatarURL(user),
|
||||
AssistantAvatar: normalizeAvatarURL(assistant),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAvatarURL(u string) string {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleGetWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
if key != "" {
|
||||
val, _ := database.GetConfig(key)
|
||||
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
|
||||
return
|
||||
}
|
||||
all, err := database.AllConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"entries": all})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.WebuiConfigSetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Entries != nil {
|
||||
for k, v := range req.Entries {
|
||||
if err := database.SetConfig(k, v); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if req.Key != "" {
|
||||
if err := database.SetConfig(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteWebuiConfig(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.WebuiConfigDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := database.DeleteConfig(req.Key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
17
backend/internal/middleware/cors.go
Normal file
17
backend/internal/middleware/cors.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// CORS allows all origins (needed for desktop clients and dev server proxy).
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
263
backend/internal/models/types.go
Normal file
263
backend/internal/models/types.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ── RPC ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RPCCommand struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
// prompt fields
|
||||
Text string `json:"text,omitempty"`
|
||||
Images []ChatImage `json:"images,omitempty"`
|
||||
// model / thinking fields
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
Budget *int `json:"budget,omitempty"`
|
||||
// session fields
|
||||
Path string `json:"path,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// streaming behaviour
|
||||
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
||||
// slash dispatch passthrough
|
||||
Args string `json:"args,omitempty"`
|
||||
// extension ui
|
||||
Response json.RawMessage `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
type RPCResponse struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Raw event line forwarded to SSE clients as-is
|
||||
type RPCEvent struct {
|
||||
Type string `json:"type"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ChatImage struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Data string `json:"data"` // base64
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
Images []ChatImage `json:"images,omitempty"`
|
||||
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
||||
}
|
||||
|
||||
type SteerRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type BashRequest struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type MessagesRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type SessionSummary struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name,omitempty"`
|
||||
FirstMessage string `json:"firstMessage,omitempty"`
|
||||
MessageCount int `json:"messageCount,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
Created string `json:"created,omitempty"`
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
}
|
||||
|
||||
type SessionsResponse struct {
|
||||
Sessions []SessionSummary `json:"sessions"`
|
||||
}
|
||||
|
||||
type SessionStateResponse struct {
|
||||
IsStreaming bool `json:"isStreaming"`
|
||||
SessionFile string `json:"sessionFile,omitempty"`
|
||||
}
|
||||
|
||||
type LoadSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ActivateSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type DeleteSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type PinSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type NameSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type HistoryRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type ModelEntry struct {
|
||||
Provider string `json:"provider"`
|
||||
ModelID string `json:"modelId"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
IsDefault bool `json:"isDefault,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsResponse struct {
|
||||
Models []ModelEntry `json:"models"`
|
||||
Current *ModelEntry `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
type SetModelRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
ModelID string `json:"modelId"`
|
||||
}
|
||||
|
||||
type SetThinkingRequest struct {
|
||||
Budget *int `json:"budget"` // nil = disable
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type SlashCommand struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source"` // builtin | extension | skill | prompt
|
||||
}
|
||||
|
||||
type CommandsResponse struct {
|
||||
Commands []SlashCommand `json:"commands"`
|
||||
}
|
||||
|
||||
// ── WebUI Config ──────────────────────────────────────────────────────────────
|
||||
|
||||
type AvatarsResponse struct {
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
||||
}
|
||||
|
||||
type WebuiConfigResponse struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
// batch response
|
||||
Entries map[string]string `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
type WebuiConfigSetRequest struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
// batch
|
||||
Entries map[string]string `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
type WebuiConfigDeleteRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ExtensionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source"` // local | npm
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type MCPServer struct {
|
||||
Name string `json:"name"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Tools []MCPTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type SettingsResponse struct {
|
||||
Skills []SkillInfo `json:"skills"`
|
||||
Extensions []ExtensionInfo `json:"extensions"`
|
||||
MCPServers []MCPServer `json:"mcpServers"`
|
||||
SystemPrompt string `json:"systemPrompt,omitempty"`
|
||||
ModelsConfig string `json:"modelsConfig,omitempty"`
|
||||
}
|
||||
|
||||
type ToggleRequest struct {
|
||||
ID string `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type MCPServerToggleRequest struct {
|
||||
Server string `json:"server"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type MCPToolToggleRequest struct {
|
||||
Server string `json:"server"`
|
||||
Tool string `json:"tool"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SystemPromptRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ModelsConfigRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type AvatarsSetRequest struct {
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
||||
}
|
||||
|
||||
type EnvironmentResponse struct {
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Hostname string `json:"hostname"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ── Extension UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
type ExtensionUIResponseRequest struct {
|
||||
ID string `json:"id"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
// ── Generic ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type OKResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
304
backend/internal/rpc/client.go
Normal file
304
backend/internal/rpc/client.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
cmdTimeout = 60 * time.Second
|
||||
promptTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// SSEClient represents a connected browser SSE client.
|
||||
type SSEClient struct {
|
||||
Ch chan []byte
|
||||
Done <-chan struct{}
|
||||
}
|
||||
|
||||
// RunSnapshot holds a snapshot of the current pi agent run state.
|
||||
type RunSnapshot struct {
|
||||
IsStreaming bool
|
||||
SessionFile string
|
||||
ReplayEvents []json.RawMessage
|
||||
}
|
||||
|
||||
// Client manages a pi CLI subprocess and bridges its JSON-RPC protocol
|
||||
// to HTTP handlers and SSE clients.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
pending map[string]chan models.RPCResponse
|
||||
sseClients map[*SSEClient]struct{}
|
||||
replay []json.RawMessage // events in current agent run
|
||||
isRunning bool
|
||||
sessionFile string
|
||||
}
|
||||
|
||||
// NewClient starts the pi CLI subprocess and begins reading its stdout.
|
||||
// onExit is called with the exit code when the subprocess terminates.
|
||||
func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) (*Client, error) {
|
||||
if piCmd == "" {
|
||||
return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
|
||||
}
|
||||
|
||||
args := append(piArgs, "--rpc")
|
||||
cmd := exec.Command(piCmd, args...)
|
||||
cmd.Dir = agentDir
|
||||
cmd.Env = append(cmd.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = log.Writer()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start pi cli: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
pending: make(map[string]chan models.RPCResponse),
|
||||
sseClients: make(map[*SSEClient]struct{}),
|
||||
}
|
||||
|
||||
go c.readLoop(stdout)
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
code := 0
|
||||
if cmd.ProcessState != nil {
|
||||
code = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
onExit(code)
|
||||
}()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// readLoop reads stdout line-by-line and dispatches messages.
|
||||
func (c *Client) readLoop(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
c.dispatch(line)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch parses one JSON line from stdout and routes it.
|
||||
func (c *Client) dispatch(raw []byte) {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
switch msg.Type {
|
||||
case "response":
|
||||
var resp models.RPCResponse
|
||||
if err := json.Unmarshal(raw, &resp); err == nil {
|
||||
if ch, ok := c.pending[resp.ID]; ok {
|
||||
delete(c.pending, resp.ID)
|
||||
ch <- resp
|
||||
}
|
||||
}
|
||||
|
||||
case "agent_start":
|
||||
c.isRunning = true
|
||||
c.replay = nil
|
||||
// try to extract session path
|
||||
var ev struct {
|
||||
SessionFile string `json:"sessionFile"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ev); err == nil && ev.SessionFile != "" {
|
||||
c.sessionFile = ev.SessionFile
|
||||
}
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
c.broadcastLocked(rawCopy)
|
||||
|
||||
case "agent_end":
|
||||
c.isRunning = false
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
c.broadcastLocked(rawCopy)
|
||||
|
||||
default:
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
if c.isRunning {
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
}
|
||||
c.broadcastLocked(rawCopy)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu.
|
||||
func (c *Client) broadcastLocked(raw []byte) {
|
||||
for client := range c.sseClients {
|
||||
select {
|
||||
case client.Ch <- raw:
|
||||
default:
|
||||
// slow client: drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send writes a JSON command to the pi CLI stdin.
|
||||
func (c *Client) send(cmd models.RPCCommand) error {
|
||||
b, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err = fmt.Fprintf(c.stdin, "%s\n", b)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendCmd sends a command and waits for the matching response.
|
||||
func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
||||
if cmd.ID == "" {
|
||||
cmd.ID = uuid.New().String()
|
||||
}
|
||||
ch := make(chan models.RPCResponse, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
c.pending[cmd.ID] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.send(cmd); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, cmd.ID)
|
||||
c.mu.Unlock()
|
||||
return models.RPCResponse{}, err
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
case <-time.After(cmdTimeout):
|
||||
c.mu.Lock()
|
||||
delete(c.pending, cmd.ID)
|
||||
c.mu.Unlock()
|
||||
return models.RPCResponse{}, fmt.Errorf("rpc timeout: %s", cmd.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitPrompt sends a prompt without waiting for a response.
|
||||
func (c *Client) SubmitPrompt(req models.ChatRequest) error {
|
||||
cmd := models.RPCCommand{
|
||||
Type: "prompt",
|
||||
ID: uuid.New().String(),
|
||||
Text: req.Message,
|
||||
Images: req.Images,
|
||||
StreamingBehavior: req.StreamingBehavior,
|
||||
}
|
||||
return c.send(cmd)
|
||||
}
|
||||
|
||||
// GetSnapshot returns the current run snapshot.
|
||||
func (c *Client) GetSnapshot() RunSnapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
replay := make([]json.RawMessage, len(c.replay))
|
||||
copy(replay, c.replay)
|
||||
return RunSnapshot{
|
||||
IsStreaming: c.isRunning,
|
||||
SessionFile: c.sessionFile,
|
||||
ReplayEvents: replay,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSSEClient adds a client to the broadcast set and replays buffered events.
|
||||
func (c *Client) RegisterSSEClient(client *SSEClient) {
|
||||
c.mu.Lock()
|
||||
c.sseClients[client] = struct{}{}
|
||||
// replay current run events
|
||||
replay := make([]json.RawMessage, len(c.replay))
|
||||
copy(replay, c.replay)
|
||||
c.mu.Unlock()
|
||||
|
||||
// send replay in a goroutine so we don't hold the lock
|
||||
go func() {
|
||||
for _, ev := range replay {
|
||||
select {
|
||||
case client.Ch <- ev:
|
||||
case <-client.Done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// UnregisterSSEClient removes a client from the broadcast set.
|
||||
func (c *Client) UnregisterSSEClient(client *SSEClient) {
|
||||
c.mu.Lock()
|
||||
delete(c.sseClients, client)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendExtensionUIResponse forwards a UI response back to the pi CLI.
|
||||
func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error {
|
||||
return c.send(models.RPCCommand{
|
||||
Type: "extension_ui_response",
|
||||
ID: id,
|
||||
Response: response,
|
||||
})
|
||||
}
|
||||
|
||||
// Stop terminates the pi CLI subprocess.
|
||||
func (c *Client) Stop() {
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// buildEnv returns os.Environ() with extra variables appended.
|
||||
func (c *Client) Environ() []string {
|
||||
return c.cmd.Environ()
|
||||
}
|
||||
|
||||
// helper
|
||||
func mustMarshal(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// IsRunning returns true when the agent is actively streaming.
|
||||
func (c *Client) IsRunning() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.isRunning
|
||||
}
|
||||
|
||||
// FormatSSEEvent formats a raw JSON payload as an SSE data line.
|
||||
func FormatSSEEvent(raw []byte) []byte {
|
||||
// strip newlines so the SSE frame stays on one logical line
|
||||
trimmed := strings.TrimRight(string(raw), "\n\r")
|
||||
return []byte("data: " + trimmed + "\n\n")
|
||||
}
|
||||
91
backend/internal/services/commands.go
Normal file
91
backend/internal/services/commands.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// builtinCommands are the slash commands the WebUI handles natively.
|
||||
var builtinCommands = []models.SlashCommand{
|
||||
{Name: "new", Description: "开启新会话", Source: "builtin"},
|
||||
{Name: "compact", Description: "压缩会话历史", Source: "builtin"},
|
||||
{Name: "reload", Description: "重载配置", Source: "builtin"},
|
||||
{Name: "clone", Description: "克隆当前会话", Source: "builtin"},
|
||||
{Name: "name", Description: "重命名当前会话", Source: "builtin"},
|
||||
{Name: "model", Description: "切换模型", Source: "builtin"},
|
||||
{Name: "session", Description: "显示会话统计", Source: "builtin"},
|
||||
{Name: "export", Description: "导出为 HTML", Source: "builtin"},
|
||||
{Name: "copy", Description: "复制最后一条助手消息", Source: "builtin"},
|
||||
{Name: "webui", Description: "WebUI 控制", Source: "builtin"},
|
||||
}
|
||||
|
||||
// ListSlashCommands aggregates all slash commands visible in the WebUI.
|
||||
func ListSlashCommands(agentDir string) []models.SlashCommand {
|
||||
cmds := make([]models.SlashCommand, len(builtinCommands))
|
||||
copy(cmds, builtinCommands)
|
||||
|
||||
// scan skills dir for SKILL.md files
|
||||
skillsDir := filepath.Join(agentDir, "skills")
|
||||
cmds = append(cmds, scanSkillCommands(skillsDir)...)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func scanSkillCommands(skillsDir string) []models.SlashCommand {
|
||||
entries, err := os.ReadDir(skillsDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cmds []models.SlashCommand
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
skillMd := filepath.Join(skillsDir, e.Name(), "SKILL.md")
|
||||
name, desc := parseSkillMD(skillMd)
|
||||
if name == "" {
|
||||
name = e.Name()
|
||||
}
|
||||
cmds = append(cmds, models.SlashCommand{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Source: "skill",
|
||||
})
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func parseSkillMD(path string) (name, desc string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
content := string(data)
|
||||
// parse frontmatter between --- delimiters
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(content, "---", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", ""
|
||||
}
|
||||
fm := parts[1]
|
||||
for _, line := range strings.Split(fm, "\n") {
|
||||
kv := strings.SplitN(line, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(kv[0])
|
||||
v := strings.TrimSpace(kv[1])
|
||||
switch k {
|
||||
case "name":
|
||||
name = v
|
||||
case "description":
|
||||
desc = v
|
||||
}
|
||||
}
|
||||
return name, desc
|
||||
}
|
||||
105
backend/internal/services/extensions.go
Normal file
105
backend/internal/services/extensions.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListExtensions reads the extensions directory and returns metadata.
|
||||
func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||
extDir := filepath.Join(agentDir, "extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exts []models.ExtensionInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := buildExtensionInfo(filepath.Join(extDir, e.Name()), e.Name())
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
return exts
|
||||
}
|
||||
|
||||
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info := models.ExtensionInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Enabled: true, // assume enabled unless configured otherwise
|
||||
Source: "local",
|
||||
}
|
||||
|
||||
// try package.json for version/name
|
||||
pkgPath := filepath.Join(path, "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
|
||||
// detect npm packages
|
||||
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
|
||||
info.Source = "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ToggleExtension is a placeholder – full implementation requires settings.json management.
|
||||
func ToggleExtension(agentDir, id string, enable bool) error {
|
||||
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
|
||||
}
|
||||
|
||||
func updateSettingsExtensions(settingsPath, id string, enable bool) error {
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
|
||||
// extensions is a list of enabled extension IDs
|
||||
extList, _ := settings["extensions"].([]any)
|
||||
set := map[string]bool{}
|
||||
for _, e := range extList {
|
||||
if s, ok := e.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
set[id] = true
|
||||
} else {
|
||||
delete(set, id)
|
||||
}
|
||||
|
||||
newList := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
settings["extensions"] = newList
|
||||
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
||||
}
|
||||
42
backend/internal/services/images.go
Normal file
42
backend/internal/services/images.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImages = 8
|
||||
maxImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
)
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
// ValidateImages checks image count, type, and base64-decoded size.
|
||||
func ValidateImages(images []models.ChatImage) error {
|
||||
if len(images) > maxImages {
|
||||
return fmt.Errorf("最多支持 %d 张图片,当前 %d 张", maxImages, len(images))
|
||||
}
|
||||
for i, img := range images {
|
||||
mt := strings.ToLower(img.MediaType)
|
||||
if !allowedImageTypes[mt] {
|
||||
return fmt.Errorf("图片 %d 类型不支持: %s", i+1, img.MediaType)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(img.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("图片 %d base64 解码失败", i+1)
|
||||
}
|
||||
if len(decoded) > maxImageSize {
|
||||
return fmt.Errorf("图片 %d 超过 10MB 限制", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
133
backend/internal/services/mcp.go
Normal file
133
backend/internal/services/mcp.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
type mcpConfig struct {
|
||||
MCPServers map[string]any `json:"mcpServers"`
|
||||
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
|
||||
ExcludeTools []string `json:"excludeTools,omitempty"`
|
||||
}
|
||||
|
||||
// ReadMCPServers loads mcp.json and returns the list of servers with their tools.
|
||||
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
cache := loadMCPCache(mcpCachePath)
|
||||
|
||||
disabledSet := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
disabledSet[s] = true
|
||||
}
|
||||
excludeSet := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
excludeSet[t] = true
|
||||
}
|
||||
|
||||
var servers []models.MCPServer
|
||||
for name := range cfg.MCPServers {
|
||||
srv := models.MCPServer{
|
||||
Name: name,
|
||||
Disabled: disabledSet[name],
|
||||
}
|
||||
// add tools from cache
|
||||
if toolNames, ok := cache[name]; ok {
|
||||
for _, t := range toolNames {
|
||||
srv.Tools = append(srv.Tools, models.MCPTool{
|
||||
Name: t,
|
||||
Disabled: excludeSet[name+"/"+t] || excludeSet[t],
|
||||
})
|
||||
}
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// ToggleMCPServer enables or disables a server.
|
||||
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
set := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
set[s] = true
|
||||
}
|
||||
if enable {
|
||||
delete(set, serverName)
|
||||
} else {
|
||||
set[serverName] = true
|
||||
}
|
||||
cfg.MCPServersDisabled = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
}
|
||||
|
||||
// ToggleMCPTool enables or disables a specific tool on a server.
|
||||
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
key := serverName + "/" + toolName
|
||||
set := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
set[t] = true
|
||||
}
|
||||
if enable {
|
||||
delete(set, key)
|
||||
} else {
|
||||
set[key] = true
|
||||
}
|
||||
cfg.ExcludeTools = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
}
|
||||
|
||||
func loadMCPConfig(path string) mcpConfig {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return mcpConfig{MCPServers: map[string]any{}}
|
||||
}
|
||||
var cfg mcpConfig
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if cfg.MCPServers == nil {
|
||||
cfg.MCPServers = map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func saveMCPConfig(path string, cfg mcpConfig) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func loadMCPCache(path string) map[string][]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// cache format: { "serverName": { "tools": [{ "name": "..." }] } }
|
||||
var raw map[string]struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := map[string][]string{}
|
||||
for srv, info := range raw {
|
||||
for _, t := range info.Tools {
|
||||
result[srv] = append(result[srv], t.Name)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
28
backend/internal/services/models_config.go
Normal file
28
backend/internal/services/models_config.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ReadModelsConfig reads models.json as a raw string.
|
||||
func ReadModelsConfig(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "{}", nil
|
||||
}
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// WriteModelsConfig validates JSON and writes models.json.
|
||||
func WriteModelsConfig(path, content string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(content), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
pretty, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(pretty, '\n'), 0o644)
|
||||
}
|
||||
171
backend/internal/services/sessions.go
Normal file
171
backend/internal/services/sessions.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// jsonlLine is a minimal parse of one line in a session JSONL file.
|
||||
type jsonlLine struct {
|
||||
Type string `json:"type"`
|
||||
// session header
|
||||
ID string `json:"id"`
|
||||
Created string `json:"created"`
|
||||
// message
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
// session_info
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
|
||||
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pinned, _ := database.GetPinnedSessions()
|
||||
pinnedSet := map[string]struct{}{}
|
||||
for _, p := range pinned {
|
||||
pinnedSet[p] = struct{}{}
|
||||
}
|
||||
|
||||
var summaries []models.SessionSummary
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(sessionsDir, e.Name())
|
||||
sum, err := readSessionSummary(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, sum.Pinned = pinnedSet[path]
|
||||
sum.Path = path
|
||||
summaries = append(summaries, sum)
|
||||
}
|
||||
|
||||
// Sort: pinned first, then by modified desc
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
pi, pj := summaries[i].Pinned, summaries[j].Pinned
|
||||
if pi != pj {
|
||||
return pi
|
||||
}
|
||||
return summaries[i].Modified > summaries[j].Modified
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func readSessionSummary(path string) (models.SessionSummary, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return models.SessionSummary{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, _ := f.Stat()
|
||||
modified := ""
|
||||
if info != nil {
|
||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
var sum models.SessionSummary
|
||||
sum.Modified = modified
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
|
||||
msgCount := 0
|
||||
for scanner.Scan() {
|
||||
var line jsonlLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||
continue
|
||||
}
|
||||
switch line.Type {
|
||||
case "session":
|
||||
sum.Created = line.Created
|
||||
case "session_info":
|
||||
if line.Name != "" {
|
||||
sum.Name = line.Name
|
||||
}
|
||||
case "message":
|
||||
msgCount++
|
||||
if sum.FirstMessage == "" && line.Role == "user" {
|
||||
sum.FirstMessage = extractTextContent(line.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
sum.MessageCount = msgCount
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func extractTextContent(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return truncate(v, 120)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
if m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
return truncate(t, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
// ReadSessionMessages reads all lines from a JSONL session file.
|
||||
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []json.RawMessage
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
raw := append([]byte(nil), scanner.Bytes()...)
|
||||
lines = append(lines, raw)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// AppendSessionName appends a session_info rename record to the JSONL file.
|
||||
func AppendSessionName(path, name string) error {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
record := map[string]string{"type": "session_info", "name": name}
|
||||
b, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
72
backend/internal/services/skills.go
Normal file
72
backend/internal/services/skills.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListSkills returns all skills (enabled and disabled) under agentDir.
|
||||
func ListSkills(agentDir string) []models.SkillInfo {
|
||||
var skills []models.SkillInfo
|
||||
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills"), true)...)
|
||||
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills-disabled"), false)...)
|
||||
return skills
|
||||
}
|
||||
|
||||
func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var skills []models.SkillInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
skillPath := filepath.Join(dir, e.Name())
|
||||
name, desc := parseSkillMD(filepath.Join(skillPath, "SKILL.md"))
|
||||
if name == "" {
|
||||
name = e.Name()
|
||||
}
|
||||
skills = append(skills, models.SkillInfo{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Enabled: enabled,
|
||||
Path: skillPath,
|
||||
})
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
// ToggleSkill moves a skill between skills/ and skills-disabled/ directories.
|
||||
func ToggleSkill(agentDir, skillPath string, enable bool) error {
|
||||
skillsDir := filepath.Join(agentDir, "skills")
|
||||
disabledDir := filepath.Join(agentDir, "skills-disabled")
|
||||
|
||||
if !isUnderDir(skillPath, skillsDir) && !isUnderDir(skillPath, disabledDir) {
|
||||
return fmt.Errorf("skill path not in managed directories")
|
||||
}
|
||||
|
||||
name := filepath.Base(skillPath)
|
||||
var src, dst string
|
||||
if enable {
|
||||
src = filepath.Join(disabledDir, name)
|
||||
dst = filepath.Join(skillsDir, name)
|
||||
} else {
|
||||
src = filepath.Join(skillsDir, name)
|
||||
dst = filepath.Join(disabledDir, name)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
func isUnderDir(path, dir string) bool {
|
||||
return strings.HasPrefix(filepath.Clean(path), filepath.Clean(dir))
|
||||
}
|
||||
57
backend/internal/services/slash_dispatch.go
Normal file
57
backend/internal/services/slash_dispatch.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supportedWebUICommands is the whitelist of commands the WebUI handles.
|
||||
var supportedWebUICommands = map[string]bool{
|
||||
"compact": true,
|
||||
"new": true,
|
||||
"reload": true,
|
||||
"clone": true,
|
||||
"name": true,
|
||||
"model": true,
|
||||
"session": true,
|
||||
"export": true,
|
||||
"copy": true,
|
||||
}
|
||||
|
||||
// SlashDispatchResult holds the parsed result of a slash command attempt.
|
||||
type SlashDispatchResult struct {
|
||||
Handled bool
|
||||
Command string
|
||||
Args string
|
||||
// For unsupported commands, return a friendly message
|
||||
Message string
|
||||
}
|
||||
|
||||
// TrySlashDispatch checks if the message is a slash command the WebUI supports.
|
||||
func TrySlashDispatch(message string) SlashDispatchResult {
|
||||
if !strings.HasPrefix(message, "/") {
|
||||
return SlashDispatchResult{}
|
||||
}
|
||||
// strip leading slash and split
|
||||
trimmed := strings.TrimPrefix(message, "/")
|
||||
parts := strings.SplitN(trimmed, " ", 2)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
if !supportedWebUICommands[cmd] {
|
||||
return SlashDispatchResult{
|
||||
Handled: true,
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Message: "该命令在 WebUI 模式下暂不支持,请在终端中使用",
|
||||
}
|
||||
}
|
||||
|
||||
return SlashDispatchResult{
|
||||
Handled: true,
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
17
backend/internal/services/system_prompt.go
Normal file
17
backend/internal/services/system_prompt.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package services
|
||||
|
||||
import "os"
|
||||
|
||||
// ReadSystemPrompt reads AGENTS.md content.
|
||||
func ReadSystemPrompt(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// WriteSystemPrompt writes AGENTS.md content.
|
||||
func WriteSystemPrompt(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
68
backend/internal/static/static.go
Normal file
68
backend/internal/static/static.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Register attaches the static file handler to the Gin router.
|
||||
// distDir is the path to frontend/dist/.
|
||||
func Register(r *gin.Engine, distDir string) {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Skip API routes – they're handled elsewhere
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Try to serve a real file
|
||||
fsPath := filepath.Join(distDir, filepath.FromSlash(path))
|
||||
if info, err := os.Stat(fsPath); err == nil && !info.IsDir() {
|
||||
serveFile(c, fsPath)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html
|
||||
indexPath := filepath.Join(distDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err != nil {
|
||||
c.String(http.StatusNotFound, "frontend not built – run npm run build:web in frontend/")
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.File(indexPath)
|
||||
})
|
||||
}
|
||||
|
||||
func serveFile(c *gin.Context, fsPath string) {
|
||||
name := filepath.Base(fsPath)
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
|
||||
if name == "index.html" || strings.HasSuffix(name, ".webmanifest") {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
} else if isHashedAsset(name, ext) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else if ext == ".woff" || ext == ".woff2" || ext == ".ttf" || ext == ".otf" {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
|
||||
// Gin's File() handles Content-Type and ETag automatically
|
||||
c.File(fsPath)
|
||||
}
|
||||
|
||||
// isHashedAsset returns true if the filename looks like a Vite hashed asset
|
||||
// (e.g. index-BxYt3k2a.js, chunk-abc123.css).
|
||||
func isHashedAsset(name, ext string) bool {
|
||||
switch ext {
|
||||
case ".js", ".css", ".mjs":
|
||||
return strings.Contains(name, "-") || strings.Contains(name, ".")
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
backend/internal/utils/paths.go
Normal file
25
backend/internal/utils/paths.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsPathInsideRoot returns true if candidate is inside (or equal to) root.
|
||||
// Both paths are cleaned and compared case-insensitively on Windows.
|
||||
func IsPathInsideRoot(root, candidate string) bool {
|
||||
root = filepath.Clean(root)
|
||||
candidate = filepath.Clean(candidate)
|
||||
// normalize separators
|
||||
root = filepath.ToSlash(root)
|
||||
candidate = filepath.ToSlash(candidate)
|
||||
// case-insensitive on Windows
|
||||
rootLow := strings.ToLower(root)
|
||||
candLow := strings.ToLower(candidate)
|
||||
return candLow == rootLow || strings.HasPrefix(candLow, rootLow+"/")
|
||||
}
|
||||
|
||||
// IsSameResolvedPath compares two paths after cleaning.
|
||||
func IsSameResolvedPath(a, b string) bool {
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
Reference in New Issue
Block a user