first commit
This commit is contained in:
@@ -5,11 +5,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
AgentDir string
|
||||
RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录)
|
||||
DataDir string // sproutclaw-web 自己的数据目录(SQLite 等)
|
||||
PiCmd string // pi CLI 启动命令(完整路径)
|
||||
PiArgs []string
|
||||
|
||||
@@ -29,11 +32,13 @@ type Config struct {
|
||||
func New() *Config {
|
||||
port := flag.Int("port", 19133, "HTTP port")
|
||||
agentDir := flag.String("agent-dir", "", "pi agent directory (e.g. /path/to/.pi/agent)")
|
||||
piCmd := flag.String("pi-cmd", "", "pi CLI executable path")
|
||||
repoRoot := flag.String("repo-root", "", "pi repo root (pi CLI cwd); defaults to two levels above agent-dir")
|
||||
dataDir := flag.String("data-dir", "", "sproutclaw-web data dir for SQLite (defaults to ./data)")
|
||||
piCmd := flag.String("pi-cmd", "", "pi CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
|
||||
frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)")
|
||||
flag.Parse()
|
||||
|
||||
if *agentDir == "" {
|
||||
// 默认值:当前目录的 .pi/agent
|
||||
cwd, _ := os.Getwd()
|
||||
*agentDir = filepath.Join(cwd, ".pi", "agent")
|
||||
}
|
||||
@@ -41,9 +46,34 @@ func New() *Config {
|
||||
cfg := &Config{
|
||||
Port: *port,
|
||||
AgentDir: *agentDir,
|
||||
PiCmd: *piCmd,
|
||||
RepoRoot: *repoRoot,
|
||||
DataDir: *dataDir,
|
||||
}
|
||||
// repoRoot defaults to the directory two levels above agentDir (.pi/agent -> repo root)
|
||||
if cfg.RepoRoot == "" {
|
||||
cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir))
|
||||
}
|
||||
// dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/)
|
||||
if cfg.DataDir == "" {
|
||||
cwd, _ := os.Getwd()
|
||||
cfg.DataDir = filepath.Join(cwd, "data")
|
||||
}
|
||||
if abs, err := filepath.Abs(cfg.DataDir); err == nil {
|
||||
cfg.DataDir = abs
|
||||
}
|
||||
// --pi-cmd may include arguments separated by spaces, e.g.
|
||||
// "node /path/to/tsx/dist/cli.mjs /path/to/pi/cli.ts"
|
||||
// Split into executable + args so exec.Command receives them correctly.
|
||||
if parts := strings.Fields(*piCmd); len(parts) > 0 {
|
||||
cfg.PiCmd = parts[0]
|
||||
cfg.PiArgs = parts[1:]
|
||||
}
|
||||
cfg.derivePaths()
|
||||
|
||||
// Override frontend dist if explicitly specified
|
||||
if *frontendDist != "" {
|
||||
cfg.FrontendDist = *frontendDist
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate images
|
||||
if len(req.Images) > 0 {
|
||||
if err := services.ValidateImages(req.Images); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
@@ -39,51 +38,40 @@ func handleChat(piClient *rpc.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Try slash command dispatch
|
||||
result := services.TrySlashDispatch(req.Message)
|
||||
if result.Handled {
|
||||
if result.Message != "" {
|
||||
// unsupported command – return friendly message
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "message": result.Message})
|
||||
return
|
||||
}
|
||||
// supported built-in command – forward to pi CLI as a command type
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: result.Command,
|
||||
Args: result.Args,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"handled": true, "result": resp.Result})
|
||||
return
|
||||
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
|
||||
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
|
||||
req.StreamingBehavior = ""
|
||||
}
|
||||
|
||||
// Regular prompt
|
||||
// Submit prompt; agent output streams back over SSE.
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusAccepted, gin.H{"ok": true, "accepted": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSteer(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.SteerRequest
|
||||
var req models.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err := piClient.SubmitPrompt(models.ChatRequest{
|
||||
Message: req.Message,
|
||||
StreamingBehavior: "steer",
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "steer",
|
||||
Message: req.Message,
|
||||
Images: req.Images,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "steer 失败")})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
@@ -95,11 +83,19 @@ func handleFollowUp(piClient *rpc.Client) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
req.StreamingBehavior = "follow_up"
|
||||
if err := piClient.SubmitPrompt(req); err != nil {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "follow_up",
|
||||
Message: req.Message,
|
||||
Images: req.Images,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "follow_up 失败")})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
@@ -112,21 +108,24 @@ func handleBash(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "bash",
|
||||
Args: req.Command,
|
||||
Type: "bash",
|
||||
Command: req.Command,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp.Result)
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: orDefault(resp.Error, "命令执行失败")})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort"})
|
||||
if err != nil {
|
||||
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort"}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -136,8 +135,7 @@ func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
|
||||
|
||||
func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"})
|
||||
if err != nil {
|
||||
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -147,16 +145,16 @@ func handleAbortRetry(piClient *rpc.Client) gin.HandlerFunc {
|
||||
|
||||
func handleMessages(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sessionFile": snap.SessionFile,
|
||||
"events": snap.ReplayEvents,
|
||||
})
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +171,7 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan []byte, 64)
|
||||
ch := make(chan []byte, 256)
|
||||
client := &rpc.SSEClient{
|
||||
Ch: ch,
|
||||
Done: c.Request.Context().Done(),
|
||||
@@ -181,19 +179,19 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
||||
piClient.RegisterSSEClient(client)
|
||||
defer piClient.UnregisterSSEClient(client)
|
||||
|
||||
// send initial keepalive
|
||||
_, _ = io.WriteString(c.Writer, ": keepalive\n\n")
|
||||
// initial comment to open the stream
|
||||
_, _ = io.WriteString(c.Writer, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case raw, ok := <-ch:
|
||||
case frame, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
frame := rpc.FormatSSEEvent(raw)
|
||||
// frame is already a fully-formatted "data: ...\n\n" SSE frame
|
||||
if _, err := c.Writer.Write(frame); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -203,12 +201,21 @@ func handleSSE(piClient *rpc.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// jsonRaw is a helper to return pre-serialized JSON.
|
||||
// jsonRaw decodes pre-serialized JSON for re-emission via gin.
|
||||
func jsonRaw(raw json.RawMessage) any {
|
||||
if raw == nil {
|
||||
return nil
|
||||
if len(raw) == 0 {
|
||||
return gin.H{}
|
||||
}
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func orDefault(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
@@ -13,27 +11,23 @@ import (
|
||||
|
||||
// RegisterModels registers model-related routes.
|
||||
func RegisterModels(r *gin.RouterGroup, cfg *config.Config, piClient *rpc.Client) {
|
||||
r.GET("/models", handleGetModels(cfg, piClient))
|
||||
r.GET("/models", handleGetModels(piClient))
|
||||
r.POST("/model", handleSetModel(piClient))
|
||||
r.POST("/thinking", handleSetThinking(piClient))
|
||||
}
|
||||
|
||||
func handleGetModels(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
func handleGetModels(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "list_models"})
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_available_models"})
|
||||
if err != nil {
|
||||
// fallback: read models.json
|
||||
data, ferr := os.ReadFile(cfg.ModelsConfigFile)
|
||||
if ferr != nil {
|
||||
c.JSON(http.StatusOK, models.ModelsResponse{Models: []models.ModelEntry{}})
|
||||
return
|
||||
}
|
||||
var raw any
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
c.JSON(http.StatusOK, raw)
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +47,11 @@ func handleSetModel(piClient *rpc.Client) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"model": jsonRaw(resp.Data)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +63,17 @@ func handleSetThinking(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "set_thinking",
|
||||
Budget: req.Budget,
|
||||
Type: "set_thinking_level",
|
||||
Level: req.Level,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -47,7 +48,24 @@ func handleNewSession(piClient *rpc.Client) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
// merge sessionFile from a follow-up get_state
|
||||
out := map[string]any{}
|
||||
if len(resp.Data) > 0 {
|
||||
_ = jsonUnmarshalInto(resp.Data, &out)
|
||||
}
|
||||
if state, serr := piClient.SendCmd(models.RPCCommand{Type: "get_state"}); serr == nil && state.Success {
|
||||
var sd struct {
|
||||
SessionFile string `json:"sessionFile"`
|
||||
}
|
||||
if jsonUnmarshalInto(state.Data, &sd) == nil && sd.SessionFile != "" {
|
||||
out["sessionFile"] = sd.SessionFile
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,20 +139,31 @@ func handleLoadSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
lines, err := services.ReadSessionMessages(req.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
sw, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
SessionPath: req.Path,
|
||||
CwdOverride: cfg.RepoRoot,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": lines, "result": jsonRaw(resp.Result)})
|
||||
if !sw.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error})
|
||||
return
|
||||
}
|
||||
mr, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !mr.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: mr.Error})
|
||||
return
|
||||
}
|
||||
// mr.Data is { messages: [...] }; forward it plus a session summary
|
||||
summary, _ := services.ReadSessionSummaryByPath(req.Path)
|
||||
c.JSON(http.StatusOK, gin.H{"messages": extractMessages(mr.Data), "session": summary})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,15 +178,25 @@ func handleActivateSession(cfg *config.Config, piClient *rpc.Client) gin.Handler
|
||||
c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"})
|
||||
return
|
||||
}
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
Path: req.Path,
|
||||
sw, err := piClient.SendCmd(models.RPCCommand{
|
||||
Type: "switch_session",
|
||||
SessionPath: req.Path,
|
||||
CwdOverride: cfg.RepoRoot,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Result))
|
||||
if !sw.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error})
|
||||
return
|
||||
}
|
||||
state, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "state": jsonRaw(state.Data)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,10 +221,36 @@ func handleNameSession(cfg *config.Config) gin.HandlerFunc {
|
||||
|
||||
func handleSessionState(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap := piClient.GetSnapshot()
|
||||
c.JSON(http.StatusOK, models.SessionStateResponse{
|
||||
IsStreaming: snap.IsStreaming,
|
||||
SessionFile: snap.SessionFile,
|
||||
})
|
||||
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.Success {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jsonRaw(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// extractMessages pulls the "messages" array out of a get_messages data payload.
|
||||
func extractMessages(data json.RawMessage) any {
|
||||
if len(data) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
var wrap struct {
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
}
|
||||
if jsonUnmarshalInto(data, &wrap) == nil && len(wrap.Messages) > 0 {
|
||||
return jsonRaw(wrap.Messages)
|
||||
}
|
||||
return jsonRaw(data)
|
||||
}
|
||||
|
||||
func jsonUnmarshalInto(raw json.RawMessage, v any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, v)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"sproutclaw-web/internal/config"
|
||||
@@ -17,7 +15,7 @@ import (
|
||||
|
||||
// RegisterSettings registers all settings-related routes.
|
||||
func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) {
|
||||
r.GET("/settings", handleGetSettings(cfg))
|
||||
r.GET("/settings", handleGetSettings(cfg, database))
|
||||
r.POST("/settings/skills/toggle", handleToggleSkill(cfg, piClient))
|
||||
r.POST("/settings/extensions/toggle", handleToggleExtension(cfg, piClient))
|
||||
r.POST("/settings/mcp/server/toggle", handleToggleMCPServer(cfg, piClient))
|
||||
@@ -29,53 +27,67 @@ func RegisterSettings(r *gin.RouterGroup, cfg *config.Config, database *db.DB, p
|
||||
r.GET("/environment", handleEnvironment(cfg))
|
||||
}
|
||||
|
||||
func handleGetSettings(cfg *config.Config) gin.HandlerFunc {
|
||||
func handleGetSettings(cfg *config.Config, database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
skills := services.ListSkills(cfg.AgentDir)
|
||||
extensions := services.ListExtensions(cfg.AgentDir)
|
||||
mcpServers := services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile)
|
||||
systemPrompt, _ := services.ReadSystemPrompt(cfg.SystemPromptFile)
|
||||
modelsConfig, _ := services.ReadModelsConfig(cfg.ModelsConfigFile)
|
||||
userAvatar, _ := database.GetConfig("userAvatarUrl")
|
||||
agentAvatar, _ := database.GetConfig("agentAvatarUrl")
|
||||
|
||||
c.JSON(http.StatusOK, models.SettingsResponse{
|
||||
Skills: skills,
|
||||
Extensions: extensions,
|
||||
MCPServers: mcpServers,
|
||||
SystemPrompt: systemPrompt,
|
||||
ModelsConfig: modelsConfig,
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"systemPrompt": systemPrompt,
|
||||
"systemPromptPath": cfg.SystemPromptFile,
|
||||
"modelsConfig": modelsConfig,
|
||||
"modelsConfigPath": cfg.ModelsConfigFile,
|
||||
"userAvatarUrl": userAvatar,
|
||||
"agentAvatarUrl": agentAvatar,
|
||||
"skills": services.ListSkills(cfg.AgentDir),
|
||||
"extensions": services.ListExtensions(cfg.AgentDir),
|
||||
"extensionsPath": cfg.ExtensionsDir,
|
||||
"mcpTools": services.ReadMCPServers(cfg.McpConfigFile, cfg.McpCacheFile),
|
||||
"mcpConfigPath": cfg.McpConfigFile,
|
||||
"mcpCachePath": cfg.McpCacheFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleSkill(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
var req models.SkillToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleSkill(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
if req.Path == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "skill path 无效"})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleSkill(cfg.AgentDir, req.Path, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleToggleExtension(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req models.ToggleRequest
|
||||
var req models.ExtensionToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleExtension(cfg.AgentDir, req.ID, req.Enabled); err != nil {
|
||||
if req.Path == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "extension path 无效"})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleExtension(cfg.AgentDir, req.Path, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,12 +98,16 @@ func handleToggleMCPServer(cfg *config.Config, piClient *rpc.Client) gin.Handler
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Server == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server 名称无效"})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleMCPServer(cfg.McpConfigFile, req.Server, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,19 +118,23 @@ func handleToggleMCPTool(cfg *config.Config, piClient *rpc.Client) gin.HandlerFu
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Server == "" || req.Tool == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "server / tool 名称无效"})
|
||||
return
|
||||
}
|
||||
if err := services.ToggleMCPTool(cfg.McpConfigFile, req.Server, req.Tool, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleReload(piClient *rpc.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +145,17 @@ func handleSetModelsConfig(cfg *config.Config, piClient *rpc.Client) gin.Handler
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.WriteModelsConfig(cfg.ModelsConfigFile, req.Content); err != nil {
|
||||
// Guard: the field must be present (not just empty) to avoid clobbering.
|
||||
if req.ModelsConfig == nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 modelsConfig 字段"})
|
||||
return
|
||||
}
|
||||
if err := services.WriteModelsConfig(cfg.ModelsConfigFile, *req.ModelsConfig); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "modelsConfigPath": cfg.ModelsConfigFile})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +166,18 @@ func handleSetSystemPrompt(cfg *config.Config, piClient *rpc.Client) gin.Handler
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.WriteSystemPrompt(cfg.SystemPromptFile, req.Content); err != nil {
|
||||
// Guard: the field must be present. A missing field would otherwise
|
||||
// silently write an empty string and wipe the prompt file.
|
||||
if req.SystemPrompt == nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "缺少 systemPrompt 字段"})
|
||||
return
|
||||
}
|
||||
if err := services.WriteSystemPrompt(cfg.SystemPromptFile, *req.SystemPrompt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
reloadAgent(piClient)
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "systemPromptPath": cfg.SystemPromptFile})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,27 +188,27 @@ func handleSetAvatars(database *db.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.UserAvatar != "" {
|
||||
_ = database.SetConfig("userAvatar", req.UserAvatar)
|
||||
}
|
||||
if req.AssistantAvatar != "" {
|
||||
_ = database.SetConfig("assistantAvatar", req.AssistantAvatar)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.OKResponse{OK: true})
|
||||
userURL := normalizeAvatarURL(req.UserAvatarURL)
|
||||
agentURL := normalizeAvatarURL(req.AgentAvatarURL)
|
||||
_ = database.SetConfig("userAvatarUrl", userURL)
|
||||
_ = database.SetConfig("agentAvatarUrl", agentURL)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "userAvatarUrl": userURL, "agentAvatarUrl": agentURL})
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
lanAddrs := getLANAddresses()
|
||||
cwd, _ := os.Getwd()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
"version": runtime.Version(),
|
||||
"port": cfg.Port,
|
||||
"lan": lanAddrs,
|
||||
"nodeVersion": runtime.Version(),
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
"pid": os.Getpid(),
|
||||
"cwd": cwd,
|
||||
"port": cfg.Port,
|
||||
"repoRoot": cfg.RepoRoot,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -185,31 +216,3 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
|
||||
func reloadAgent(piClient *rpc.Client) {
|
||||
_, _ = piClient.SendCmd(models.RPCCommand{Type: "reload"})
|
||||
}
|
||||
|
||||
func getLANAddresses() []string {
|
||||
// simplified – returns first non-loopback IPv4
|
||||
var addrs []string
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return addrs
|
||||
}
|
||||
_ = hostname
|
||||
// Use a simple approach: read network interfaces via fmt
|
||||
// For a full implementation, net.InterfaceAddrs() would be used
|
||||
ifaces := getNetworkAddresses()
|
||||
for _, a := range ifaces {
|
||||
if !strings.HasPrefix(a, "127.") && !strings.HasPrefix(a, "::") {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func getNetworkAddresses() []string {
|
||||
var result []string
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname != "" {
|
||||
result = append(result, fmt.Sprintf("%s (hostname)", hostname))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
44
backend/internal/handlers/terminal.go
Normal file
44
backend/internal/handlers/terminal.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"sproutclaw-web/internal/config"
|
||||
"sproutclaw-web/internal/services"
|
||||
)
|
||||
|
||||
var terminalUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// RegisterTerminal registers web terminal routes.
|
||||
func RegisterTerminal(r *gin.RouterGroup, cfg *config.Config) {
|
||||
r.GET("/terminal", handleTerminalInfo(cfg))
|
||||
r.GET("/terminal/ws", handleTerminalWS(cfg))
|
||||
}
|
||||
|
||||
func handleTerminalInfo(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"repoRoot": cfg.RepoRoot,
|
||||
"platform": runtime.GOOS,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleTerminalWS(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := services.ServeWebTerminal(conn, cfg.RepoRoot); err != nil {
|
||||
_ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ func RegisterWebuiConfig(r *gin.RouterGroup, database *db.DB) {
|
||||
|
||||
func handleGetAvatars(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, _ := database.GetConfig("userAvatar")
|
||||
assistant, _ := database.GetConfig("assistantAvatar")
|
||||
user, _ := database.GetConfig("userAvatarUrl")
|
||||
agent, _ := database.GetConfig("agentAvatarUrl")
|
||||
c.JSON(http.StatusOK, models.AvatarsResponse{
|
||||
UserAvatar: normalizeAvatarURL(user),
|
||||
AssistantAvatar: normalizeAvatarURL(assistant),
|
||||
UserAvatarURL: normalizeAvatarURL(user),
|
||||
AgentAvatarURL: normalizeAvatarURL(agent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Extensions")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
|
||||
@@ -7,29 +7,33 @@ import "encoding/json"
|
||||
type RPCCommand struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
// prompt fields
|
||||
Text string `json:"text,omitempty"`
|
||||
Images []ChatImage `json:"images,omitempty"`
|
||||
// model / thinking fields
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
Budget *int `json:"budget,omitempty"`
|
||||
// session fields
|
||||
Path string `json:"path,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// streaming behaviour
|
||||
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
||||
// slash dispatch passthrough
|
||||
Args string `json:"args,omitempty"`
|
||||
// extension ui
|
||||
Response json.RawMessage `json:"response,omitempty"`
|
||||
// prompt / steer / follow_up
|
||||
Message string `json:"message,omitempty"`
|
||||
Images []ChatImage `json:"images,omitempty"`
|
||||
StreamingBehavior string `json:"streamingBehavior,omitempty"`
|
||||
// model / thinking
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
Level json.RawMessage `json:"level,omitempty"`
|
||||
// session
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
CwdOverride string `json:"cwdOverride,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// bash
|
||||
Command string `json:"command,omitempty"`
|
||||
ExcludeFromContext bool `json:"excludeFromContext,omitempty"`
|
||||
}
|
||||
|
||||
// RPCResponse mirrors the pi CLI response frame:
|
||||
//
|
||||
// {"type":"response","id":"req_1","command":"get_state","success":true,"data":{...},"error":"..."}
|
||||
type RPCResponse struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Raw event line forwarded to SSE clients as-is
|
||||
@@ -130,7 +134,7 @@ type SetModelRequest struct {
|
||||
}
|
||||
|
||||
type SetThinkingRequest struct {
|
||||
Budget *int `json:"budget"` // nil = disable
|
||||
Level json.RawMessage `json:"level"` // string ("high"/"off"...) or number
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
@@ -148,8 +152,8 @@ type CommandsResponse struct {
|
||||
// ── WebUI Config ──────────────────────────────────────────────────────────────
|
||||
|
||||
type AvatarsResponse struct {
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
||||
UserAvatarURL string `json:"userAvatarUrl"`
|
||||
AgentAvatarURL string `json:"agentAvatarUrl"`
|
||||
}
|
||||
|
||||
type WebuiConfigResponse struct {
|
||||
@@ -171,46 +175,53 @@ type WebuiConfigDeleteRequest struct {
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
// Field names mirror the frontend SettingsData contract (frontend/src/types/events.ts).
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Toggleable bool `json:"toggleable"`
|
||||
}
|
||||
|
||||
type ExtensionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source"` // local | npm
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Category string `json:"category,omitempty"` // local | npm
|
||||
Source string `json:"source,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Toggleable bool `json:"toggleable"` // only local extensions can be toggled
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
type MCPTool struct {
|
||||
Server string `json:"server,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type MCPServer struct {
|
||||
Name string `json:"name"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Tools []MCPTool `json:"tools,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
EnabledToolCount int `json:"enabledToolCount"`
|
||||
Tools []MCPTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type SettingsResponse struct {
|
||||
Skills []SkillInfo `json:"skills"`
|
||||
Extensions []ExtensionInfo `json:"extensions"`
|
||||
MCPServers []MCPServer `json:"mcpServers"`
|
||||
SystemPrompt string `json:"systemPrompt,omitempty"`
|
||||
ModelsConfig string `json:"modelsConfig,omitempty"`
|
||||
// SkillToggleRequest / ExtensionToggleRequest identify the target by path.
|
||||
type SkillToggleRequest struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ToggleRequest struct {
|
||||
ID string `json:"id"`
|
||||
type ExtensionToggleRequest struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -225,24 +236,19 @@ type MCPToolToggleRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// SystemPromptRequest uses a pointer so a missing field is distinguishable from
|
||||
// an empty string — guards against accidentally clearing the file.
|
||||
type SystemPromptRequest struct {
|
||||
Content string `json:"content"`
|
||||
SystemPrompt *string `json:"systemPrompt"`
|
||||
}
|
||||
|
||||
type ModelsConfigRequest struct {
|
||||
Content string `json:"content"`
|
||||
ModelsConfig *string `json:"modelsConfig"`
|
||||
}
|
||||
|
||||
type AvatarsSetRequest struct {
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
AssistantAvatar string `json:"assistantAvatar,omitempty"`
|
||||
}
|
||||
|
||||
type EnvironmentResponse struct {
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Hostname string `json:"hostname"`
|
||||
Version string `json:"version"`
|
||||
UserAvatarURL string `json:"userAvatarUrl"`
|
||||
AgentAvatarURL string `json:"agentAvatarUrl"`
|
||||
}
|
||||
|
||||
// ── Extension UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
@@ -20,10 +21,29 @@ const (
|
||||
promptTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// bufferedEventTypes are the event types replayed to newly-connected SSE
|
||||
// clients while the agent is mid-run (mirrors the original pi-client.ts).
|
||||
var bufferedEventTypes = map[string]bool{
|
||||
"agent_start": true,
|
||||
"agent_end": true,
|
||||
"message_start": true,
|
||||
"message_update": true,
|
||||
"message_end": true,
|
||||
"tool_execution_start": true,
|
||||
"tool_execution_update": true,
|
||||
"tool_execution_end": true,
|
||||
"compaction_start": true,
|
||||
"compaction_end": true,
|
||||
"queue_update": true,
|
||||
"auto_retry_start": true,
|
||||
"auto_retry_end": true,
|
||||
"bash_update": true,
|
||||
}
|
||||
|
||||
// SSEClient represents a connected browser SSE client.
|
||||
type SSEClient struct {
|
||||
Ch chan []byte
|
||||
Done <-chan struct{}
|
||||
Ch chan []byte
|
||||
Done <-chan struct{}
|
||||
}
|
||||
|
||||
// RunSnapshot holds a snapshot of the current pi agent run state.
|
||||
@@ -36,27 +56,33 @@ type RunSnapshot struct {
|
||||
// Client manages a pi CLI subprocess and bridges its JSON-RPC protocol
|
||||
// to HTTP handlers and SSE clients.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
pending map[string]chan models.RPCResponse
|
||||
sseClients map[*SSEClient]struct{}
|
||||
replay []json.RawMessage // events in current agent run
|
||||
isRunning bool
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
pending map[string]chan models.RPCResponse
|
||||
sseClients map[*SSEClient]struct{}
|
||||
replay []json.RawMessage // events in current agent run
|
||||
isRunning bool
|
||||
sessionFile string
|
||||
reqMu sync.Mutex
|
||||
reqCounter int
|
||||
}
|
||||
|
||||
// NewClient starts the pi CLI subprocess and begins reading its stdout.
|
||||
// onExit is called with the exit code when the subprocess terminates.
|
||||
func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int)) (*Client, error) {
|
||||
// repoRoot is used as the subprocess working directory; onExit is called
|
||||
// with the exit code when the subprocess terminates.
|
||||
func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) {
|
||||
if piCmd == "" {
|
||||
return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
|
||||
}
|
||||
|
||||
args := append(piArgs, "--rpc")
|
||||
// pi enters RPC mode via "--mode rpc"
|
||||
args := append(append([]string{}, piArgs...), "--mode", "rpc")
|
||||
cmd := exec.Command(piCmd, args...)
|
||||
cmd.Dir = agentDir
|
||||
cmd.Env = append(cmd.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
||||
cmd.Dir = repoRoot
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
||||
|
||||
log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
@@ -66,12 +92,15 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = log.Writer()
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start pi cli: %w", err)
|
||||
}
|
||||
|
||||
// Keep stdin open; closing it on Windows can trigger RPC shutdown.
|
||||
_, _ = stdin.Write([]byte(""))
|
||||
|
||||
c := &Client{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
@@ -86,82 +115,103 @@ func NewClient(piCmd string, piArgs []string, agentDir string, onExit func(int))
|
||||
if cmd.ProcessState != nil {
|
||||
code = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code)
|
||||
onExit(code)
|
||||
}()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) nextReqID() string {
|
||||
c.reqMu.Lock()
|
||||
c.reqCounter++
|
||||
id := c.reqCounter
|
||||
c.reqMu.Unlock()
|
||||
return "req_" + strconv.Itoa(id)
|
||||
}
|
||||
|
||||
// readLoop reads stdout line-by-line and dispatches messages.
|
||||
func (c *Client) readLoop(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
c.dispatch(line)
|
||||
c.dispatch(append([]byte(nil), line...))
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch parses one JSON line from stdout and routes it.
|
||||
func (c *Client) dispatch(raw []byte) {
|
||||
var msg struct {
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return // ignore non-JSON lines
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
switch msg.Type {
|
||||
case "response":
|
||||
// Response frame: resolve the pending request.
|
||||
if head.Type == "response" && head.ID != "" {
|
||||
var resp models.RPCResponse
|
||||
if err := json.Unmarshal(raw, &resp); err == nil {
|
||||
if ch, ok := c.pending[resp.ID]; ok {
|
||||
// capture sessionFile from get_state responses
|
||||
if resp.Command == "get_state" && resp.Success {
|
||||
var data struct {
|
||||
SessionFile string `json:"sessionFile"`
|
||||
}
|
||||
if json.Unmarshal(resp.Data, &data) == nil && data.SessionFile != "" {
|
||||
c.mu.Lock()
|
||||
c.sessionFile = data.SessionFile
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
ch, ok := c.pending[resp.ID]
|
||||
if ok {
|
||||
delete(c.pending, resp.ID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if ok {
|
||||
ch <- resp
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Event frame: track agent run state + broadcast to SSE clients.
|
||||
c.trackAgentEvent(head.Type, raw)
|
||||
c.mu.Lock()
|
||||
c.broadcastLocked(raw)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) trackAgentEvent(typ string, raw []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch typ {
|
||||
case "agent_start":
|
||||
c.isRunning = true
|
||||
c.replay = nil
|
||||
// try to extract session path
|
||||
var ev struct {
|
||||
SessionFile string `json:"sessionFile"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ev); err == nil && ev.SessionFile != "" {
|
||||
c.sessionFile = ev.SessionFile
|
||||
}
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
c.broadcastLocked(rawCopy)
|
||||
|
||||
c.replay = []json.RawMessage{raw}
|
||||
case "agent_end":
|
||||
c.replay = append(c.replay, raw)
|
||||
c.isRunning = false
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
c.broadcastLocked(rawCopy)
|
||||
|
||||
c.replay = nil
|
||||
default:
|
||||
rawCopy := append([]byte(nil), raw...)
|
||||
if c.isRunning {
|
||||
c.replay = append(c.replay, rawCopy)
|
||||
if c.isRunning && bufferedEventTypes[typ] {
|
||||
c.replay = append(c.replay, raw)
|
||||
}
|
||||
c.broadcastLocked(rawCopy)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastLocked sends raw JSON to all SSE clients. Must hold c.mu.
|
||||
func (c *Client) broadcastLocked(raw []byte) {
|
||||
frame := formatSSEData(raw)
|
||||
for client := range c.sseClients {
|
||||
select {
|
||||
case client.Ch <- raw:
|
||||
case client.Ch <- frame:
|
||||
default:
|
||||
// slow client: drop
|
||||
}
|
||||
@@ -176,15 +226,13 @@ func (c *Client) send(cmd models.RPCCommand) error {
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err = fmt.Fprintf(c.stdin, "%s\n", b)
|
||||
_, err = c.stdin.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// SendCmd sends a command and waits for the matching response.
|
||||
func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
||||
if cmd.ID == "" {
|
||||
cmd.ID = uuid.New().String()
|
||||
}
|
||||
cmd.ID = c.nextReqID()
|
||||
ch := make(chan models.RPCResponse, 1)
|
||||
|
||||
c.mu.Lock()
|
||||
@@ -205,20 +253,50 @@ func (c *Client) SendCmd(cmd models.RPCCommand) (models.RPCResponse, error) {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, cmd.ID)
|
||||
c.mu.Unlock()
|
||||
return models.RPCResponse{}, fmt.Errorf("rpc timeout: %s", cmd.Type)
|
||||
return models.RPCResponse{}, fmt.Errorf("命令超时: %s", cmd.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitPrompt sends a prompt without waiting for a response.
|
||||
// SubmitPrompt sends a prompt. It registers a preflight pending entry but does
|
||||
// not block the HTTP handler — agent output arrives via SSE.
|
||||
func (c *Client) SubmitPrompt(req models.ChatRequest) error {
|
||||
id := c.nextReqID()
|
||||
cmd := models.RPCCommand{
|
||||
Type: "prompt",
|
||||
ID: uuid.New().String(),
|
||||
Text: req.Message,
|
||||
ID: id,
|
||||
Message: req.Message,
|
||||
Images: req.Images,
|
||||
StreamingBehavior: req.StreamingBehavior,
|
||||
}
|
||||
return c.send(cmd)
|
||||
|
||||
ch := make(chan models.RPCResponse, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.send(cmd); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Preflight: wait briefly for a rejection; success streams via SSE.
|
||||
go func() {
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if !resp.Success {
|
||||
c.mu.Lock()
|
||||
c.broadcastLocked([]byte(fmt.Sprintf(`{"type":"prompt_rejected","error":%q}`, resp.Error)))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
case <-time.After(promptTimeout):
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSnapshot returns the current run snapshot.
|
||||
@@ -234,25 +312,26 @@ func (c *Client) GetSnapshot() RunSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSSEClient adds a client to the broadcast set and replays buffered events.
|
||||
// RegisterSSEClient adds a client and sends the initial "connected" frame
|
||||
// carrying the current run snapshot (matches the original pi-client.ts).
|
||||
func (c *Client) RegisterSSEClient(client *SSEClient) {
|
||||
c.mu.Lock()
|
||||
c.sseClients[client] = struct{}{}
|
||||
// replay current run events
|
||||
replay := make([]json.RawMessage, len(c.replay))
|
||||
copy(replay, c.replay)
|
||||
isStreaming := c.isRunning
|
||||
c.mu.Unlock()
|
||||
|
||||
// send replay in a goroutine so we don't hold the lock
|
||||
go func() {
|
||||
for _, ev := range replay {
|
||||
select {
|
||||
case client.Ch <- ev:
|
||||
case <-client.Done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
connected := map[string]any{
|
||||
"type": "connected",
|
||||
"isStreaming": isStreaming,
|
||||
"replay": replay,
|
||||
}
|
||||
b, _ := json.Marshal(connected)
|
||||
select {
|
||||
case client.Ch <- formatSSEData(b):
|
||||
case <-client.Done:
|
||||
}
|
||||
}
|
||||
|
||||
// UnregisterSSEClient removes a client from the broadcast set.
|
||||
@@ -263,12 +342,23 @@ func (c *Client) UnregisterSSEClient(client *SSEClient) {
|
||||
}
|
||||
|
||||
// SendExtensionUIResponse forwards a UI response back to the pi CLI.
|
||||
// The response object is merged into the top-level frame: {type, id, ...response}.
|
||||
func (c *Client) SendExtensionUIResponse(id string, response json.RawMessage) error {
|
||||
return c.send(models.RPCCommand{
|
||||
Type: "extension_ui_response",
|
||||
ID: id,
|
||||
Response: response,
|
||||
})
|
||||
merged := map[string]any{"type": "extension_ui_response", "id": id}
|
||||
var extra map[string]any
|
||||
if json.Unmarshal(response, &extra) == nil {
|
||||
for k, v := range extra {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err = c.stdin.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop terminates the pi CLI subprocess.
|
||||
@@ -278,17 +368,6 @@ func (c *Client) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// buildEnv returns os.Environ() with extra variables appended.
|
||||
func (c *Client) Environ() []string {
|
||||
return c.cmd.Environ()
|
||||
}
|
||||
|
||||
// helper
|
||||
func mustMarshal(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// IsRunning returns true when the agent is actively streaming.
|
||||
func (c *Client) IsRunning() bool {
|
||||
c.mu.Lock()
|
||||
@@ -296,9 +375,8 @@ func (c *Client) IsRunning() bool {
|
||||
return c.isRunning
|
||||
}
|
||||
|
||||
// FormatSSEEvent formats a raw JSON payload as an SSE data line.
|
||||
func FormatSSEEvent(raw []byte) []byte {
|
||||
// strip newlines so the SSE frame stays on one logical line
|
||||
trimmed := strings.TrimRight(string(raw), "\n\r")
|
||||
// formatSSEData wraps a raw JSON payload as an SSE "data:" frame.
|
||||
func formatSSEData(raw []byte) []byte {
|
||||
trimmed := strings.TrimRight(string(raw), "\r\n")
|
||||
return []byte("data: " + trimmed + "\n\n")
|
||||
}
|
||||
|
||||
@@ -2,27 +2,43 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListExtensions reads the extensions directory and returns metadata.
|
||||
var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`)
|
||||
|
||||
// ListExtensions returns local extensions (enabled and disabled) plus npm packages
|
||||
// declared in settings.json. Local ones live in extensions/ and extensions-disabled/.
|
||||
func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||
extDir := filepath.Join(agentDir, "extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
var exts []models.ExtensionInfo
|
||||
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions"), true)...)
|
||||
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions-disabled"), false)...)
|
||||
exts = append(exts, listNpmExtensions(agentDir)...)
|
||||
return exts
|
||||
}
|
||||
|
||||
func scanExtensionsDir(dir string, enabled bool) []models.ExtensionInfo {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exts []models.ExtensionInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := buildExtensionInfo(filepath.Join(extDir, e.Name()), e.Name())
|
||||
ext := buildExtensionInfo(filepath.Join(dir, e.Name()), e.Name())
|
||||
// Anything physically under extensions/ or extensions-disabled/ is a
|
||||
// local extension and can be toggled, regardless of its package.json name.
|
||||
ext.Category = "local"
|
||||
ext.Toggleable = true
|
||||
ext.Enabled = enabled
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
return exts
|
||||
@@ -30,13 +46,11 @@ func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||
|
||||
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info := models.ExtensionInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Enabled: true, // assume enabled unless configured otherwise
|
||||
Source: "local",
|
||||
Name: id,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
// try package.json for version/name
|
||||
// try package.json for name/version
|
||||
pkgPath := filepath.Join(path, "package.json")
|
||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||
var pkg struct {
|
||||
@@ -48,58 +62,149 @@ func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info.Name = pkg.Name
|
||||
}
|
||||
info.Version = pkg.Version
|
||||
// detect npm packages
|
||||
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
|
||||
info.Source = "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ToggleExtension is a placeholder – full implementation requires settings.json management.
|
||||
func ToggleExtension(agentDir, id string, enable bool) error {
|
||||
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
|
||||
func listNpmExtensions(agentDir string) []models.ExtensionInfo {
|
||||
sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json"))
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(sources))
|
||||
var exts []models.ExtensionInfo
|
||||
for _, source := range sources {
|
||||
name, ok := npmPackageName(source)
|
||||
if !ok || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
exts = append(exts, buildNpmExtensionInfo(agentDir, name, source))
|
||||
}
|
||||
return exts
|
||||
}
|
||||
|
||||
func updateSettingsExtensions(settingsPath, id string, enable bool) error {
|
||||
func readNpmPackageSources(settingsPath string) []string {
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
|
||||
// extensions is a list of enabled extension IDs
|
||||
extList, _ := settings["extensions"].([]any)
|
||||
set := map[string]bool{}
|
||||
for _, e := range extList {
|
||||
if s, ok := e.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
set[id] = true
|
||||
} else {
|
||||
delete(set, id)
|
||||
}
|
||||
|
||||
newList := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
settings["extensions"] = newList
|
||||
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var settings struct {
|
||||
Packages []json.RawMessage `json:"packages"`
|
||||
Extensions []string `json:"extensions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sources []string
|
||||
for _, raw := range settings.Packages {
|
||||
if source := parsePackageSource(raw); source != "" {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
for _, source := range settings.Extensions {
|
||||
if strings.HasPrefix(source, "npm:") {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func parsePackageSource(raw json.RawMessage) string {
|
||||
var source string
|
||||
if err := json.Unmarshal(raw, &source); err == nil {
|
||||
return strings.TrimSpace(source)
|
||||
}
|
||||
|
||||
var obj struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
return strings.TrimSpace(obj.Source)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func npmPackageName(source string) (string, bool) {
|
||||
if !strings.HasPrefix(source, "npm:") {
|
||||
return "", false
|
||||
}
|
||||
spec := strings.TrimSpace(strings.TrimPrefix(source, "npm:"))
|
||||
if spec == "" {
|
||||
return "", false
|
||||
}
|
||||
name, _ := parseNpmSpec(spec)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func parseNpmSpec(spec string) (name, version string) {
|
||||
match := npmSpecRe.FindStringSubmatch(spec)
|
||||
if match == nil {
|
||||
return spec, ""
|
||||
}
|
||||
name = match[1]
|
||||
version = match[2]
|
||||
return name, version
|
||||
}
|
||||
|
||||
func buildNpmExtensionInfo(agentDir, packageName, source string) models.ExtensionInfo {
|
||||
modPath := filepath.Join(agentDir, "npm", "node_modules", packageName)
|
||||
info := models.ExtensionInfo{
|
||||
Name: packageName,
|
||||
Path: modPath,
|
||||
Source: source,
|
||||
Category: "npm",
|
||||
Enabled: true,
|
||||
Toggleable: false,
|
||||
}
|
||||
|
||||
pkgPath := filepath.Join(modPath, "package.json")
|
||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||
var pkg struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(data, &pkg) == nil {
|
||||
if pkg.Name != "" {
|
||||
info.Name = pkg.Name
|
||||
}
|
||||
info.Version = pkg.Version
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ToggleExtension moves a local extension between extensions/ and
|
||||
// extensions-disabled/ directories. The agent's loader only scans extensions/,
|
||||
// so moving a directory out of it disables that extension. npm extensions live
|
||||
// elsewhere and are not managed here.
|
||||
func ToggleExtension(agentDir, extPath string, enable bool) error {
|
||||
extDir := filepath.Join(agentDir, "extensions")
|
||||
disabledDir := filepath.Join(agentDir, "extensions-disabled")
|
||||
|
||||
if !isUnderDir(extPath, extDir) && !isUnderDir(extPath, disabledDir) {
|
||||
return fmt.Errorf("extension path not in managed directories")
|
||||
}
|
||||
|
||||
name := filepath.Base(extPath)
|
||||
var src, dst string
|
||||
if enable {
|
||||
src = filepath.Join(disabledDir, name)
|
||||
dst = filepath.Join(extDir, name)
|
||||
} else {
|
||||
src = filepath.Join(extDir, name)
|
||||
dst = filepath.Join(disabledDir, name)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
56
backend/internal/services/extensions_test.go
Normal file
56
backend/internal/services/extensions_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListNpmExtensions(t *testing.T) {
|
||||
agentDir := t.TempDir()
|
||||
npmRoot := filepath.Join(agentDir, "npm", "node_modules", "pi-subagents")
|
||||
if err := os.MkdirAll(npmRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(npmRoot, "package.json"), []byte(`{"name":"pi-subagents","version":"0.28.0"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := `{
|
||||
"packages": ["npm:pi-subagents", {"source": "npm:pi-mcp-adapter@2.10.0"}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(agentDir, "settings.json"), []byte(settings), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exts := listNpmExtensions(agentDir)
|
||||
if len(exts) != 2 {
|
||||
t.Fatalf("expected 2 npm extensions, got %d", len(exts))
|
||||
}
|
||||
|
||||
found := map[string]modelsExtensionSnapshot{}
|
||||
for _, ext := range exts {
|
||||
found[ext.Name] = modelsExtensionSnapshot{
|
||||
Version: ext.Version,
|
||||
Category: ext.Category,
|
||||
Toggleable: ext.Toggleable,
|
||||
Source: ext.Source,
|
||||
}
|
||||
}
|
||||
|
||||
if found["pi-subagents"].Version != "0.28.0" {
|
||||
t.Fatalf("pi-subagents version: %#v", found["pi-subagents"])
|
||||
}
|
||||
if found["pi-subagents"].Category != "npm" || found["pi-subagents"].Toggleable {
|
||||
t.Fatalf("pi-subagents metadata: %#v", found["pi-subagents"])
|
||||
}
|
||||
if found["pi-mcp-adapter"].Source != "npm:pi-mcp-adapter@2.10.0" {
|
||||
t.Fatalf("pi-mcp-adapter source: %#v", found["pi-mcp-adapter"])
|
||||
}
|
||||
}
|
||||
|
||||
type modelsExtensionSnapshot struct {
|
||||
Version string
|
||||
Category string
|
||||
Toggleable bool
|
||||
Source string
|
||||
}
|
||||
@@ -7,68 +7,80 @@ import (
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
type mcpConfig struct {
|
||||
MCPServers map[string]any `json:"mcpServers"`
|
||||
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
|
||||
ExcludeTools []string `json:"excludeTools,omitempty"`
|
||||
}
|
||||
// mcp.json is shared with pi-mcp-adapter, which only reads the top-level
|
||||
// "mcpServers", "imports" and "settings" keys. To disable a server we move its
|
||||
// full definition into the adapter-ignored "mcpServersDisabled" object, so the
|
||||
// adapter never starts it; enabling moves it back. All operations work on the
|
||||
// raw JSON map so unrecognized keys (settings, imports, ...) are preserved.
|
||||
|
||||
// ReadMCPServers loads mcp.json and returns the list of servers with their tools.
|
||||
// Servers under "mcpServers" are reported enabled; those parked under
|
||||
// "mcpServersDisabled" are reported disabled.
|
||||
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
raw := loadRawMCP(mcpConfigPath)
|
||||
cache := loadMCPCache(mcpCachePath)
|
||||
|
||||
disabledSet := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
disabledSet[s] = true
|
||||
}
|
||||
excludeSet := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
for _, t := range toStringSlice(raw["excludeTools"]) {
|
||||
excludeSet[t] = true
|
||||
}
|
||||
|
||||
var servers []models.MCPServer
|
||||
for name := range cfg.MCPServers {
|
||||
srv := models.MCPServer{
|
||||
Name: name,
|
||||
Disabled: disabledSet[name],
|
||||
}
|
||||
// add tools from cache
|
||||
if toolNames, ok := cache[name]; ok {
|
||||
for _, t := range toolNames {
|
||||
srv.Tools = append(srv.Tools, models.MCPTool{
|
||||
Name: t,
|
||||
Disabled: excludeSet[name+"/"+t] || excludeSet[t],
|
||||
})
|
||||
appendServer := func(name string, enabled bool) {
|
||||
srv := models.MCPServer{Name: name, Configured: true, Enabled: enabled}
|
||||
for _, t := range cache[name] {
|
||||
toolEnabled := !(excludeSet[name+"/"+t] || excludeSet[t])
|
||||
srv.Tools = append(srv.Tools, models.MCPTool{Server: name, Name: t, Enabled: toolEnabled})
|
||||
srv.ToolCount++
|
||||
if toolEnabled {
|
||||
srv.EnabledToolCount++
|
||||
}
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
|
||||
for name := range asMap(raw["mcpServers"]) {
|
||||
appendServer(name, true)
|
||||
}
|
||||
for name := range asMap(raw["mcpServersDisabled"]) {
|
||||
appendServer(name, false)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// ToggleMCPServer enables or disables a server.
|
||||
// ToggleMCPServer enables/disables a server by moving its full definition between
|
||||
// "mcpServers" (adapter reads it -> server can start) and "mcpServersDisabled"
|
||||
// (adapter ignores it -> server never starts).
|
||||
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
set := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
set[s] = true
|
||||
}
|
||||
raw := loadRawMCP(mcpConfigPath)
|
||||
servers := asMap(raw["mcpServers"])
|
||||
disabled := asMap(raw["mcpServersDisabled"])
|
||||
|
||||
if enable {
|
||||
delete(set, serverName)
|
||||
if def, ok := disabled[serverName]; ok {
|
||||
servers[serverName] = def
|
||||
delete(disabled, serverName)
|
||||
}
|
||||
} else {
|
||||
set[serverName] = true
|
||||
if def, ok := servers[serverName]; ok {
|
||||
disabled[serverName] = def
|
||||
delete(servers, serverName)
|
||||
}
|
||||
}
|
||||
cfg.MCPServersDisabled = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
|
||||
raw["mcpServers"] = servers
|
||||
setOrDeleteMap(raw, "mcpServersDisabled", disabled)
|
||||
return saveRawMCP(mcpConfigPath, raw)
|
||||
}
|
||||
|
||||
// ToggleMCPTool enables or disables a specific tool on a server.
|
||||
// ToggleMCPTool enables or disables a specific tool via the top-level
|
||||
// "excludeTools" list (server/tool keys).
|
||||
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
raw := loadRawMCP(mcpConfigPath)
|
||||
key := serverName + "/" + toolName
|
||||
|
||||
set := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
for _, t := range toStringSlice(raw["excludeTools"]) {
|
||||
set[t] = true
|
||||
}
|
||||
if enable {
|
||||
@@ -76,31 +88,68 @@ func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) erro
|
||||
} else {
|
||||
set[key] = true
|
||||
}
|
||||
cfg.ExcludeTools = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
|
||||
list := make([]any, 0, len(set))
|
||||
for k := range set {
|
||||
list = append(list, k)
|
||||
}
|
||||
if len(list) > 0 {
|
||||
raw["excludeTools"] = list
|
||||
} else {
|
||||
delete(raw, "excludeTools")
|
||||
}
|
||||
return saveRawMCP(mcpConfigPath, raw)
|
||||
}
|
||||
|
||||
func loadMCPConfig(path string) mcpConfig {
|
||||
func loadRawMCP(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return mcpConfig{MCPServers: map[string]any{}}
|
||||
return map[string]any{}
|
||||
}
|
||||
var cfg mcpConfig
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if cfg.MCPServers == nil {
|
||||
cfg.MCPServers = map[string]any{}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil || raw == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
return raw
|
||||
}
|
||||
|
||||
func saveMCPConfig(path string, cfg mcpConfig) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
func saveRawMCP(path string, raw map[string]any) error {
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func asMap(v any) map[string]any {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func setOrDeleteMap(raw map[string]any, key string, m map[string]any) {
|
||||
if len(m) > 0 {
|
||||
raw[key] = m
|
||||
} else {
|
||||
delete(raw, key)
|
||||
}
|
||||
}
|
||||
|
||||
func toStringSlice(v any) []string {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadMCPCache(path string) map[string][]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -123,11 +172,3 @@ func loadMCPCache(path string) map[string][]string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,159 +13,308 @@ import (
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// jsonlLine is a minimal parse of one line in a session JSONL file.
|
||||
type jsonlLine struct {
|
||||
Type string `json:"type"`
|
||||
// session header
|
||||
ID string `json:"id"`
|
||||
Created string `json:"created"`
|
||||
// message
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
// session_info
|
||||
Name string `json:"name"`
|
||||
// sessionLine is one parsed line of a session JSONL file.
|
||||
type sessionLine struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Name string `json:"name"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
}
|
||||
|
||||
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
|
||||
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
type sessionMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
pinned, _ := database.GetPinnedSessions()
|
||||
pinnedSet := map[string]struct{}{}
|
||||
for _, p := range pinned {
|
||||
pinnedSet[p] = struct{}{}
|
||||
}
|
||||
var (
|
||||
reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
|
||||
reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
|
||||
reWS = regexp.MustCompile(`\s+`)
|
||||
reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`)
|
||||
)
|
||||
|
||||
var summaries []models.SessionSummary
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(sessionsDir, e.Name())
|
||||
sum, err := readSessionSummary(path)
|
||||
// listSessionFiles recursively collects every *.jsonl under sessionsDir.
|
||||
// pi stores sessions in cwd-encoded subdirectories, e.g.
|
||||
// sessions/--D--SmyProjects-AI-sproutclaw--/<id>.jsonl
|
||||
func listSessionFiles(sessionsDir string) []string {
|
||||
var files []string
|
||||
_ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
_, sum.Pinned = pinnedSet[path]
|
||||
sum.Path = path
|
||||
summaries = append(summaries, sum)
|
||||
}
|
||||
|
||||
// Sort: pinned first, then by modified desc
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
pi, pj := summaries[i].Pinned, summaries[j].Pinned
|
||||
if pi != pj {
|
||||
return pi
|
||||
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return summaries[i].Modified > summaries[j].Modified
|
||||
return nil
|
||||
})
|
||||
return summaries, nil
|
||||
// newest first by path (ids are time-sortable); final sort happens later
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
return files
|
||||
}
|
||||
|
||||
func readSessionSummary(path string) (models.SessionSummary, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return models.SessionSummary{}, err
|
||||
func isMachineSessionLabel(text, headerID string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return true
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, _ := f.Stat()
|
||||
modified := ""
|
||||
if info != nil {
|
||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
||||
if headerID != "" && t == headerID {
|
||||
return true
|
||||
}
|
||||
|
||||
var sum models.SessionSummary
|
||||
sum.Modified = modified
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
|
||||
msgCount := 0
|
||||
for scanner.Scan() {
|
||||
var line jsonlLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||
continue
|
||||
}
|
||||
switch line.Type {
|
||||
case "session":
|
||||
sum.Created = line.Created
|
||||
case "session_info":
|
||||
if line.Name != "" {
|
||||
sum.Name = line.Name
|
||||
}
|
||||
case "message":
|
||||
msgCount++
|
||||
if sum.FirstMessage == "" && line.Role == "user" {
|
||||
sum.FirstMessage = extractTextContent(line.Content)
|
||||
}
|
||||
}
|
||||
if reHex.MatchString(t) || reDigits.MatchString(t) {
|
||||
return true
|
||||
}
|
||||
sum.MessageCount = msgCount
|
||||
return sum, nil
|
||||
return false
|
||||
}
|
||||
|
||||
func extractTextContent(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return truncate(v, 120)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
if m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
return truncate(t, 120)
|
||||
}
|
||||
func titleFromFirstUserMessage(text string) string {
|
||||
const maxChars = 56
|
||||
cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " "))
|
||||
if cleaned == "" {
|
||||
return ""
|
||||
}
|
||||
candidate := cleaned
|
||||
if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" {
|
||||
candidate = strings.TrimSpace(m[1])
|
||||
}
|
||||
runes := []rune(candidate)
|
||||
if len(runes) > maxChars {
|
||||
candidate = strings.TrimRight(string(runes[:maxChars]), " ") + "…"
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func extractPreview(msg sessionMessage) string {
|
||||
if len(msg.Content) == 0 {
|
||||
return ""
|
||||
}
|
||||
// content may be a string
|
||||
var s string
|
||||
if json.Unmarshal(msg.Content, &s) == nil {
|
||||
return truncate(s, 200)
|
||||
}
|
||||
// or an array of blocks
|
||||
var blocks []map[string]any
|
||||
if json.Unmarshal(msg.Content, &blocks) == nil {
|
||||
var sb strings.Builder
|
||||
imageCount := 0
|
||||
for _, b := range blocks {
|
||||
switch b["type"] {
|
||||
case "text":
|
||||
if t, ok := b["text"].(string); ok {
|
||||
sb.WriteString(t)
|
||||
}
|
||||
case "image":
|
||||
imageCount++
|
||||
}
|
||||
}
|
||||
if txt := sb.String(); txt != "" {
|
||||
return truncate(txt, 200)
|
||||
}
|
||||
if imageCount > 0 {
|
||||
return "[" + itoa(imageCount) + " 张图片]"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
// readSessionSummary parses one session file into a summary (nil if not a session).
|
||||
func readSessionSummary(path string) (models.SessionSummary, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return models.SessionSummary{}, false
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:n]) + "…"
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) == 0 {
|
||||
return models.SessionSummary{}, false
|
||||
}
|
||||
|
||||
var header sessionLine
|
||||
if json.Unmarshal([]byte(lines[0]), &header) != nil || header.Type != "session" {
|
||||
return models.SessionSummary{}, false
|
||||
}
|
||||
|
||||
info, _ := os.Stat(path)
|
||||
modified := ""
|
||||
if info != nil {
|
||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
nameFromInfo := ""
|
||||
messageCount := 0
|
||||
firstMessage := ""
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var entry sessionLine
|
||||
if json.Unmarshal([]byte(line), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
switch entry.Type {
|
||||
case "session_info":
|
||||
if entry.Name != "" {
|
||||
n := strings.TrimSpace(entry.Name)
|
||||
if n != "" && !isMachineSessionLabel(n, header.ID) {
|
||||
nameFromInfo = n
|
||||
}
|
||||
}
|
||||
case "message":
|
||||
messageCount++
|
||||
if firstMessage == "" && len(entry.Message) > 0 {
|
||||
var m sessionMessage
|
||||
if json.Unmarshal(entry.Message, &m) == nil && m.Role == "user" {
|
||||
firstMessage = extractPreview(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := nameFromInfo
|
||||
if name == "" || isMachineSessionLabel(name, header.ID) {
|
||||
name = titleFromFirstUserMessage(firstMessage)
|
||||
}
|
||||
|
||||
preview := firstMessage
|
||||
if preview == "" {
|
||||
preview = "(空)"
|
||||
}
|
||||
|
||||
return models.SessionSummary{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Created: header.Timestamp,
|
||||
Modified: modified,
|
||||
MessageCount: messageCount,
|
||||
FirstMessage: preview,
|
||||
}, true
|
||||
}
|
||||
|
||||
// ReadSessionMessages reads all lines from a JSONL session file.
|
||||
// BuildSessionList recursively reads all sessions and assembles the response.
|
||||
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
|
||||
if _, err := os.Stat(sessionsDir); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pinned, _ := database.GetPinnedSessions()
|
||||
pinnedOrder := map[string]int{}
|
||||
for i, p := range pinned {
|
||||
pinnedOrder[filepath.Clean(p)] = i
|
||||
}
|
||||
|
||||
var summaries []models.SessionSummary
|
||||
for _, f := range listSessionFiles(sessionsDir) {
|
||||
sum, ok := readSessionSummary(f)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, sum.Pinned = pinnedOrder[filepath.Clean(f)]
|
||||
summaries = append(summaries, sum)
|
||||
}
|
||||
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
ci, iPinned := pinnedOrder[filepath.Clean(summaries[i].Path)]
|
||||
cj, jPinned := pinnedOrder[filepath.Clean(summaries[j].Path)]
|
||||
if iPinned && jPinned {
|
||||
return ci < cj
|
||||
}
|
||||
if iPinned != jPinned {
|
||||
return iPinned
|
||||
}
|
||||
return sessionSortKey(summaries[i]) > sessionSortKey(summaries[j])
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func sessionSortKey(s models.SessionSummary) string {
|
||||
if s.Modified != "" {
|
||||
return s.Modified
|
||||
}
|
||||
return s.Created
|
||||
}
|
||||
|
||||
// ReadSessionSummaryByPath returns a single session's summary (exported).
|
||||
func ReadSessionSummaryByPath(path string) (models.SessionSummary, error) {
|
||||
sum, ok := readSessionSummary(path)
|
||||
if !ok {
|
||||
return models.SessionSummary{Path: path}, nil
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// ReadSessionMessages returns the `message` payload of each message line.
|
||||
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []json.RawMessage
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
raw := append([]byte(nil), scanner.Bytes()...)
|
||||
lines = append(lines, raw)
|
||||
var out []json.RawMessage
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var entry sessionLine
|
||||
if json.Unmarshal([]byte(line), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Type == "message" && len(entry.Message) > 0 {
|
||||
out = append(out, entry.Message)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AppendSessionName appends a session_info rename record to the JSONL file.
|
||||
func AppendSessionName(path, name string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
record := map[string]string{"type": "session_info", "name": name}
|
||||
record := map[string]any{
|
||||
"type": "session_info",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"name": trimmed,
|
||||
}
|
||||
b, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(b, '\n'))
|
||||
_, err = f.Write(append([]byte("\n"), b...))
|
||||
return err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Enabled: enabled,
|
||||
Toggleable: true,
|
||||
Path: skillPath,
|
||||
})
|
||||
}
|
||||
|
||||
140
backend/internal/services/web_terminal.go
Normal file
140
backend/internal/services/web_terminal.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/Kodecable/crosspty"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type terminalWSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// ServeWebTerminal attaches a PTY shell session to the websocket connection.
|
||||
func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
|
||||
abs, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析目录失败: %w", err)
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("目录不存在: %s", abs)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("不是有效目录: %s", abs)
|
||||
}
|
||||
|
||||
ptmx, err := crosspty.Start(crosspty.CommandConfig{
|
||||
Argv: defaultShellArgv(),
|
||||
Dir: abs,
|
||||
Env: os.Environ(),
|
||||
EnvInject: map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
},
|
||||
Size: crosspty.TermSize{Rows: 24, Cols: 80},
|
||||
})
|
||||
if err != nil {
|
||||
if err == crosspty.ErrConPTYNotSupported {
|
||||
return fmt.Errorf("当前 Windows 版本不支持 ConPTY,需 Windows 10 1809 或更高版本")
|
||||
}
|
||||
return fmt.Errorf("启动终端失败: %w", err)
|
||||
}
|
||||
defer ptmx.Close()
|
||||
|
||||
var writeMu sync.Mutex
|
||||
writeJSON := func(v any) error {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
return conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := ptmx.Read(buf)
|
||||
if n > 0 {
|
||||
writeMu.Lock()
|
||||
werr := conn.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
writeMu.Unlock()
|
||||
if werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, payload, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
if websocket.IsCloseError(readErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
return nil
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
|
||||
switch msgType {
|
||||
case websocket.BinaryMessage:
|
||||
if _, werr := ptmx.Write(payload); werr != nil {
|
||||
return werr
|
||||
}
|
||||
case websocket.TextMessage:
|
||||
var msg terminalWSMessage
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
if _, werr := ptmx.Write(payload); werr != nil {
|
||||
return werr
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
|
||||
return werr
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = ptmx.Resize(crosspty.TermSize{Rows: msg.Rows, Cols: msg.Cols})
|
||||
}
|
||||
case "ping":
|
||||
if err := writeJSON(map[string]string{"type": "pong"}); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if msg.Data != "" {
|
||||
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultShellArgv() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if comspec := os.Getenv("COMSPEC"); comspec != "" {
|
||||
return []string{comspec}
|
||||
}
|
||||
return []string{"cmd.exe"}
|
||||
}
|
||||
if shell := os.Getenv("SHELL"); shell != "" {
|
||||
return []string{shell, "-l"}
|
||||
}
|
||||
return []string{"/bin/bash", "-l"}
|
||||
}
|
||||
Reference in New Issue
Block a user