Files
gochat/backend/internal/handler/api/v1/sse_stream_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

187 lines
5.5 KiB
Go

package v1
import (
"fmt"
"io"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
)
// SSEStreamHandler handles Server-Sent Events streaming for Copilot real-time responses.
// Reference: M12 PRD §Copilot — Streaming SSE Support
//
// SSE event format:
// event: message
// data: {"content": "...", "done": false}
//
// event: done
// data: {"done": true}
// SSEStreamHandler holds dependencies for SSE streaming endpoints.
type SSEStreamHandler struct {
copilotSvc *service.CopilotService
llmProvider llm.Provider
}
// NewSSEStreamHandler creates a new SSEStreamHandler.
func NewSSEStreamHandler(copilotSvc *service.CopilotService, llmProvider llm.Provider) *SSEStreamHandler {
return &SSEStreamHandler{copilotSvc: copilotSvc, llmProvider: llmProvider}
}
// SSEStreamRequest holds the input for a streaming Copilot request.
type SSEStreamRequest struct {
ThreadID uint `json:"thread_id" validate:"required"`
Content string `json:"content" validate:"required,min=1"`
}
// StreamCopilotMessage streams a Copilot chat response via SSE.
// The OpenAIProvider.ChatCompletionStream uses a callback pattern (onChunk),
// so we write SSE events inside the callback as chunks arrive.
//
// POST /api/v1/accounts/:id/captain/copilot_threads/:thread_id/stream
func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
writeSSEMessage(c, "error", `{"error": "invalid account_id"}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
threadID, err := strconv.ParseUint(c.Param("thread_id"), 10, 64)
if err != nil {
writeSSEMessage(c, "error", `{"error": "invalid thread_id"}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
var req SSEStreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, escapeJSONString(err.Error())))
writeSSEMessage(c, "done", `{"done": true}`)
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // disable nginx buffering
// Get thread for context
thread, err := h.copilotSvc.GetThreadByID(c.Request.Context(), uint(threadID))
if err != nil {
applogger.L().Errorf("SSE GetThread: %v", err)
writeSSEMessage(c, "error", `{"error": "thread not found"}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
if thread.AccountID != uint(accountID) {
writeSSEMessage(c, "error", `{"error": "thread not found"}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
if h.llmProvider == nil {
writeSSEMessage(c, "error", `{"error": "Captain is disabled", "status": 422, "done": true}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
// Build chat messages from thread history + new user message
chatMessages := buildStreamChatMessages(thread, req.Content)
// Stream from LLM using callback pattern
streamReq := llm.ChatRequest{
Model: "gpt-4",
Messages: chatMessages,
Temperature: 0.7,
MaxTokens: 1024,
Stream: true,
}
err = h.llmProvider.ChatCompletionStream(c.Request.Context(), streamReq, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
if content != "" {
escaped := escapeJSONString(content)
writeSSEMessage(c, "message", fmt.Sprintf(`{"content": "%s", "done": false}`, escaped))
c.Writer.Flush()
}
finishReason := chunk.Choices[0].FinishReason
if finishReason != "" && finishReason != "null" {
writeSSEMessage(c, "done", `{"done": true, "content": ""}`)
c.Writer.Flush()
return io.EOF // signal end of stream
}
}
return nil
})
if err != nil && err != io.EOF {
applogger.L().Errorf("SSE stream error: %v", err)
writeSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, escapeJSONString(err.Error())))
writeSSEMessage(c, "done", `{"done": true}`)
}
}
// --- Helper Functions ---
// writeSSEMessage writes a single SSE event to the Gin response writer.
// SSE format: "event: <event>\ndata: <data>\n\n"
func writeSSEMessage(c *gin.Context, event string, data string) {
c.Writer.WriteString(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
}
// buildStreamChatMessages constructs chat messages for streaming from thread history + new content.
func buildStreamChatMessages(thread *model.CopilotThread, userContent string) []llm.ChatMessage {
messages := []llm.ChatMessage{
{Role: "system", Content: "You are an AI assistant helping a customer support agent. Provide helpful, professional, and concise responses."},
}
// Add thread history as context
history := thread.PreviousHistory(thread.Messages)
for _, h := range history {
messages = append(messages, llm.ChatMessage{
Role: h.Role,
Content: h.Content,
})
}
// Add the new user message
messages = append(messages, llm.ChatMessage{
Role: "user",
Content: userContent,
})
return messages
}
// escapeJSONString escapes special characters in a string for safe JSON embedding.
func escapeJSONString(s string) string {
var result strings.Builder
for _, ch := range s {
switch ch {
case '"':
result.WriteString(`\"`)
case '\\':
result.WriteString(`\\`)
case '\n':
result.WriteString(`\n`)
case '\t':
result.WriteString(`\t`)
case '\r':
result.WriteString(`\r`)
default:
result.WriteRune(ch)
}
}
return result.String()
}