Files
sproutclaw-web/backend/internal/handlers/chat.go
2026-06-14 20:31:10 +08:00

222 lines
5.9 KiB
Go

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
}
if len(req.Images) > 0 {
if err := services.ValidateImages(req.Images); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
return
}
}
// normalize streamingBehavior: only "steer" / "followUp" are forwarded
if req.StreamingBehavior != "steer" && req.StreamingBehavior != "followUp" {
req.StreamingBehavior = ""
}
// 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.StatusAccepted, gin.H{"ok": true, "accepted": true})
}
}
func handleSteer(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
}
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})
}
}
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
}
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})
}
}
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",
Command: req.Command,
})
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, "命令执行失败")})
return
}
c.JSON(http.StatusOK, jsonRaw(resp.Data))
}
}
func handleAbort(piClient *rpc.Client) gin.HandlerFunc {
return func(c *gin.Context) {
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort"}); 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) {
if _, err := piClient.SendCmd(models.RPCCommand{Type: "abort_retry"}); 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) {
resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"})
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))
}
}
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, 256)
client := &rpc.SSEClient{
Ch: ch,
Done: c.Request.Context().Done(),
}
piClient.RegisterSSEClient(client)
defer piClient.UnregisterSSEClient(client)
// initial comment to open the stream
_, _ = io.WriteString(c.Writer, ": connected\n\n")
flusher.Flush()
for {
select {
case <-c.Request.Context().Done():
return
case frame, ok := <-ch:
if !ok {
return
}
// frame is already a fully-formatted "data: ...\n\n" SSE frame
if _, err := c.Writer.Write(frame); err != nil {
return
}
flusher.Flush()
}
}
}
}
// jsonRaw decodes pre-serialized JSON for re-emission via gin.
func jsonRaw(raw json.RawMessage) any {
if len(raw) == 0 {
return gin.H{}
}
var v any
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
}