feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
backend/sproutclaw-web
|
||||||
|
backend/sproutclaw-web.exe
|
||||||
|
frontend/dist/
|
||||||
|
frontend/dist-desktop/
|
||||||
|
frontend/node_modules/
|
||||||
|
*.db
|
||||||
|
*.pid
|
||||||
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)
|
||||||
|
}
|
||||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist/
|
||||||
|
dist-desketop/
|
||||||
20
frontend/index.html
Normal file
20
frontend/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#f7f8fb" />
|
||||||
|
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="萌小芽" />
|
||||||
|
<title>萌小芽</title>
|
||||||
|
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6816
frontend/package-lock.json
generated
Normal file
6816
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "sproutclaw-webui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"generate-icons": "node scripts/generate-icons.mjs",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "npm run generate-icons && tsc -b && vite build && vite build --mode desktop",
|
||||||
|
"build:web": "npm run generate-icons && tsc -b && vite build",
|
||||||
|
"build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"highlight.js": "11.11.1",
|
||||||
|
"marked": "15.0.12",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"react-router-dom": "7.15.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mogeko/maple-mono-cn": "7.9.0",
|
||||||
|
"@types/react": "19.2.15",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "6.4.2",
|
||||||
|
"vite-plugin-pwa": "1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
22
frontend/scripts/generate-icons.mjs
Normal file
22
frontend/scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = join(__dirname, "..", "public");
|
||||||
|
const source = join(publicDir, "logo.png");
|
||||||
|
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
console.warn("[generate-icons] logo.png not found, skipping");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
const out = join(publicDir, `logo${size}.png`);
|
||||||
|
await sharp(source)
|
||||||
|
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toFile(out);
|
||||||
|
console.log(`[generate-icons] wrote ${out}`);
|
||||||
|
}
|
||||||
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||||
|
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||||
|
import { AvatarProvider } from "./context/AvatarContext";
|
||||||
|
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||||
|
import { ChatProvider } from "./context/ChatContext";
|
||||||
|
import { ChatPage } from "./routes/ChatPage";
|
||||||
|
import { SettingsPage } from "./routes/SettingsPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<BootstrapProvider>
|
||||||
|
<AvatarProvider>
|
||||||
|
<ChatProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ChatLayout />}>
|
||||||
|
<Route index element={<ChatPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
<PwaUpdatePrompt />
|
||||||
|
</ChatProvider>
|
||||||
|
</AvatarProvider>
|
||||||
|
</BootstrapProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/api/base.ts
Normal file
13
frontend/src/api/base.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const API_BASE = (import.meta.env.VITE_API_BASE ?? "").trim().replace(/\/$/, "");
|
||||||
|
|
||||||
|
/** Resolve an API path. Empty base keeps same-origin relative paths for web `dist/`. */
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
throw new Error(`API path must start with /: ${path}`);
|
||||||
|
}
|
||||||
|
return API_BASE ? `${API_BASE}${path}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiBase(): string {
|
||||||
|
return API_BASE;
|
||||||
|
}
|
||||||
71
frontend/src/api/chat.ts
Normal file
71
frontend/src/api/chat.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
|
||||||
|
import type { BashResult } from "../utils/bash";
|
||||||
|
import type { NewSessionResponse } from "../types/session";
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
accepted?: boolean;
|
||||||
|
slash?: boolean;
|
||||||
|
message?: string;
|
||||||
|
action?: "new_session" | "reload_messages" | "reload_sessions" | "copy";
|
||||||
|
sessionFile?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendMode = "prompt" | "steer" | "follow_up";
|
||||||
|
|
||||||
|
export function sendChat(
|
||||||
|
message: string,
|
||||||
|
images?: ImageContent[],
|
||||||
|
options?: { streamingBehavior?: "steer" | "followUp" },
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
return apiPost("/api/chat", { message, images, streamingBehavior: options?.streamingBehavior });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSteer(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/steer", { message, images });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendFollowUp(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/follow-up", { message, images });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runBash(command: string, options?: { excludeFromContext?: boolean }): Promise<BashResult> {
|
||||||
|
return apiPost("/api/bash", { command, excludeFromContext: options?.excludeFromContext });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abortChat(): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abortRetry(): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/abort-retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendExtensionUiResponse(
|
||||||
|
id: string,
|
||||||
|
response: Record<string, unknown>,
|
||||||
|
): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/extension-ui-response", { id, response });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSessionState(): Promise<SessionState> {
|
||||||
|
return apiGet("/api/session-state");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
|
||||||
|
return apiGet("/api/models");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/model", { provider, modelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/thinking", { level });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNewSession(): Promise<NewSessionResponse> {
|
||||||
|
return apiPost("/api/new-session");
|
||||||
|
}
|
||||||
32
frontend/src/api/client.ts
Normal file
32
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { apiUrl } from "./base";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status?: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(apiUrl(url), init);
|
||||||
|
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||||
|
if (!res.ok || (data && "error" in data && data.error)) {
|
||||||
|
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
return apiFetch<T>(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(url: string): Promise<T> {
|
||||||
|
return apiFetch<T>(url);
|
||||||
|
}
|
||||||
6
frontend/src/api/commands.ts
Normal file
6
frontend/src/api/commands.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiGet } from "./client";
|
||||||
|
import type { SlashCommandsResponse } from "../types/commands";
|
||||||
|
|
||||||
|
export function fetchSlashCommands(): Promise<SlashCommandsResponse> {
|
||||||
|
return apiGet("/api/commands");
|
||||||
|
}
|
||||||
59
frontend/src/api/sessions.ts
Normal file
59
frontend/src/api/sessions.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import { apiUrl } from "./base";
|
||||||
|
import type { SessionState } from "../types/message";
|
||||||
|
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||||
|
|
||||||
|
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
|
||||||
|
return apiGet("/api/sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
|
||||||
|
return apiPost("/api/sessions/history", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateSessionBackend(path: string): Promise<SessionState | null> {
|
||||||
|
const activateRes = await fetch(apiUrl("/api/sessions/activate"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
const activateData = (await activateRes.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
state?: SessionState;
|
||||||
|
};
|
||||||
|
if (activateRes.ok && !activateData.error) {
|
||||||
|
return activateData.state ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateError = activateData.error || activateRes.statusText || "Unknown error";
|
||||||
|
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
|
||||||
|
throw new Error(activateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRes = await fetch(apiUrl("/api/sessions/load"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
if (!fallbackRes.ok || fallbackData.error) {
|
||||||
|
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||||
|
return apiPost("/api/sessions/load", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/sessions/delete", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/sessions/name", { path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
|
||||||
|
return apiPost("/api/sessions/pin", { path, pinned });
|
||||||
|
}
|
||||||
56
frontend/src/api/settings.ts
Normal file
56
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import type { AvatarSettings, EnvironmentInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
|
||||||
|
|
||||||
|
export function fetchSettings(): Promise<SettingsData> {
|
||||||
|
return apiGet("/api/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSkill(path: string, enabled: boolean): Promise<{ ok?: boolean; skill?: SkillInfo }> {
|
||||||
|
return apiPost("/api/settings/skills/toggle", { path, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleExtension(
|
||||||
|
path: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ ok?: boolean; extension?: ExtensionInfo }> {
|
||||||
|
return apiPost("/api/settings/extensions/toggle", { path, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMcpServer(
|
||||||
|
server: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ ok?: boolean; server?: McpServerInfo }> {
|
||||||
|
return apiPost("/api/settings/mcp/server/toggle", { server, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMcpTool(
|
||||||
|
server: string,
|
||||||
|
tool: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ ok?: boolean; tool?: McpToolInfo }> {
|
||||||
|
return apiPost("/api/settings/mcp/tool/toggle", { server, tool, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAvatars(): Promise<AvatarSettings> {
|
||||||
|
return apiGet("/api/avatars");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadSettings(): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/settings/reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
|
||||||
|
return apiPost("/api/settings/system-prompt", { systemPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPath?: string }> {
|
||||||
|
return apiPost("/api/settings/models-config", { modelsConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||||
|
return apiPost("/api/settings/avatars", avatars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
||||||
|
return apiGet("/api/environment");
|
||||||
|
}
|
||||||
25
frontend/src/api/webuiConfig.ts
Normal file
25
frontend/src/api/webuiConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
|
||||||
|
export interface WebuiConfigResponse {
|
||||||
|
config?: Record<string, string>;
|
||||||
|
dbPath?: string;
|
||||||
|
key?: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
|
||||||
|
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||||
|
return apiGet(`/api/webui/config${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
|
||||||
|
return apiPost("/api/webui/config", { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
|
||||||
|
return apiPost("/api/webui/config", { entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
|
||||||
|
return apiPost("/api/webui/config/delete", { key });
|
||||||
|
}
|
||||||
93
frontend/src/components/chat/BashOutputBlock.module.css
Normal file
93
frontend/src/components/chat/BashOutputBlock.module.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
.block {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockDim .header {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockDim .command,
|
||||||
|
.blockDim .output {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e8ecf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6366f1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: inherit;
|
||||||
|
color: #374151;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: min(420px, 50vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outputEmpty {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outputStreaming::after {
|
||||||
|
content: "▊";
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: #6366f1;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 1px solid #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: inherit;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/src/components/chat/BashOutputBlock.tsx
Normal file
41
frontend/src/components/chat/BashOutputBlock.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import styles from "./BashOutputBlock.module.css";
|
||||||
|
|
||||||
|
interface BashOutputBlockProps {
|
||||||
|
command?: string;
|
||||||
|
content: string;
|
||||||
|
exitCode?: number;
|
||||||
|
streaming?: boolean;
|
||||||
|
excludeFromContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BashOutputBlock({
|
||||||
|
command,
|
||||||
|
content,
|
||||||
|
exitCode,
|
||||||
|
streaming,
|
||||||
|
excludeFromContext,
|
||||||
|
}: BashOutputBlockProps) {
|
||||||
|
const hasOutput = Boolean(content.trim());
|
||||||
|
const showExit = exitCode !== undefined && exitCode !== 0 && !streaming;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.block} ${excludeFromContext ? styles.blockDim : ""}`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.icon} aria-hidden="true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="4 17 10 11 4 5" />
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={styles.prompt}>$</span>
|
||||||
|
<span className={styles.command}>{command || "shell"}</span>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className={`${styles.output} ${!hasOutput ? styles.outputEmpty : ""} ${streaming ? styles.outputStreaming : ""}`}
|
||||||
|
>
|
||||||
|
{hasOutput ? content : streaming ? "执行中..." : "(无输出)"}
|
||||||
|
</pre>
|
||||||
|
{showExit ? <div className={styles.exit}>退出码 {exitCode}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/components/chat/ChatInput.module.css
Normal file
153
frontend/src/components/chat/ChatInput.module.css
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
.inputArea {
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputAreaDragOver {
|
||||||
|
background: rgba(239, 246, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentItem {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentThumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentRemove {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #f6f7fb;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px 4px 4px 8px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn:hover:not(:disabled) {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #2563eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:disabled {
|
||||||
|
background: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputArea {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding: 2px 2px 2px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
345
frontend/src/components/chat/ChatInput.tsx
Normal file
345
frontend/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||||
|
import { fetchSlashCommands } from "../../api/commands";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { SlashCommand } from "../../types/commands";
|
||||||
|
import type { ImageContent } from "../../types/message";
|
||||||
|
import { isTouchLike } from "../../utils/format";
|
||||||
|
import {
|
||||||
|
clipboardItemsToImages,
|
||||||
|
fileToImageContent,
|
||||||
|
imageToDataUrl,
|
||||||
|
MAX_CHAT_IMAGES,
|
||||||
|
} from "../../utils/images";
|
||||||
|
import { parseBashInput } from "../../utils/bash";
|
||||||
|
import {
|
||||||
|
applySlashCompletion,
|
||||||
|
filterSlashCommands,
|
||||||
|
filterWebUiSlashCommands,
|
||||||
|
parseSlashCommandInput,
|
||||||
|
} from "../../utils/slashCommands";
|
||||||
|
import { SlashCommandMenu } from "./SlashCommandMenu";
|
||||||
|
import styles from "./ChatInput.module.css";
|
||||||
|
|
||||||
|
export function ChatInput() {
|
||||||
|
const {
|
||||||
|
isStreaming,
|
||||||
|
sendMessage,
|
||||||
|
runBashCommand,
|
||||||
|
addSystemMessage,
|
||||||
|
abortStream,
|
||||||
|
toggleToolsExpanded,
|
||||||
|
} = useChatContext();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
|
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const touchLike = isTouchLike();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchSlashCommands()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setSlashCommands(filterWebUiSlashCommands(data.commands || []));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [addSystemMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (!inputRef.current || document.activeElement !== inputRef.current) return;
|
||||||
|
if (e.key === "Escape" && isStreaming) {
|
||||||
|
e.preventDefault();
|
||||||
|
void abortStream();
|
||||||
|
}
|
||||||
|
if (e.key === "o" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleToolsExpanded();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [isStreaming, abortStream, toggleToolsExpanded]);
|
||||||
|
|
||||||
|
const slashContext = useMemo(() => parseSlashCommandInput(value), [value]);
|
||||||
|
const filteredSlashCommands = useMemo(
|
||||||
|
() => (slashContext ? filterSlashCommands(slashCommands, slashContext.query) : []),
|
||||||
|
[slashCommands, slashContext],
|
||||||
|
);
|
||||||
|
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSlashIndex(0);
|
||||||
|
}, [value, slashMenuOpen]);
|
||||||
|
|
||||||
|
const canSend = value.trim().length > 0 || pendingImages.length > 0;
|
||||||
|
|
||||||
|
const applySlashSelection = (command: SlashCommand) => {
|
||||||
|
const nextValue = applySlashCompletion(value, command.name);
|
||||||
|
setValue(nextValue);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(nextValue.length, nextValue.length);
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImages = async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files);
|
||||||
|
if (!list.length) return;
|
||||||
|
|
||||||
|
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ImageContent[] = [];
|
||||||
|
for (const file of list.slice(0, remaining)) {
|
||||||
|
try {
|
||||||
|
next.push(await fileToImageContent(file));
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length) {
|
||||||
|
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePendingImage = (index: number) => {
|
||||||
|
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async (mode: "prompt" | "steer" | "follow_up" = "prompt") => {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text && pendingImages.length === 0) return;
|
||||||
|
|
||||||
|
const images = pendingImages.length ? pendingImages : undefined;
|
||||||
|
setValue("");
|
||||||
|
setPendingImages([]);
|
||||||
|
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||||
|
|
||||||
|
if (text.startsWith("!") && parseBashInput(text)) {
|
||||||
|
await runBashCommand(text);
|
||||||
|
} else {
|
||||||
|
const sendMode = isStreaming && mode === "prompt" ? "steer" : mode;
|
||||||
|
await sendMessage(text, images, { mode: sendMode });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchLike) inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.nativeEvent.isComposing) return;
|
||||||
|
|
||||||
|
if (slashMenuOpen) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedSlashIndex(
|
||||||
|
(prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
const command = filteredSlashCommands[selectedSlashIndex];
|
||||||
|
if (command) applySlashSelection(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setValue((prev) => {
|
||||||
|
const trimmed = prev.trimStart();
|
||||||
|
if (!trimmed.startsWith("/")) return prev;
|
||||||
|
return prev.replace(/^\s*\//, "");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const command = filteredSlashCommands[selectedSlashIndex];
|
||||||
|
if (command) applySlashSelection(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend("follow_up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend(isStreaming ? "steer" : "prompt");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
|
const native = e.nativeEvent as InputEvent;
|
||||||
|
if (touchLike && native.inputType === "insertLineBreak") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend(isStreaming ? "steer" : "prompt");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
|
||||||
|
if (!hasImage) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
void clipboardItemsToImages(items)
|
||||||
|
.then((images) => {
|
||||||
|
if (!images.length) return;
|
||||||
|
setPendingImages((prev) => {
|
||||||
|
const remaining = MAX_CHAT_IMAGES - prev.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, ...images.slice(0, remaining)];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files?.length) void addImages(files);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||||
|
if (files.length) void addImages(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{pendingImages.length > 0 ? (
|
||||||
|
<div className={styles.attachments}>
|
||||||
|
{pendingImages.map((img, index) => (
|
||||||
|
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
||||||
|
<img className={styles.attachmentThumb} src={imageToDataUrl(img)} alt="" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.attachmentRemove}
|
||||||
|
onClick={() => removePendingImage(index)}
|
||||||
|
aria-label="移除图片"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{slashMenuOpen ? (
|
||||||
|
<SlashCommandMenu
|
||||||
|
commands={filteredSlashCommands}
|
||||||
|
selectedIndex={selectedSlashIndex}
|
||||||
|
onSelect={applySlashSelection}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
className={styles.fileInput}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.attachBtn}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={pendingImages.length >= MAX_CHAT_IMAGES}
|
||||||
|
title="上传图片"
|
||||||
|
aria-label="上传图片"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.input}
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBeforeInput={handleBeforeInput}
|
||||||
|
placeholder={isStreaming ? "Enter 改方向,Shift+Enter 排队后续…" : "输入消息… (/ 命令, ! bash)"}
|
||||||
|
aria-label="消息输入框"
|
||||||
|
enterKeyHint="send"
|
||||||
|
inputMode="text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.sendBtn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend(isStreaming ? "steer" : "prompt")}
|
||||||
|
disabled={!canSend}
|
||||||
|
aria-label="发送"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.summary {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
15
frontend/src/components/chat/CompactionSummaryBlock.tsx
Normal file
15
frontend/src/components/chat/CompactionSummaryBlock.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import styles from "./CompactionSummaryBlock.module.css";
|
||||||
|
|
||||||
|
interface CompactionSummaryBlockProps {
|
||||||
|
content: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactionSummaryBlock({ content, label = "上下文已压缩" }: CompactionSummaryBlockProps) {
|
||||||
|
return (
|
||||||
|
<details className={styles.summary}>
|
||||||
|
<summary className={styles.toggle}>{label}</summary>
|
||||||
|
<div className={styles.body}>{content}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/chat/ExportMenu.module.css
Normal file
84
frontend/src/components/chat/ExportMenu.module.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(37, 99, 235, 0.22);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: rgba(248, 250, 252, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnIcon {
|
||||||
|
width: 32px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--toolbar-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
background: rgba(37, 99, 235, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItemDesc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
frontend/src/components/chat/ExportMenu.tsx
Normal file
164
frontend/src/components/chat/ExportMenu.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchSessionHistory } from "../../api/sessions";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { ChatMessage, ToolCallBlock } from "../../types/message";
|
||||||
|
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||||
|
import { nextMessageId } from "../../utils/format";
|
||||||
|
import {
|
||||||
|
downloadTextFile,
|
||||||
|
messagesToMarkdown,
|
||||||
|
messagesToMinimalHtml,
|
||||||
|
safeExportFilename,
|
||||||
|
} from "../../utils/exportConversation";
|
||||||
|
import styles from "./ExportMenu.module.css";
|
||||||
|
|
||||||
|
function historyToExportMessages(
|
||||||
|
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
|
||||||
|
): ChatMessage[] {
|
||||||
|
const result: ChatMessage[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||||
|
} else if (m.role === "assistant") {
|
||||||
|
if (Array.isArray(m.content)) {
|
||||||
|
for (const block of m.content) {
|
||||||
|
if (block.type === "thinking") {
|
||||||
|
const thinking = (block.thinking ?? "").trim();
|
||||||
|
if (thinking) {
|
||||||
|
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
||||||
|
}
|
||||||
|
} else if (block.type === "text") {
|
||||||
|
const text = (block.text ?? "").trim();
|
||||||
|
if (text) {
|
||||||
|
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
}
|
||||||
|
} else if (block.type === "toolCall") {
|
||||||
|
const tc = block as ToolCallBlock;
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const thinking = extractThinking(m.content);
|
||||||
|
if (thinking) {
|
||||||
|
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
||||||
|
}
|
||||||
|
const text = extractContent(m.content);
|
||||||
|
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
for (const tc of getToolCalls(m.content)) {
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMenu({ compact = false }: { compact?: boolean }) {
|
||||||
|
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
|
||||||
|
const canExport = hasMessages && !isStreaming && !exporting;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
|
||||||
|
if (activeSessionPath) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchSessionHistory(activeSessionPath);
|
||||||
|
if (payload.messages?.length) {
|
||||||
|
return historyToExportMessages(payload.messages);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fallback to in-memory messages */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (format: "markdown" | "html") => {
|
||||||
|
if (!canExport) return;
|
||||||
|
setExporting(true);
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
const exportMessages = await resolveExportMessages();
|
||||||
|
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
|
||||||
|
if (!filtered.length) {
|
||||||
|
addSystemMessage("当前对话没有可导出的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = headerTitle.trim() || "对话";
|
||||||
|
const base = safeExportFilename(title);
|
||||||
|
if (format === "markdown") {
|
||||||
|
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
|
||||||
|
} else {
|
||||||
|
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap} ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={compact ? `${styles.btn} ${styles.btnIcon}` : styles.btn}
|
||||||
|
disabled={!canExport}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="导出对话"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
|
||||||
|
</svg>
|
||||||
|
{compact ? null : "导出"}
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className={styles.menu} role="menu">
|
||||||
|
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
|
||||||
|
Markdown
|
||||||
|
<span className={styles.menuItemDesc}>.md 格式,保留 Markdown 结构</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
|
||||||
|
HTML 超简洁文本
|
||||||
|
<span className={styles.menuItemDesc}>纯文本风格 HTML,便于阅读与分享</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/chat/ExtensionDialogModal.module.css
Normal file
80
frontend/src/components/chat/ExtensionDialogModal.module.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionBtn {
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #4f46e5;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
78
frontend/src/components/chat/ExtensionDialogModal.tsx
Normal file
78
frontend/src/components/chat/ExtensionDialogModal.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ExtensionDialogState } from "../../types/events";
|
||||||
|
import styles from "./ExtensionDialogModal.module.css";
|
||||||
|
|
||||||
|
interface ExtensionDialogModalProps {
|
||||||
|
dialog: ExtensionDialogState;
|
||||||
|
onSubmit: (response: Record<string, unknown>) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionDialogModal({ dialog, onSubmit, onDismiss }: ExtensionDialogModalProps) {
|
||||||
|
const [value, setValue] = useState(dialog.prefill ?? "");
|
||||||
|
|
||||||
|
const submitValue = (payload: Record<string, unknown>) => {
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} role="presentation" onClick={onDismiss}>
|
||||||
|
<div className={styles.modal} role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.title}>{dialog.title}</div>
|
||||||
|
{dialog.message ? <div className={styles.message}>{dialog.message}</div> : null}
|
||||||
|
|
||||||
|
{dialog.method === "select" && dialog.options ? (
|
||||||
|
<div className={styles.options}>
|
||||||
|
{dialog.options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={styles.optionBtn}
|
||||||
|
onClick={() => submitValue({ value: option })}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className={styles.optionBtn} onClick={() => submitValue({ cancelled: true })}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dialog.method === "confirm" ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.btn} onClick={() => submitValue({ cancelled: true })}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.btnPrimary} onClick={() => submitValue({ confirmed: true })}>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dialog.method === "input" || dialog.method === "editor" ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className={styles.input}
|
||||||
|
rows={dialog.method === "editor" ? 8 : 3}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.btn} onClick={() => submitValue({ cancelled: true })}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
onClick={() => submitValue({ value })}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/chat/ExtensionWidgetPanel.module.css
Normal file
25
frontend/src/components/chat/ExtensionWidgetPanel.module.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--toolbar-border, #e8ecf0);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetKey {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary, #9ca3af);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
21
frontend/src/components/chat/ExtensionWidgetPanel.tsx
Normal file
21
frontend/src/components/chat/ExtensionWidgetPanel.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import styles from "./ExtensionWidgetPanel.module.css";
|
||||||
|
|
||||||
|
interface ExtensionWidgetPanelProps {
|
||||||
|
widgets: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionWidgetPanel({ widgets }: ExtensionWidgetPanelProps) {
|
||||||
|
const entries = Object.entries(widgets).filter(([, lines]) => lines.length > 0);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
{entries.map(([key, lines]) => (
|
||||||
|
<div key={key} className={styles.widget}>
|
||||||
|
<div className={styles.widgetKey}>{key}</div>
|
||||||
|
{lines.join("\n")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/components/chat/MessageBubble.tsx
Normal file
204
frontend/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ChatMessage } from "../../types/message";
|
||||||
|
import { useAvatars } from "../../context/AvatarContext";
|
||||||
|
import {
|
||||||
|
downloadTextFile,
|
||||||
|
messageMarkdownFilename,
|
||||||
|
singleAssistantMarkdown,
|
||||||
|
} from "../../utils/exportConversation";
|
||||||
|
import { renderMarkdown } from "../../utils/markdown";
|
||||||
|
import { imageToDataUrl } from "../../utils/images";
|
||||||
|
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
|
||||||
|
import { ToolCallBlock } from "./ToolCallBlock";
|
||||||
|
import { ThinkingBlock } from "./ThinkingBlock";
|
||||||
|
import { BashOutputBlock } from "./BashOutputBlock";
|
||||||
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
agentGutter?: "avatar" | "spacer" | "none";
|
||||||
|
toolsExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
if (!src || failed) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
src={src}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avatarUrl: string }) {
|
||||||
|
if (mode === "spacer") {
|
||||||
|
return <div className={styles.assistantAvatarSpacer} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantAvatarSlot}>
|
||||||
|
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantActions({ content }: { content: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const markdown = singleAssistantMarkdown(content);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantToolbar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
title="复制 Markdown"
|
||||||
|
>
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.msgActionBtn}
|
||||||
|
onClick={handleDownload}
|
||||||
|
title="下载 Markdown"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({
|
||||||
|
message,
|
||||||
|
agentGutter = "none",
|
||||||
|
toolsExpanded = false,
|
||||||
|
}: MessageBubbleProps) {
|
||||||
|
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||||
|
const gutter =
|
||||||
|
agentGutter === "avatar" || agentGutter === "spacer" ? (
|
||||||
|
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (message.role === "thinking") {
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
{gutter}
|
||||||
|
<div className={`${styles.msg} ${styles.thinking}`}>
|
||||||
|
<ThinkingBlock content={message.content} streaming={message.streaming} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "tool_call") {
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
{gutter}
|
||||||
|
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||||
|
<ToolCallBlock content={message.content} forceOpen={toolsExpanded} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "bash") {
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
{gutter}
|
||||||
|
<div className={`${styles.msg} ${styles.bash}`}>
|
||||||
|
<BashOutputBlock
|
||||||
|
command={message.bashCommand}
|
||||||
|
content={message.content}
|
||||||
|
exitCode={message.bashExitCode}
|
||||||
|
streaming={message.streaming}
|
||||||
|
excludeFromContext={message.bashExcludeFromContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "compaction_summary") {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.msg} ${styles.system}`}>
|
||||||
|
<CompactionSummaryBlock content={message.content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "branch_summary") {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.msg} ${styles.system}`}>
|
||||||
|
<CompactionSummaryBlock content={message.content} label="分支摘要" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classNames = [styles.msg];
|
||||||
|
if (message.role === "user") classNames.push(styles.user);
|
||||||
|
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||||
|
if (message.role === "system") classNames.push(styles.system);
|
||||||
|
if (message.streaming) classNames.push(styles.streaming);
|
||||||
|
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const showActions = !message.streaming && message.content.trim().length > 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
{gutter}
|
||||||
|
<div className={classNames.join(" ")}>
|
||||||
|
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||||
|
<div
|
||||||
|
className={styles.assistantBody}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "user") {
|
||||||
|
return (
|
||||||
|
<div className={styles.userRow}>
|
||||||
|
<div className={classNames.join(" ")}>
|
||||||
|
{message.content ? <div>{message.content}</div> : null}
|
||||||
|
{message.images?.length ? (
|
||||||
|
<div className={styles.userImages}>
|
||||||
|
{message.images.map((img, index) => (
|
||||||
|
<img
|
||||||
|
key={`${img.mimeType}-${index}`}
|
||||||
|
className={styles.userImage}
|
||||||
|
src={imageToDataUrl(img)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={classNames.join(" ")}>{message.content}</div>;
|
||||||
|
}
|
||||||
434
frontend/src/components/chat/MessageList.module.css
Normal file
434
frontend/src/components/chat/MessageList.module.css
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
.chat {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-thumb {
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: #bbb;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
padding: 10px 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 16px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
max-width: min(700px, 85%);
|
||||||
|
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 14px 14px 4px 14px;
|
||||||
|
margin: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userRow {
|
||||||
|
align-self: flex-end;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(700px, 85%);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImages:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImage {
|
||||||
|
max-width: min(280px, 100%);
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantRow {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSlot {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSpacer {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.46;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantToolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin: -2px -2px 6px 0;
|
||||||
|
min-height: 22px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant:hover .assistantToolbar,
|
||||||
|
.assistant:focus-within .assistantToolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtn {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtnCopied {
|
||||||
|
color: #15803d;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantBody {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system {
|
||||||
|
align-self: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolCall {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.46;
|
||||||
|
color: #666;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.46;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #faf8fc;
|
||||||
|
border: 1px solid #ece6f5;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bash {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.46;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming::after {
|
||||||
|
content: "▊";
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: #2563eb;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantRow + .assistantRow {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(h1),
|
||||||
|
.assistant :global(h2),
|
||||||
|
.assistant :global(h3),
|
||||||
|
.assistant :global(h4),
|
||||||
|
.assistant :global(h5),
|
||||||
|
.assistant :global(h6) {
|
||||||
|
margin: 0.78em 0 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(h1) { font-size: 1.18em; }
|
||||||
|
.assistant :global(h2) { font-size: 1.1em; }
|
||||||
|
.assistant :global(h3) { font-size: 1.03em; }
|
||||||
|
.assistant :global(h4) { font-size: 1em; }
|
||||||
|
|
||||||
|
.assistant > :global(:first-child) { margin-top: 0; }
|
||||||
|
.assistant > :global(:last-child) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.assistant :global(p) {
|
||||||
|
margin: 0 0 0.46em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(p + p) {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(strong) { font-weight: 600; }
|
||||||
|
.assistant :global(em) { font-style: italic; }
|
||||||
|
|
||||||
|
.assistant :global(a) {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(a:hover) { text-decoration: underline; }
|
||||||
|
|
||||||
|
.assistant :global(ul),
|
||||||
|
.assistant :global(ol) {
|
||||||
|
margin: 0.32em 0 0.48em;
|
||||||
|
padding-left: 1.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(li) {
|
||||||
|
margin: 0.08em 0;
|
||||||
|
padding-left: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(li > p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(blockquote) {
|
||||||
|
margin: 0.36em 0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-left: 2px solid #e5e5e5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin: 0.58em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.42em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(th),
|
||||||
|
.assistant :global(td) {
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
padding: 4px 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(th) {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(code:not(.hljs)) {
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d63384;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(pre.assistant-pre) {
|
||||||
|
margin: 0.5em 0 0.6em;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #e8edf2;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(pre.assistant-pre code.hljs) {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.48;
|
||||||
|
word-break: normal;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.42em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.assistantRow {
|
||||||
|
max-width: 66.6667%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat {
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
padding: 11px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userRow {
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSpacer,
|
||||||
|
.assistantAvatarSlot {
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantToolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.userRow {
|
||||||
|
max-width: 92%;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/src/components/chat/MessageList.tsx
Normal file
96
frontend/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { MessageRole } from "../../types/message";
|
||||||
|
import { MessageBubble } from "./MessageBubble";
|
||||||
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
|
const NEAR_BOTTOM_THRESHOLD = 96;
|
||||||
|
|
||||||
|
function isNearBottom(el: HTMLElement): boolean {
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(el: HTMLElement): void {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAgentSideRole(role: MessageRole): boolean {
|
||||||
|
return role === "assistant" || role === "thinking" || role === "tool_call" || role === "bash";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList() {
|
||||||
|
const { messages, toolsExpanded } = useChatContext();
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stickToBottomRef = useRef(true);
|
||||||
|
const prevMessageCountRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
stickToBottomRef.current = isNearBottom(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const prevCount = prevMessageCountRef.current;
|
||||||
|
const messageCount = messages.length;
|
||||||
|
prevMessageCountRef.current = messageCount;
|
||||||
|
|
||||||
|
const lastMessage = messages[messageCount - 1];
|
||||||
|
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
|
||||||
|
|
||||||
|
if (userJustSent || prevCount === 0) {
|
||||||
|
stickToBottomRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stickToBottomRef.current) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (chatRef.current) scrollToBottom(chatRef.current);
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.chat} ref={chatRef}>
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<div className={styles.emptyIcon}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>发送一条消息开始对话</p>
|
||||||
|
<p className={styles.emptyHint}>按 Enter 发送 · Shift+Enter 换行</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.chat} ref={chatRef}>
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
const prev = messages[index - 1];
|
||||||
|
const prevIsAgentSide = prev ? isAgentSideRole(prev.role) : false;
|
||||||
|
const isAgentSide = isAgentSideRole(msg.role);
|
||||||
|
const agentGutter = isAgentSide ? (prevIsAgentSide ? "spacer" : "avatar") : "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
agentGutter={agentGutter}
|
||||||
|
toolsExpanded={toolsExpanded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/chat/PendingQueueBar.module.css
Normal file
30
frontend/src/components/chat/PendingQueueBar.module.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
.pendingBar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--toolbar-border, #e8ecf0);
|
||||||
|
background: var(--surface-muted, #f3f4f6);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueItem {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
35
frontend/src/components/chat/PendingQueueBar.tsx
Normal file
35
frontend/src/components/chat/PendingQueueBar.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import styles from "./PendingQueueBar.module.css";
|
||||||
|
|
||||||
|
interface PendingQueueBarProps {
|
||||||
|
steering: string[];
|
||||||
|
followUp: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingQueueBar({ steering, followUp }: PendingQueueBarProps) {
|
||||||
|
if (steering.length === 0 && followUp.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.pendingBar}>
|
||||||
|
{steering.length > 0 ? (
|
||||||
|
<div className={styles.queueGroup}>
|
||||||
|
<span className={styles.queueLabel}>Steering</span>
|
||||||
|
{steering.map((item, i) => (
|
||||||
|
<div key={`steer-${i}`} className={styles.queueItem}>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{followUp.length > 0 ? (
|
||||||
|
<div className={styles.queueGroup}>
|
||||||
|
<span className={styles.queueLabel}>Follow-up</span>
|
||||||
|
{followUp.map((item, i) => (
|
||||||
|
<div key={`follow-${i}`} className={styles.queueItem}>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/components/chat/RunStatusBar.module.css
Normal file
47
frontend/src/components/chat/RunStatusBar.module.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 1px solid var(--toolbar-border, #e8ecf0);
|
||||||
|
background: var(--toolbar-bg, #f8fafc);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #4b5563);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6366f1;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryMeta {
|
||||||
|
color: var(--text-tertiary, #9ca3af);
|
||||||
|
}
|
||||||
56
frontend/src/components/chat/RunStatusBar.tsx
Normal file
56
frontend/src/components/chat/RunStatusBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RetryState } from "../../types/events";
|
||||||
|
import styles from "./RunStatusBar.module.css";
|
||||||
|
|
||||||
|
interface RunStatusBarProps {
|
||||||
|
isStreaming: boolean;
|
||||||
|
isCompacting: boolean;
|
||||||
|
retryState: RetryState | null;
|
||||||
|
onAbort: () => void;
|
||||||
|
onAbortRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunStatusBar({
|
||||||
|
isStreaming,
|
||||||
|
isCompacting,
|
||||||
|
retryState,
|
||||||
|
onAbort,
|
||||||
|
onAbortRetry,
|
||||||
|
}: RunStatusBarProps) {
|
||||||
|
const retryActive = retryState?.active;
|
||||||
|
if (!isStreaming && !isCompacting && !retryActive) return null;
|
||||||
|
|
||||||
|
let label = "";
|
||||||
|
if (retryActive) {
|
||||||
|
const attempt = retryState?.attempt ?? 1;
|
||||||
|
const max = retryState?.maxAttempts ?? attempt;
|
||||||
|
label = `自动重试中 (${attempt}/${max})`;
|
||||||
|
} else if (isCompacting) {
|
||||||
|
label = "整理上下文中…";
|
||||||
|
} else {
|
||||||
|
label = "生成中…";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar}>
|
||||||
|
<div className={styles.statusText}>
|
||||||
|
<span className={styles.dot} aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
{retryActive && retryState?.errorMessage ? (
|
||||||
|
<span className={styles.retryMeta}>{retryState.errorMessage.slice(0, 80)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{retryActive ? (
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onAbortRetry}>
|
||||||
|
取消重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{isStreaming || isCompacting ? (
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onAbort}>
|
||||||
|
中止
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/chat/SessionContextBar.module.css
Normal file
45
frontend/src/components/chat/SessionContextBar.module.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #1e40af;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxWarn {
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border-color: rgba(245, 158, 11, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxDanger {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxChip {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/src/components/chat/SessionContextBar.tsx
Normal file
42
frontend/src/components/chat/SessionContextBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { formatFooterTokens } from "../../utils/format";
|
||||||
|
import styles from "./SessionContextBar.module.css";
|
||||||
|
|
||||||
|
export function SessionContextBar() {
|
||||||
|
const { sessionState, isStreaming } = useChatContext();
|
||||||
|
const data = sessionState;
|
||||||
|
|
||||||
|
if (!data || data.error || !data.stats || !data.model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveStreaming = isStreaming || data.isStreaming;
|
||||||
|
if (liveStreaming || data.isCompacting) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = data.stats.contextUsage;
|
||||||
|
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||||
|
if (!cw) return null;
|
||||||
|
|
||||||
|
const pctRaw = cx?.percent;
|
||||||
|
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||||
|
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||||
|
const autoInd = data.autoCompactionEnabled ? " auto" : "";
|
||||||
|
const ctxTxt =
|
||||||
|
pctStr === "?"
|
||||||
|
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||||
|
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||||
|
|
||||||
|
let ctxClass = styles.ctxChip;
|
||||||
|
if (pctNum != null) {
|
||||||
|
if (pctNum > 90) ctxClass = `${styles.ctxChip} ${styles.ctxDanger}`;
|
||||||
|
else if (pctNum > 70) ctxClass = `${styles.ctxChip} ${styles.ctxWarn}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar}>
|
||||||
|
<span className={ctxClass} title="上下文占用">{ctxTxt}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/chat/SlashCommandMenu.module.css
Normal file
67
frontend/src/components/chat/SlashCommandMenu.module.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
.menu {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, auto) 1fr auto;
|
||||||
|
gap: 8px 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover,
|
||||||
|
.itemActive {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
frontend/src/components/chat/SlashCommandMenu.tsx
Normal file
49
frontend/src/components/chat/SlashCommandMenu.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { SlashCommand } from "../../types/commands";
|
||||||
|
import { slashCommandSourceLabel } from "../../utils/slashCommands";
|
||||||
|
import styles from "./SlashCommandMenu.module.css";
|
||||||
|
|
||||||
|
interface SlashCommandMenuProps {
|
||||||
|
commands: SlashCommand[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (command: SlashCommand) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCommandMenuProps) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const item = list.children[selectedIndex] as HTMLElement | undefined;
|
||||||
|
item?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedIndex, commands.length]);
|
||||||
|
|
||||||
|
if (!commands.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.menu} role="listbox" aria-label="斜杠命令">
|
||||||
|
<div ref={listRef} className={styles.list}>
|
||||||
|
{commands.map((command, index) => (
|
||||||
|
<button
|
||||||
|
key={command.name}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(command);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.name}>/{command.name}</span>
|
||||||
|
{command.description ? (
|
||||||
|
<span className={styles.description}>{command.description}</span>
|
||||||
|
) : null}
|
||||||
|
<span className={styles.source}>{slashCommandSourceLabel(command.source)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/chat/ThinkingBlock.module.css
Normal file
80
frontend/src/components/chat/ThinkingBlock.module.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.block {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #7c6a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::before {
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid #a894c6;
|
||||||
|
border-top: 3.5px solid transparent;
|
||||||
|
border-bottom: 3.5px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block[open] > .summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-style: italic;
|
||||||
|
color: #8b7aa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
border-top: 1px solid #ece6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6f5f8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming::after {
|
||||||
|
content: "▊";
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: #8b6fd4;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/components/chat/ThinkingBlock.tsx
Normal file
23
frontend/src/components/chat/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { deriveThinkingSummary } from "../../utils/toolCall";
|
||||||
|
import styles from "./ThinkingBlock.module.css";
|
||||||
|
|
||||||
|
interface ThinkingBlockProps {
|
||||||
|
content: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
|
||||||
|
const summary = deriveThinkingSummary(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className={styles.block}>
|
||||||
|
<summary className={styles.summary}>
|
||||||
|
<span className={styles.summaryLabel}>思考</span>
|
||||||
|
<span className={styles.summaryText}>{summary}</span>
|
||||||
|
</summary>
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<div className={`${styles.body} ${streaming ? styles.streaming : ""}`}>{content}</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/chat/ToolCallBlock.module.css
Normal file
70
frontend/src/components/chat/ToolCallBlock.module.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
.collapsible {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::before {
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid #888;
|
||||||
|
border-top: 3.5px solid transparent;
|
||||||
|
border-bottom: 3.5px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible[open] > .summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPre :global(.diffAdd) {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPre :global(.diffDel) {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPre :global(.diffMeta) {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
31
frontend/src/components/chat/ToolCallBlock.tsx
Normal file
31
frontend/src/components/chat/ToolCallBlock.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall";
|
||||||
|
import styles from "./ToolCallBlock.module.css";
|
||||||
|
|
||||||
|
interface ToolCallBlockProps {
|
||||||
|
content: string;
|
||||||
|
forceOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
|
||||||
|
const bodyStart = content.indexOf("\n");
|
||||||
|
const body = bodyStart >= 0 ? content.slice(bodyStart + 1) : "";
|
||||||
|
const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className={styles.collapsible} open={forceOpen}>
|
||||||
|
<summary className={styles.summary}>
|
||||||
|
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
||||||
|
</summary>
|
||||||
|
<div className={styles.detail}>
|
||||||
|
{hasDiff ? (
|
||||||
|
<pre
|
||||||
|
className={styles.detailPre}
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className={styles.detailPre}>{body || content}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/layout/AppLayout.module.css
Normal file
32
frontend/src/components/layout/AppLayout.module.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: var(--app-height);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayOpen {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app {
|
||||||
|
height: var(--app-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/components/layout/ChatLayout.tsx
Normal file
22
frontend/src/components/layout/ChatLayout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import layoutStyles from "./AppLayout.module.css";
|
||||||
|
|
||||||
|
export function ChatLayout() {
|
||||||
|
const { sidebarOpen, toggleSidebar } = useChatContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={layoutStyles.app}>
|
||||||
|
<Sidebar />
|
||||||
|
<main className={layoutStyles.main}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<div
|
||||||
|
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
frontend/src/components/layout/Header.module.css
Normal file
273
frontend/src/components/layout/Header.module.css
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: var(--header-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn {
|
||||||
|
display: none;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--toolbar-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--toolbar-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectWrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectWrap::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 5px solid var(--text-tertiary);
|
||||||
|
transform: translateY(-35%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 220px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 24px 0 10px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectModel {
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: min(280px, 34vw);
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectCompact {
|
||||||
|
min-width: 68px;
|
||||||
|
max-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:disabled {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: rgba(248, 250, 252, 0.9);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsWrap {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsWrap::before {
|
||||||
|
content: "";
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 24px;
|
||||||
|
margin-right: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(15, 23, 42, 0.08) 18%,
|
||||||
|
rgba(15, 23, 42, 0.08) 82%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolsToggleBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--toolbar-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolsToggleBtn:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 72px;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectModel,
|
||||||
|
.selectCompact {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsWrap::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
frontend/src/components/layout/Header.tsx
Normal file
166
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { modelKey, modelLabel, shortModelLabel } from "../../utils/sessionTitle";
|
||||||
|
import { ExportMenu } from "../chat/ExportMenu";
|
||||||
|
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||||
|
import styles from "./Header.module.css";
|
||||||
|
|
||||||
|
function ModelControls({
|
||||||
|
availableModels,
|
||||||
|
currentModelKey,
|
||||||
|
currentModel,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
isStreaming,
|
||||||
|
isApplyingModelSettings,
|
||||||
|
applyModelSelection,
|
||||||
|
applyThinkingSelection,
|
||||||
|
}: {
|
||||||
|
availableModels: ReturnType<typeof useChatContext>["availableModels"];
|
||||||
|
currentModelKey: string;
|
||||||
|
currentModel: ReturnType<typeof useChatContext>["availableModels"][number] | undefined;
|
||||||
|
thinkingLevel: string;
|
||||||
|
availableThinkingLevels: ReturnType<typeof useChatContext>["availableThinkingLevels"];
|
||||||
|
isStreaming: boolean;
|
||||||
|
isApplyingModelSettings: boolean;
|
||||||
|
applyModelSelection: (key: string) => Promise<void>;
|
||||||
|
applyThinkingSelection: (level: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>模型</span>
|
||||||
|
<div className={styles.selectWrap}>
|
||||||
|
<select
|
||||||
|
className={`${styles.select} ${styles.selectModel}`}
|
||||||
|
aria-label="选择模型"
|
||||||
|
title={currentModel ? modelLabel(currentModel) : "选择模型"}
|
||||||
|
value={currentModelKey}
|
||||||
|
disabled={isStreaming || isApplyingModelSettings}
|
||||||
|
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<option value="">加载中…</option>
|
||||||
|
) : (
|
||||||
|
availableModels.map((m) => (
|
||||||
|
<option key={modelKey(m)} value={modelKey(m)} title={modelLabel(m)}>
|
||||||
|
{shortModelLabel(m)}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>推理</span>
|
||||||
|
<div className={styles.selectWrap}>
|
||||||
|
<select
|
||||||
|
className={`${styles.select} ${styles.selectCompact}`}
|
||||||
|
aria-label="选择推理强度"
|
||||||
|
title="推理强度"
|
||||||
|
value={
|
||||||
|
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
|
||||||
|
? thinkingLevel
|
||||||
|
: availableThinkingLevels[0] || "off"
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!availableThinkingLevels.length ||
|
||||||
|
availableThinkingLevels.length === 1 ||
|
||||||
|
isStreaming ||
|
||||||
|
isApplyingModelSettings
|
||||||
|
}
|
||||||
|
onChange={(e) => void applyThinkingSelection(e.target.value)}
|
||||||
|
>
|
||||||
|
{availableThinkingLevels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolsToggleButton({
|
||||||
|
expanded,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
expanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toolsToggleBtn}
|
||||||
|
onClick={onClick}
|
||||||
|
title={expanded ? "折叠全部 tool (Ctrl+O)" : "展开全部 tool (Ctrl+O)"}
|
||||||
|
aria-label={expanded ? "折叠全部 tool" : "展开全部 tool"}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const {
|
||||||
|
headerTitle,
|
||||||
|
headerMeta,
|
||||||
|
toggleSidebar,
|
||||||
|
toggleToolsExpanded,
|
||||||
|
toolsExpanded,
|
||||||
|
availableModels,
|
||||||
|
currentModelKey,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
isStreaming,
|
||||||
|
isApplyingModelSettings,
|
||||||
|
applyModelSelection,
|
||||||
|
applyThinkingSelection,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
|
const currentModel = availableModels.find((m) => modelKey(m) === currentModelKey);
|
||||||
|
const controlProps = {
|
||||||
|
availableModels,
|
||||||
|
currentModelKey,
|
||||||
|
currentModel,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
isStreaming,
|
||||||
|
isApplyingModelSettings,
|
||||||
|
applyModelSelection,
|
||||||
|
applyThinkingSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.topRow}>
|
||||||
|
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h1 className={styles.title} title={headerTitle}>
|
||||||
|
{headerTitle}
|
||||||
|
</h1>
|
||||||
|
{headerMeta ? <span className={styles.meta}>{headerMeta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.topActions}>
|
||||||
|
<ToolsToggleButton expanded={toolsExpanded} onClick={toggleToolsExpanded} />
|
||||||
|
<ExportMenu compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.bottomRow}>
|
||||||
|
<ModelControls {...controlProps} />
|
||||||
|
<div className={styles.statsWrap}>
|
||||||
|
<SessionContextBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/components/layout/Sidebar.module.css
Normal file
170
frontend/src/components/layout/Sidebar.module.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
.sidebar {
|
||||||
|
width: 236px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 18px 16px 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionRefresh {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionRefresh:hover {
|
||||||
|
color: #666;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusModel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
frontend/src/components/layout/Sidebar.tsx
Normal file
63
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { SessionList } from "../session/SessionList";
|
||||||
|
import styles from "./Sidebar.module.css";
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
|
||||||
|
|
||||||
|
const statusDotClass = connected
|
||||||
|
? isStreaming
|
||||||
|
? `${styles.statusDot} ${styles.streaming}`
|
||||||
|
: `${styles.statusDot} ${styles.connected}`
|
||||||
|
: `${styles.statusDot} ${styles.disconnected}`;
|
||||||
|
|
||||||
|
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
|
||||||
|
<h2 className={styles.title}>萌小芽</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span>历史会话</span>
|
||||||
|
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SessionList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.actionBtn} onClick={newSession}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
|
<Link to="/settings" className={styles.actionBtn}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
|
||||||
|
</svg>
|
||||||
|
设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.status}>
|
||||||
|
<div className={styles.statusRow}>
|
||||||
|
<span className={statusDotClass} />
|
||||||
|
<span>{statusText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/pwa/PwaUpdatePrompt.module.css
Normal file
74
frontend/src/components/pwa/PwaUpdatePrompt.module.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: min(92vw, 420px);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary,
|
||||||
|
.secondary {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/src/components/pwa/PwaUpdatePrompt.tsx
Normal file
39
frontend/src/components/pwa/PwaUpdatePrompt.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||||
|
import styles from "./PwaUpdatePrompt.module.css";
|
||||||
|
|
||||||
|
export function PwaUpdatePrompt() {
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
onRegistered(registration) {
|
||||||
|
if (!registration) return;
|
||||||
|
window.setInterval(() => {
|
||||||
|
void registration.update();
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
},
|
||||||
|
onRegisterError(error) {
|
||||||
|
console.error("[PWA] service worker registration failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!needRefresh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar} role="status" aria-live="polite">
|
||||||
|
<div className={styles.text}>发现新版本,更新后即可使用最新功能。</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
|
||||||
|
稍后
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primary}
|
||||||
|
onClick={() => void updateServiceWorker(true)}
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/components/pwa/SplashScreen.module.css
Normal file
186
frontend/src/components/pwa/SplashScreen.module.css
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
.splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
|
||||||
|
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
|
||||||
|
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.42s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splashExiting {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgPulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: -20%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
|
||||||
|
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
|
||||||
|
animation: bgPulse 3.6s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgGlow {
|
||||||
|
position: absolute;
|
||||||
|
width: min(72vw, 420px);
|
||||||
|
height: min(72vw, 420px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
|
||||||
|
filter: blur(8px);
|
||||||
|
animation: glowDrift 4.8s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(37, 99, 235, 0.28);
|
||||||
|
opacity: 0;
|
||||||
|
animation: ringExpand 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring2 {
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring3 {
|
||||||
|
animation-delay: 1.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 28px rgba(37, 99, 235, 0.18),
|
||||||
|
0 2px 8px rgba(15, 23, 42, 0.08);
|
||||||
|
animation: logoFloat 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: clamp(28px, 6vw, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
animation: dotPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot2 {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot3 {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bgPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.72; }
|
||||||
|
50% { transform: scale(1.06); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glowDrift {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-10px) scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ringExpand {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.72);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.45);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0.72);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bgPulse,
|
||||||
|
.bgGlow,
|
||||||
|
.logo,
|
||||||
|
.ring,
|
||||||
|
.dot {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/components/pwa/SplashScreen.tsx
Normal file
31
frontend/src/components/pwa/SplashScreen.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import styles from "./SplashScreen.module.css";
|
||||||
|
|
||||||
|
interface SplashScreenProps {
|
||||||
|
exiting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplashScreen({ exiting = false }: SplashScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
|
||||||
|
<div className={styles.bgPulse} />
|
||||||
|
<div className={styles.bgGlow} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.logoWrap}>
|
||||||
|
<span className={styles.ring} />
|
||||||
|
<span className={`${styles.ring} ${styles.ring2}`} />
|
||||||
|
<span className={`${styles.ring} ${styles.ring3}`} />
|
||||||
|
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h1 className={styles.title}>萌小芽</h1>
|
||||||
|
<p className={styles.subtitle}>加载中</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.dots} aria-label="加载中">
|
||||||
|
<span className={styles.dot} />
|
||||||
|
<span className={`${styles.dot} ${styles.dot2}`} />
|
||||||
|
<span className={`${styles.dot} ${styles.dot3}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend/src/components/session/SessionList.module.css
Normal file
149
frontend/src/components/session/SessionList.module.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list::-webkit-scrollbar-thumb {
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 16px 10px;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned.active {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 2px 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow .name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renameInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid #2563eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinBtn,
|
||||||
|
.renameBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinBtnActive {
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinBtn:hover,
|
||||||
|
.pinBtnActive:hover {
|
||||||
|
color: #ca8a04;
|
||||||
|
background: #fef9c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renameBtn:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
130
frontend/src/components/session/SessionList.tsx
Normal file
130
frontend/src/components/session/SessionList.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { formatTimeAgo } from "../../utils/format";
|
||||||
|
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||||
|
import styles from "./SessionList.module.css";
|
||||||
|
|
||||||
|
export function SessionList() {
|
||||||
|
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||||
|
useChatContext();
|
||||||
|
const [editingPath, setEditingPath] = useState<string | null>(null);
|
||||||
|
const [editingValue, setEditingValue] = useState("");
|
||||||
|
|
||||||
|
const startEditing = (path: string, currentName: string) => {
|
||||||
|
setEditingPath(path);
|
||||||
|
setEditingValue(currentName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRename = (path: string) => {
|
||||||
|
const trimmed = editingValue.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
void renameSession(path, trimmed);
|
||||||
|
}
|
||||||
|
setEditingPath(null);
|
||||||
|
setEditingValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingPath(null);
|
||||||
|
setEditingValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sessions.length) {
|
||||||
|
return <div className={styles.empty}>暂无历史会话</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const isActive = activeSessionPath === s.path;
|
||||||
|
const isPinned = Boolean(s.pinned);
|
||||||
|
const title = formatSessionTitle(s.name);
|
||||||
|
const isEditing = editingPath === s.path;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.path}
|
||||||
|
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||||
|
>
|
||||||
|
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className={styles.renameInput}
|
||||||
|
value={editingValue}
|
||||||
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmRename(s.path);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => confirmRename(s.path)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.name}>{title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
||||||
|
title={isPinned ? "取消置顶" : "置顶会话"}
|
||||||
|
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleSessionPin(s.path, !isPinned);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path d="M12 17v5" />
|
||||||
|
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.renameBtn}
|
||||||
|
title="重命名会话"
|
||||||
|
aria-label={`重命名 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEditing) {
|
||||||
|
cancelEditing();
|
||||||
|
} else {
|
||||||
|
startEditing(s.path, s.name || title);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteBtn}
|
||||||
|
title="删除会话"
|
||||||
|
aria-label={`删除 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void deleteSession(s.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/context/AvatarContext.tsx
Normal file
66
frontend/src/context/AvatarContext.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import * as settingsApi from "../api/settings";
|
||||||
|
import type { AvatarSettings } from "../types/events";
|
||||||
|
|
||||||
|
interface AvatarContextValue extends AvatarSettings {
|
||||||
|
loaded: boolean;
|
||||||
|
refreshAvatars: () => Promise<void>;
|
||||||
|
updateAvatars: (next: AvatarSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarContext = createContext<AvatarContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AvatarProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||||
|
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const refreshAvatars = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.fetchAvatars();
|
||||||
|
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||||
|
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAvatars = useCallback((next: AvatarSettings) => {
|
||||||
|
setUserAvatarUrl(next.userAvatarUrl || "");
|
||||||
|
setAgentAvatarUrl(next.agentAvatarUrl || "");
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshAvatars();
|
||||||
|
}, [refreshAvatars]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
(): AvatarContextValue => ({
|
||||||
|
userAvatarUrl,
|
||||||
|
agentAvatarUrl,
|
||||||
|
loaded,
|
||||||
|
refreshAvatars,
|
||||||
|
updateAvatars,
|
||||||
|
}),
|
||||||
|
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvatars(): AvatarContextValue {
|
||||||
|
const ctx = useContext(AvatarContext);
|
||||||
|
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
89
frontend/src/context/BootstrapContext.tsx
Normal file
89
frontend/src/context/BootstrapContext.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { SplashScreen } from "../components/pwa/SplashScreen";
|
||||||
|
|
||||||
|
const MIN_SPLASH_MS = 400;
|
||||||
|
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
||||||
|
|
||||||
|
type BootstrapRoute = "chat" | "settings";
|
||||||
|
|
||||||
|
interface BootstrapContextValue {
|
||||||
|
markRouteReady: (route: BootstrapRoute) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
|
||||||
|
|
||||||
|
export function BootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
|
||||||
|
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
|
||||||
|
chat: false,
|
||||||
|
settings: false,
|
||||||
|
});
|
||||||
|
const [splashVisible, setSplashVisible] = useState(true);
|
||||||
|
const [splashExiting, setSplashExiting] = useState(false);
|
||||||
|
const mountTime = useRef(Date.now());
|
||||||
|
|
||||||
|
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
|
||||||
|
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
|
||||||
|
setSplashVisible(true);
|
||||||
|
setSplashExiting(false);
|
||||||
|
mountTime.current = Date.now();
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!readyRoutes[route]) return;
|
||||||
|
|
||||||
|
let warmBoot = false;
|
||||||
|
try {
|
||||||
|
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
|
||||||
|
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warmBoot) {
|
||||||
|
setSplashVisible(false);
|
||||||
|
setSplashExiting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - mountTime.current;
|
||||||
|
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSplashExiting(true);
|
||||||
|
window.setTimeout(() => setSplashVisible(false), 420);
|
||||||
|
}, remaining);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [readyRoutes, route]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BootstrapContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
|
||||||
|
</BootstrapContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBootstrap(): BootstrapContextValue {
|
||||||
|
const ctx = useContext(BootstrapContext);
|
||||||
|
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
1742
frontend/src/context/ChatContext.tsx
Normal file
1742
frontend/src/context/ChatContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "@mogeko/maple-mono-cn/dist/font/result.css";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
48
frontend/src/routes/ChatPage.tsx
Normal file
48
frontend/src/routes/ChatPage.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ChatInput } from "../components/chat/ChatInput";
|
||||||
|
import { ExtensionDialogModal } from "../components/chat/ExtensionDialogModal";
|
||||||
|
import { ExtensionWidgetPanel } from "../components/chat/ExtensionWidgetPanel";
|
||||||
|
import { MessageList } from "../components/chat/MessageList";
|
||||||
|
import { PendingQueueBar } from "../components/chat/PendingQueueBar";
|
||||||
|
import { RunStatusBar } from "../components/chat/RunStatusBar";
|
||||||
|
import { Header } from "../components/layout/Header";
|
||||||
|
import { useChatContext } from "../context/ChatContext";
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const {
|
||||||
|
pendingSteering,
|
||||||
|
pendingFollowUp,
|
||||||
|
isStreaming,
|
||||||
|
isCompacting,
|
||||||
|
retryState,
|
||||||
|
extensionWidgets,
|
||||||
|
extensionDialog,
|
||||||
|
abortStream,
|
||||||
|
abortRetry,
|
||||||
|
respondExtensionDialog,
|
||||||
|
dismissExtensionDialog,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<MessageList />
|
||||||
|
<ExtensionWidgetPanel widgets={extensionWidgets} />
|
||||||
|
<PendingQueueBar steering={pendingSteering} followUp={pendingFollowUp} />
|
||||||
|
<RunStatusBar
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isCompacting={isCompacting}
|
||||||
|
retryState={retryState}
|
||||||
|
onAbort={() => void abortStream()}
|
||||||
|
onAbortRetry={() => void abortRetry()}
|
||||||
|
/>
|
||||||
|
<ChatInput />
|
||||||
|
{extensionDialog ? (
|
||||||
|
<ExtensionDialogModal
|
||||||
|
dialog={extensionDialog}
|
||||||
|
onSubmit={respondExtensionDialog}
|
||||||
|
onDismiss={dismissExtensionDialog}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
857
frontend/src/routes/SettingsPage.module.css
Normal file
857
frontend/src/routes/SettingsPage.module.css
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: var(--app-height);
|
||||||
|
height: var(--app-height);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #eef0f3;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle p {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
background: #f4f6f9;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtnActive {
|
||||||
|
background: #fff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:focus-visible {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneActive {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #eef0f3;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader p {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b95a8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn,
|
||||||
|
.secondaryBtn,
|
||||||
|
.linkBtn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #374151;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
border: 1px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryBtn {
|
||||||
|
color: #374151;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
color: #111827;
|
||||||
|
background: #fff;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formBody {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b95a8;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPreviewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPreview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 7px 14px 9px;
|
||||||
|
border-top: 1px solid #eef0f3;
|
||||||
|
color: #8b95a8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOk {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusError {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 20px 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCompact {
|
||||||
|
padding: 10px 4px 2px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillItem,
|
||||||
|
.mcpServerItem,
|
||||||
|
.extensionItem {
|
||||||
|
padding: 10px 10px 9px;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillItem + .skillItem,
|
||||||
|
.mcpServerItem + .mcpServerItem,
|
||||||
|
.extensionItem + .extensionItem {
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroup + .extensionGroup {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroupHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroupHeader span {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroupList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.extensionGroupList {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.extensionGroupList {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.envGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envRow:nth-child(even) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envLabel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envValue {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #111827;
|
||||||
|
word-break: break-all;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.extensionGroupList {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionTitleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionItem .itemName {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionSubtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionGroupList .extensionItem + .extensionItem {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillItemDisabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggleUi {
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggleUi::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggle input:checked + .skillToggleUi {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggle input:checked + .skillToggleUi::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggle input:disabled + .skillToggleUi {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillToggleLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
min-width: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDesc {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemPath {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.mcpToolList {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.mcpToolList {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.mcpToolList {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerMain {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerSub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerBadges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 19px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolItem {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid #f1f3f6;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolItem[open] {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolSummary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolSummaryActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolItemDisabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolSummary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolName {
|
||||||
|
color: #111827;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolDesc {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpParamList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpParamList code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
color: #374151;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionCounts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionCounts span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid #f1f3f6;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails > summary:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
color: #374151;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pageHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: max(12px, env(safe-area-inset-top));
|
||||||
|
padding-bottom: 12px;
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
padding-right: max(12px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
1010
frontend/src/routes/SettingsPage.tsx
Normal file
1010
frontend/src/routes/SettingsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
56
frontend/src/styles/global.css
Normal file
56
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
@import "./variables.css";
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
letter-spacing: var(--font-letter-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: inherit;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||||
|
color: #1a1a1a;
|
||||||
|
height: var(--app-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
optgroup {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs,
|
||||||
|
pre code.hljs,
|
||||||
|
code.hljs {
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/styles/variables.css
Normal file
26
frontend/src/styles/variables.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
:root {
|
||||||
|
--app-height: 100vh;
|
||||||
|
--font-family: "Maple Mono CN", ui-monospace, monospace;
|
||||||
|
--font-letter-spacing: -0.05em;
|
||||||
|
--font-ui: var(--font-family);
|
||||||
|
--font-mono: var(--font-family);
|
||||||
|
|
||||||
|
--text-primary: #111827;
|
||||||
|
--text-secondary: #4b5563;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
--surface-muted: #f3f4f6;
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--header-border: rgba(15, 23, 42, 0.06);
|
||||||
|
--header-shadow: 0 1px 0 rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.04);
|
||||||
|
--toolbar-bg: linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(241, 245, 249, 0.96) 100%);
|
||||||
|
--toolbar-border: rgba(15, 23, 42, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
11
frontend/src/types/commands.ts
Normal file
11
frontend/src/types/commands.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type SlashCommandSource = "builtin" | "extension" | "prompt" | "skill";
|
||||||
|
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
source: SlashCommandSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandsResponse {
|
||||||
|
commands: SlashCommand[];
|
||||||
|
}
|
||||||
248
frontend/src/types/events.ts
Normal file
248
frontend/src/types/events.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import type { RpcMessage } from "./message";
|
||||||
|
|
||||||
|
export interface AgentStartEvent {
|
||||||
|
type: "agent_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentEndEvent {
|
||||||
|
type: "agent_end";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageStartEvent {
|
||||||
|
type: "message_start";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageUpdateEvent {
|
||||||
|
type: "message_update";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageEndEvent {
|
||||||
|
type: "message_end";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionStartEvent {
|
||||||
|
type: "tool_execution_start";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
args?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionUpdateEvent {
|
||||||
|
type: "tool_execution_update";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
partialResult?: { content?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionEndEvent {
|
||||||
|
type: "tool_execution_end";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
isError?: boolean;
|
||||||
|
result?: { content?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactionStartEvent {
|
||||||
|
type: "compaction_start";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactionEndEvent {
|
||||||
|
type: "compaction_end";
|
||||||
|
reason?: string;
|
||||||
|
aborted?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
willRetry?: boolean;
|
||||||
|
result?: { tokensBefore?: number; summary?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueUpdateEvent {
|
||||||
|
type: "queue_update";
|
||||||
|
steering?: string[];
|
||||||
|
followUp?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoRetryStartEvent {
|
||||||
|
type: "auto_retry_start";
|
||||||
|
attempt?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
delayMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoRetryEndEvent {
|
||||||
|
type: "auto_retry_end";
|
||||||
|
success?: boolean;
|
||||||
|
attempt?: number;
|
||||||
|
finalError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionErrorEvent {
|
||||||
|
type: "extension_error";
|
||||||
|
extensionPath?: string;
|
||||||
|
event?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BashUpdateEvent {
|
||||||
|
type: "bash_update";
|
||||||
|
command?: string;
|
||||||
|
partialOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionUiRequestEvent {
|
||||||
|
type: "extension_ui_request";
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
options?: string[];
|
||||||
|
notifyType?: "info" | "warning" | "error";
|
||||||
|
statusKey?: string;
|
||||||
|
statusText?: string;
|
||||||
|
widgetKey?: string;
|
||||||
|
widgetLines?: string[];
|
||||||
|
prefill?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent stream events replayed on SSE reconnect (excludes connected / prompt_rejected). */
|
||||||
|
export type StreamSseEvent =
|
||||||
|
| AgentStartEvent
|
||||||
|
| AgentEndEvent
|
||||||
|
| MessageStartEvent
|
||||||
|
| MessageUpdateEvent
|
||||||
|
| MessageEndEvent
|
||||||
|
| ToolExecutionStartEvent
|
||||||
|
| ToolExecutionUpdateEvent
|
||||||
|
| ToolExecutionEndEvent
|
||||||
|
| CompactionStartEvent
|
||||||
|
| CompactionEndEvent
|
||||||
|
| QueueUpdateEvent
|
||||||
|
| AutoRetryStartEvent
|
||||||
|
| AutoRetryEndEvent
|
||||||
|
| ExtensionErrorEvent
|
||||||
|
| BashUpdateEvent
|
||||||
|
| ExtensionUiRequestEvent;
|
||||||
|
|
||||||
|
export interface ConnectedEvent {
|
||||||
|
type: "connected";
|
||||||
|
isStreaming?: boolean;
|
||||||
|
replay?: StreamSseEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptRejectedEvent {
|
||||||
|
type: "prompt_rejected";
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SseEvent = ConnectedEvent | PromptRejectedEvent | StreamSseEvent;
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
name?: string;
|
||||||
|
command?: string;
|
||||||
|
description?: string;
|
||||||
|
path?: string;
|
||||||
|
scope?: string;
|
||||||
|
source?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
toggleable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolInfo {
|
||||||
|
server?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: string[];
|
||||||
|
required?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerInfo {
|
||||||
|
name?: string;
|
||||||
|
configured?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
cached?: boolean;
|
||||||
|
toolCount?: number;
|
||||||
|
enabledToolCount?: number;
|
||||||
|
resourceCount?: number;
|
||||||
|
cachedAt?: string;
|
||||||
|
tools?: McpToolInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionInfo {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
resolvedPath?: string;
|
||||||
|
kind?: string;
|
||||||
|
category?: "local" | "npm";
|
||||||
|
scope?: string;
|
||||||
|
source?: string;
|
||||||
|
location?: string;
|
||||||
|
version?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
commands?: string[];
|
||||||
|
tools?: string[];
|
||||||
|
handlers?: string[];
|
||||||
|
flags?: string[];
|
||||||
|
shortcuts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
systemPrompt?: string;
|
||||||
|
systemPromptPath?: string;
|
||||||
|
modelsConfig?: string;
|
||||||
|
modelsConfigPath?: string;
|
||||||
|
userAvatarUrl?: string;
|
||||||
|
agentAvatarUrl?: string;
|
||||||
|
skills?: SkillInfo[];
|
||||||
|
mcpTools?: McpServerInfo[];
|
||||||
|
mcpConfigPath?: string;
|
||||||
|
mcpCachePath?: string;
|
||||||
|
extensions?: ExtensionInfo[];
|
||||||
|
extensionsPath?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarSettings {
|
||||||
|
userAvatarUrl: string;
|
||||||
|
agentAvatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "env";
|
||||||
|
|
||||||
|
export interface EnvironmentInfo {
|
||||||
|
nodeVersion?: string;
|
||||||
|
platform?: string;
|
||||||
|
arch?: string;
|
||||||
|
osType?: string;
|
||||||
|
osRelease?: string;
|
||||||
|
hostname?: string;
|
||||||
|
pid?: number;
|
||||||
|
cwd?: string;
|
||||||
|
uptime?: number;
|
||||||
|
totalMemMb?: number;
|
||||||
|
freeMemMb?: number;
|
||||||
|
execPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryState {
|
||||||
|
active: boolean;
|
||||||
|
attempt?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
delayMs?: number;
|
||||||
|
startedAt?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionDialogState {
|
||||||
|
id: string;
|
||||||
|
method: "select" | "confirm" | "input" | "editor";
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
options?: string[];
|
||||||
|
prefill?: string;
|
||||||
|
}
|
||||||
116
frontend/src/types/message.ts
Normal file
116
frontend/src/types/message.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
export type MessageRole =
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool_call"
|
||||||
|
| "bash"
|
||||||
|
| "thinking"
|
||||||
|
| "compaction_summary"
|
||||||
|
| "branch_summary";
|
||||||
|
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
data?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
toolName?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageContent {
|
||||||
|
type: "image";
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpcMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | ContentBlock[];
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
cancelled?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
excludeFromContext?: boolean;
|
||||||
|
summary?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallBlock {
|
||||||
|
type: "toolCall";
|
||||||
|
toolName?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
images?: ImageContent[];
|
||||||
|
toolCallId?: string;
|
||||||
|
bashCommand?: string;
|
||||||
|
bashExitCode?: number;
|
||||||
|
bashExcludeFromContext?: boolean;
|
||||||
|
streaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
provider: string;
|
||||||
|
id: string;
|
||||||
|
reasoning?: boolean;
|
||||||
|
contextWindow?: number;
|
||||||
|
thinkingLevelMap?: Record<string, string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
firstMessage?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
modified?: string;
|
||||||
|
created?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHistoryPayload {
|
||||||
|
session?: { id?: string | number; name?: string };
|
||||||
|
messages?: RpcMessage[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
error?: string;
|
||||||
|
sessionId?: string | number;
|
||||||
|
sessionName?: string;
|
||||||
|
sessionFile?: string;
|
||||||
|
model?: ModelInfo;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
stats?: {
|
||||||
|
tokens?: {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
cacheWrite?: number;
|
||||||
|
};
|
||||||
|
cost?: number;
|
||||||
|
contextUsage?: { percent?: number; contextWindow?: number };
|
||||||
|
};
|
||||||
|
autoCompactionEnabled?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isCompacting?: boolean;
|
||||||
|
turnIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
|
|
||||||
|
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||||
22
frontend/src/types/session.ts
Normal file
22
frontend/src/types/session.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { RpcMessage } from "./message";
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
firstMessage?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
modified?: string;
|
||||||
|
created?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHistoryPayload {
|
||||||
|
session?: { id?: string | number; name?: string };
|
||||||
|
messages?: RpcMessage[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewSessionResponse {
|
||||||
|
sessionFile?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
39
frontend/src/utils/bash.ts
Normal file
39
frontend/src/utils/bash.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface BashResult {
|
||||||
|
output: string;
|
||||||
|
exitCode?: number;
|
||||||
|
cancelled?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedBashInput {
|
||||||
|
command: string;
|
||||||
|
display: string;
|
||||||
|
excludeFromContext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBashInput(text: string): ParsedBashInput | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.startsWith("!")) return null;
|
||||||
|
|
||||||
|
const excludeFromContext = trimmed.startsWith("!!");
|
||||||
|
const command = (excludeFromContext ? trimmed.slice(2) : trimmed.slice(1)).trim();
|
||||||
|
if (!command) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
display: trimmed,
|
||||||
|
excludeFromContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBashOutput(result: BashResult): string {
|
||||||
|
if (result.cancelled) return "(已取消)";
|
||||||
|
if (result.output?.trim()) {
|
||||||
|
let output = result.output;
|
||||||
|
if (result.truncated) output += "\n...(输出已截断)";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
if (result.exitCode === 0) return "(无输出)";
|
||||||
|
return "(无输出)";
|
||||||
|
}
|
||||||
123
frontend/src/utils/exportConversation.ts
Normal file
123
frontend/src/utils/exportConversation.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ChatMessage } from "../types/message";
|
||||||
|
import { escHtml } from "./format";
|
||||||
|
|
||||||
|
function roleLabel(role: ChatMessage["role"]): string {
|
||||||
|
switch (role) {
|
||||||
|
case "user":
|
||||||
|
return "用户";
|
||||||
|
case "assistant":
|
||||||
|
return "助手";
|
||||||
|
case "thinking":
|
||||||
|
return "思考";
|
||||||
|
case "tool_call":
|
||||||
|
return "工具";
|
||||||
|
case "system":
|
||||||
|
return "系统";
|
||||||
|
case "bash":
|
||||||
|
return "Shell";
|
||||||
|
case "compaction_summary":
|
||||||
|
return "压缩摘要";
|
||||||
|
case "branch_summary":
|
||||||
|
return "分支摘要";
|
||||||
|
default:
|
||||||
|
return "消息";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportableMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||||
|
return messages.filter((m) => !m.streaming && m.content.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToMarkdown(title: string, messages: ChatMessage[]): string {
|
||||||
|
const lines = [`# ${title}`, ""];
|
||||||
|
for (const m of exportableMessages(messages)) {
|
||||||
|
switch (m.role) {
|
||||||
|
case "user":
|
||||||
|
lines.push("## 用户", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "assistant":
|
||||||
|
lines.push("## 助手", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "thinking":
|
||||||
|
lines.push("## 思考", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "tool_call":
|
||||||
|
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||||
|
break;
|
||||||
|
case "system":
|
||||||
|
lines.push(`> ${m.content}`, "");
|
||||||
|
break;
|
||||||
|
case "bash": {
|
||||||
|
const cmd = m.bashCommand ? `$ ${m.bashCommand}` : "Shell";
|
||||||
|
lines.push(`## Shell (${cmd})`, "", "```", m.content, "```", "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "compaction_summary":
|
||||||
|
lines.push("## 压缩摘要", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "branch_summary":
|
||||||
|
lines.push("## 分支摘要", "", m.content, "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join("\n").trimEnd()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): string {
|
||||||
|
const sections = exportableMessages(messages)
|
||||||
|
.map((m) => {
|
||||||
|
const label = roleLabel(m.role);
|
||||||
|
return `<section class="block"><h2>${escHtml(label)}</h2><pre>${escHtml(m.content)}</pre></section>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:"Maple Mono CN",ui-monospace,monospace;letter-spacing:-0.05em;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||||
|
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||||
|
.block{margin:0 0 1rem}
|
||||||
|
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.9375rem;font-family:inherit}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escHtml(title)}</h1>
|
||||||
|
${sections}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeExportFilename(title: string): string {
|
||||||
|
const cleaned = title.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||||
|
return cleaned || "conversation";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadTextFile(filename: string, content: string, mime: string): void {
|
||||||
|
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function singleAssistantMarkdown(content: string): string {
|
||||||
|
return content.trimEnd() + (content.endsWith("\n") ? "" : "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageMarkdownFilename(content: string): string {
|
||||||
|
const snippet =
|
||||||
|
content
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 24)
|
||||||
|
.replace(/[\\/:*?"<>|]/g, "_") || "message";
|
||||||
|
return `${snippet}.md`;
|
||||||
|
}
|
||||||
63
frontend/src/utils/format.ts
Normal file
63
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export function formatFooterTokens(count: number | undefined): string {
|
||||||
|
const n = Number(count) || 0;
|
||||||
|
if (n <= 0) return "0";
|
||||||
|
if (n < 1000) return String(Math.round(n));
|
||||||
|
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
||||||
|
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
return `${Math.round(n / 1_000_000)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip TUI/terminal ANSI and orphaned CSI color codes for web display. */
|
||||||
|
export function stripAnsi(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
||||||
|
.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
|
||||||
|
.replace(/\[(?:\d{1,3};)*\d{0,3}m/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLen: number): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length <= maxLen) return trimmed;
|
||||||
|
return `${trimmed.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeAgo(isoStr: string | undefined): string {
|
||||||
|
if (!isoStr) return "";
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 1) return "刚刚";
|
||||||
|
if (mins < 60) return `${mins} 分钟前`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days} 天前`;
|
||||||
|
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncViewportHeight(): void {
|
||||||
|
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||||
|
document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTouchLike(): boolean {
|
||||||
|
return window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageIdCounter = 0;
|
||||||
|
export function nextMessageId(): string {
|
||||||
|
messageIdCounter += 1;
|
||||||
|
return `msg-${Date.now()}-${messageIdCounter}`;
|
||||||
|
}
|
||||||
117
frontend/src/utils/historyMessages.ts
Normal file
117
frontend/src/utils/historyMessages.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ChatMessage, SessionHistoryPayload, ToolCallBlock } from "../types/message";
|
||||||
|
import { nextMessageId } from "./format";
|
||||||
|
import {
|
||||||
|
extractContent,
|
||||||
|
extractImages,
|
||||||
|
extractThinking,
|
||||||
|
formatToolCall,
|
||||||
|
formatToolResult,
|
||||||
|
getToolCalls,
|
||||||
|
} from "./toolCall";
|
||||||
|
|
||||||
|
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
||||||
|
if (Array.isArray(m.content)) {
|
||||||
|
for (const block of m.content) {
|
||||||
|
const b = block as { type?: string; thinking?: string; text?: string };
|
||||||
|
if (b.type === "thinking") {
|
||||||
|
const thinking = (b.thinking ?? "").trim();
|
||||||
|
if (thinking) {
|
||||||
|
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
||||||
|
}
|
||||||
|
} else if (b.type === "text") {
|
||||||
|
const text = (b.text ?? "").trim();
|
||||||
|
if (text) {
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
if (last?.role === "assistant") {
|
||||||
|
last.content += text;
|
||||||
|
} else {
|
||||||
|
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (b.type === "toolCall") {
|
||||||
|
const tc = block as ToolCallBlock;
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = extractContent(m.content as string | undefined);
|
||||||
|
if (text) {
|
||||||
|
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
}
|
||||||
|
for (const tc of getToolCalls(m.content as string | undefined)) {
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
||||||
|
const result: ChatMessage[] = [];
|
||||||
|
for (const m of payload.messages || []) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
const text = extractContent(m.content);
|
||||||
|
const images = extractImages(m.content);
|
||||||
|
if (text || images.length) {
|
||||||
|
result.push({
|
||||||
|
id: nextMessageId(),
|
||||||
|
role: "user",
|
||||||
|
content: text,
|
||||||
|
...(images.length ? { images } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (m.role === "assistant") {
|
||||||
|
appendAssistantBlocks(m, result);
|
||||||
|
} else if (m.role === "toolResult" || m.role === "tool") {
|
||||||
|
const text = formatToolResult({ content: m.content });
|
||||||
|
if (text.trim()) {
|
||||||
|
result.push({
|
||||||
|
id: m.toolCallId ? `tool-${m.toolCallId}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: `✅ tool\n${text}`,
|
||||||
|
toolCallId: m.toolCallId ? String(m.toolCallId) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (m.role === "bashExecution") {
|
||||||
|
const output = typeof m.output === "string" ? m.output : "";
|
||||||
|
result.push({
|
||||||
|
id: nextMessageId(),
|
||||||
|
role: "user",
|
||||||
|
content: m.excludeFromContext ? `!!${m.command}` : `!${m.command}`,
|
||||||
|
});
|
||||||
|
result.push({
|
||||||
|
id: nextMessageId(),
|
||||||
|
role: "bash",
|
||||||
|
content: output,
|
||||||
|
bashCommand: m.command,
|
||||||
|
bashExitCode: m.exitCode,
|
||||||
|
bashExcludeFromContext: m.excludeFromContext,
|
||||||
|
});
|
||||||
|
} else if (m.role === "compactionSummary") {
|
||||||
|
const summary = typeof m.summary === "string" ? m.summary : extractContent(m.content);
|
||||||
|
if (summary.trim()) {
|
||||||
|
result.push({ id: nextMessageId(), role: "compaction_summary", content: summary });
|
||||||
|
}
|
||||||
|
} else if (m.role === "branchSummary") {
|
||||||
|
const summary = typeof m.summary === "string" ? m.summary : extractContent(m.content);
|
||||||
|
if (summary.trim()) {
|
||||||
|
result.push({ id: nextMessageId(), role: "branch_summary", content: summary });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string {
|
||||||
|
return extractThinking(m.content as string | undefined);
|
||||||
|
}
|
||||||
58
frontend/src/utils/images.ts
Normal file
58
frontend/src/utils/images.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface ImageContent {
|
||||||
|
type: "image";
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_CHAT_IMAGES = 8;
|
||||||
|
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
export const ALLOWED_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
|
export function imageToDataUrl(image: ImageContent): string {
|
||||||
|
return `data:${image.mimeType};base64,${image.data}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileToImageContent(file: File): Promise<ImageContent> {
|
||||||
|
if (!ALLOWED_IMAGE_MIME.has(file.type)) {
|
||||||
|
throw new Error(`不支持的图片类型: ${file.type || "unknown"}`);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!match) throw new Error("无效图片数据");
|
||||||
|
return { type: "image", mimeType: match[1], data: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clipboardItemsToImages(items: DataTransferItemList): Promise<ImageContent[]> {
|
||||||
|
const images: ImageContent[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.type.startsWith("image/")) continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
images.push(await fileToImageContent(file));
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImagePayload(image: ImageContent): ImageContent {
|
||||||
|
if (image.type !== "image") throw new Error("无效图片对象");
|
||||||
|
if (!ALLOWED_IMAGE_MIME.has(image.mimeType)) {
|
||||||
|
throw new Error(`不支持的图片类型: ${image.mimeType}`);
|
||||||
|
}
|
||||||
|
if (!image.data) throw new Error("图片数据为空");
|
||||||
|
const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
|
||||||
|
const size = Math.floor((image.data.length * 3) / 4) - padding;
|
||||||
|
if (size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||||
|
}
|
||||||
|
return { type: "image", mimeType: image.mimeType, data: image.data };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user