Files
sproutclaw-web/backend/internal/handlers/chat.go
2026-06-13 20:16:09 +08:00

215 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}