feat(captain): align streaming fallbacks

This commit is contained in:
2026-06-05 13:57:30 +08:00
parent a7cd53aa62
commit 72211e6dda
8 changed files with 329 additions and 67 deletions
+20 -14
View File
@@ -111,13 +111,13 @@ func (h *CaptainTaskHandler) Rewrite(c *gin.Context) {
func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
captainWriteSSEError(c, "invalid account_id")
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
}
var req service.TaskReplySuggestionRequest
if err := c.ShouldBindJSON(&req); err != nil {
captainWriteSSEError(c, "invalid request body: "+err.Error())
captainWriteSSEError(c, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
@@ -144,8 +144,7 @@ func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) {
if err != nil && err != io.EOF {
applogger.L().Errorf("StreamReplySuggestion: %v", err)
captainWriteSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, captainEscapeJSONString(err.Error())))
captainWriteSSEMessage(c, "done", `{"done": true}`)
captainWriteSSETaskError(c, err)
}
}
@@ -154,13 +153,13 @@ func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) {
func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
captainWriteSSEError(c, "invalid account_id")
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
}
var req service.TaskSummarizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
captainWriteSSEError(c, "invalid request body: "+err.Error())
captainWriteSSEError(c, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
@@ -187,8 +186,7 @@ func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) {
if err != nil && err != io.EOF {
applogger.L().Errorf("StreamSummarize: %v", err)
captainWriteSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, captainEscapeJSONString(err.Error())))
captainWriteSSEMessage(c, "done", `{"done": true}`)
captainWriteSSETaskError(c, err)
}
}
@@ -197,13 +195,13 @@ func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) {
func (h *CaptainTaskHandler) StreamRewrite(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
captainWriteSSEError(c, "invalid account_id")
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
}
var req service.TaskRewriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
captainWriteSSEError(c, "invalid request body: "+err.Error())
captainWriteSSEError(c, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
@@ -230,8 +228,7 @@ func (h *CaptainTaskHandler) StreamRewrite(c *gin.Context) {
if err != nil && err != io.EOF {
applogger.L().Errorf("StreamRewrite: %v", err)
captainWriteSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, captainEscapeJSONString(err.Error())))
captainWriteSSEMessage(c, "done", `{"done": true}`)
captainWriteSSETaskError(c, err)
}
}
@@ -253,12 +250,21 @@ func captainWriteSSEMessage(c *gin.Context, event string, data string) {
}
// captainWriteSSEError writes an error SSE event followed by a done event.
func captainWriteSSEError(c *gin.Context, errMsg string) {
func captainWriteSSEError(c *gin.Context, status int, errMsg string) {
captainSetSSEHeaders(c)
captainWriteSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s"}`, captainEscapeJSONString(errMsg)))
captainWriteSSEMessage(c, "error", fmt.Sprintf(`{"error": "%s", "status": %d, "done": true}`, captainEscapeJSONString(errMsg), status))
captainWriteSSEMessage(c, "done", `{"done": true}`)
}
func captainWriteSSETaskError(c *gin.Context, err error) {
status, message, ok := service.CaptainTaskErrorStatus(err)
if !ok {
status = http.StatusUnprocessableEntity
message = err.Error()
}
captainWriteSSEError(c, status, message)
}
// captainEscapeJSONString escapes special characters for safe JSON embedding.
func captainEscapeJSONString(s string) string {
var result strings.Builder
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -21,8 +22,10 @@ import (
)
type mockCaptainTaskHandlerLLM struct {
response *llm.ChatResponse
err error
response *llm.ChatResponse
err error
streamChunks []llm.StreamChunk
streamErr error
}
func (m *mockCaptainTaskHandlerLLM) ChatCompletion(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
@@ -31,7 +34,16 @@ func (m *mockCaptainTaskHandlerLLM) ChatCompletion(_ context.Context, _ llm.Chat
func (m *mockCaptainTaskHandlerLLM) CreateEmbedding(_ context.Context, _ llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return nil, nil
}
func (m *mockCaptainTaskHandlerLLM) ChatCompletionStream(_ context.Context, _ llm.ChatRequest, _ func(llm.StreamChunk) error) error {
func (m *mockCaptainTaskHandlerLLM) ChatCompletionStream(_ context.Context, _ llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
if m.streamErr != nil {
return m.streamErr
}
for _, chunk := range m.streamChunks {
if err := onChunk(chunk); err != nil {
return err
}
}
return nil
}
@@ -101,3 +113,67 @@ func TestCaptainTaskHandler_Rewrite_NoProviderRawDisabled(t *testing.T) {
assert.Equal(t, "Captain is disabled", resp["error"])
assert.NotContains(t, resp, "success")
}
func TestCaptainTaskHandler_StreamRewrite_NoProviderDisabledSSE(t *testing.T) {
handler, _ := setupCaptainTaskHandlerTest(t, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite/stream", bytes.NewReader([]byte(`{"content":"hello","operation":"professional"}`)))
c.Request.Header.Set("Content-Type", "application/json")
handler.StreamRewrite(c)
body := w.Body.String()
assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type"))
assert.Contains(t, body, "event: error")
assert.Contains(t, body, `"error": "Captain is disabled"`)
assert.Contains(t, body, `"status": 422`)
assert.Contains(t, body, "event: done")
}
func TestCaptainTaskHandler_StreamSummarize_ChatwootDisplayID(t *testing.T) {
provider := &mockCaptainTaskHandlerLLM{streamChunks: []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "Short"}}}},
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: " summary"}, FinishReason: "stop"}}},
}}
handler, db := setupCaptainTaskHandlerTest(t, provider)
displayID := uint(456)
conv := &model.Conversation{AccountID: 1, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(conv).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: 1, SenderType: "contact", MessageType: "incoming", Content: "Need help"}).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/summarize/stream", bytes.NewReader([]byte(`{"conversation_display_id":456}`)))
c.Request.Header.Set("Content-Type", "application/json")
handler.StreamSummarize(c)
body := w.Body.String()
assert.Contains(t, body, "event: message")
assert.Contains(t, body, `"content": "Short"`)
assert.Contains(t, body, `"content": " summary"`)
assert.Contains(t, body, "event: done")
assert.NotContains(t, body, "success")
}
func TestCaptainTaskHandler_StreamRewrite_InvalidOperationSSE(t *testing.T) {
handler, _ := setupCaptainTaskHandlerTest(t, &mockCaptainTaskHandlerLLM{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite/stream", strings.NewReader(`{"content":"hello","operation":"pirate"}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.StreamRewrite(c)
body := w.Body.String()
assert.Contains(t, body, "event: error")
assert.Contains(t, body, `"error": "Invalid operation: pirate"`)
assert.Contains(t, body, `"status": 422`)
assert.Contains(t, body, "event: done")
}
+21 -1
View File
@@ -349,17 +349,37 @@ func copilotThreadPayload(thread *model.CopilotThread) gin.H {
}
}
func copilotThreadPushPayload(thread *model.CopilotThread) gin.H {
return gin.H{
"id": thread.ID,
"title": thread.Title,
"created_at": thread.CreatedAt.Unix(),
"user": copilotUserPayload(&thread.User, thread.UserID, thread.AccountID),
"account_id": thread.AccountID,
}
}
func copilotMessagePayload(message *model.CopilotMessage) gin.H {
return gin.H{
"id": message.ID,
"message": rawJSONValue(message.Message),
"message_type": message.MessageType,
"created_at": message.CreatedAt.Unix(),
"copilot_thread": copilotThreadPayload(&message.CopilotThread),
"copilot_thread": copilotThreadPushPayload(&message.CopilotThread),
"account_id": message.AccountID,
}
}
func copilotMessagePushPayload(message *model.CopilotMessage) gin.H {
return gin.H{
"id": message.ID,
"message": rawJSONValue(message.Message),
"message_type": message.MessageType,
"created_at": message.CreatedAt.Unix(),
"copilot_thread": copilotThreadPushPayload(&message.CopilotThread),
}
}
func copilotUserPayload(user *model.User, fallbackID, accountID uint) gin.H {
if user == nil || user.ID == 0 {
return gin.H{"id": fallbackID, "account_id": accountID, "type": "user"}
@@ -237,6 +237,48 @@ func TestCopilotThreadMessagesListAndCreateUseNestedPayloads(t *testing.T) {
require.Len(t, messages, 4)
}
func TestCopilotMessagePayloadUsesThreadPushShape(t *testing.T) {
f := newCopilotParityFixture(t)
thread := f.createThread(t, "Need help")
threadID := uintString(uint(thread["id"].(float64)))
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
w := f.request(f.router, http.MethodGet, path, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
messages := decodeMap(t, w)["payload"].([]any)
message := messages[0].(map[string]any)
nestedThread := message["copilot_thread"].(map[string]any)
require.Equal(t, thread["id"], nestedThread["id"])
require.NotNil(t, nestedThread["user"])
require.Equal(t, thread["account_id"], nestedThread["account_id"])
require.Nil(t, nestedThread["assistant"])
require.NotNil(t, message["account_id"])
}
func TestCopilotMessagePushPayloadMatchesChatwootEventData(t *testing.T) {
f := newCopilotParityFixture(t)
thread := f.createThread(t, "Need help")
threadID := uintString(uint(thread["id"].(float64)))
path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/")
w := f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
created := decodeMap(t, w)
var stored model.CopilotMessage
require.NoError(t, f.db.Preload("CopilotThread.User").Preload("CopilotThread.Assistant").First(&stored, uint(created["id"].(float64))).Error)
push := copilotMessagePushPayload(&stored)
nestedThread := push["copilot_thread"].(gin.H)
require.Equal(t, created["id"], float64(push["id"].(uint)))
require.Equal(t, "user", string(push["message_type"].(model.CopilotMessageType)))
require.Equal(t, "Follow up", push["message"].(map[string]any)["content"])
require.Nil(t, push["account_id"])
require.Equal(t, stored.CopilotThreadID, nestedThread["id"])
require.Nil(t, nestedThread["assistant"])
require.NotNil(t, nestedThread["user"])
}
func TestCopilotThreadMessagesAreAccountAndUserScoped(t *testing.T) {
f := newCopilotParityFixture(t)
thread := f.createThread(t, "Private thread")
@@ -87,6 +87,11 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
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)
+69 -17
View File
@@ -543,12 +543,28 @@ func parseSuggestions(content string) []string {
// ReplySuggestionStream streams reply suggestions via an onChunk callback.
// The callback receives StreamChunk events; the caller (handler) writes SSE events.
func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest, onChunk func(llm.StreamChunk) error) error {
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
if err != nil {
return fmt.Errorf("assistant not found: %w", err)
if s.llmProvider == nil {
return taskError(422, "Captain is disabled")
}
messages, err := s.fetchConversationContext(ctx, req.ConversationID)
var assistant *model.CaptainAssistant
var err error
if req.AssistantID > 0 {
assistant, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
if err != nil {
return taskError(422, "assistant not found")
}
} else {
assistant = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"}
assistant.Config = []byte(`{}`)
}
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
if err != nil {
return taskError(422, "Conversation not found")
}
messages, err := s.fetchConversationContext(ctx, conversation.ID)
if err != nil {
applogger.L().Warnf("ReplySuggestionStream: conversation context fetch failed: %v, proceeding without context", err)
messages = ""
@@ -556,7 +572,7 @@ func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountI
// Enrich context with CopilotContextService (M12)
if s.copilotContextSvc != nil && messages != "" {
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, req.ConversationID)
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
if err != nil {
applogger.L().Warnf("ReplySuggestionStream: context enrichment failed: %v", err)
} else if ctxView != nil {
@@ -574,11 +590,13 @@ func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountI
}
ragContext := ""
docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages)
if err != nil {
applogger.L().Warnf("ReplySuggestionStream: document search failed: %v, proceeding without RAG", err)
} else if docContext != "" {
ragContext = docContext
if req.AssistantID > 0 {
docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages)
if err != nil {
applogger.L().Warnf("ReplySuggestionStream: document search failed: %v, proceeding without RAG", err)
} else if docContext != "" {
ragContext = docContext
}
}
cfg, _ := assistant.GetConfig()
@@ -601,14 +619,26 @@ func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountI
// SummarizeStream streams a conversation summary via an onChunk callback.
func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint, req *TaskSummarizeRequest, onChunk func(llm.StreamChunk) error) error {
conversationContext, err := s.fetchConversationContext(ctx, req.ConversationID)
if s.llmProvider == nil {
return taskError(422, "Captain is disabled")
}
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID)
if err != nil {
return taskError(422, "Conversation not found")
}
conversationContext, err := s.fetchConversationContext(ctx, conversation.ID)
if err != nil {
return fmt.Errorf("fetch conversation context: %w", err)
}
if conversationContext == "" {
return taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID))
}
// Enrich context with CopilotContextService (M12)
if s.copilotContextSvc != nil && conversationContext != "" {
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, req.ConversationID)
ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
if err != nil {
applogger.L().Warnf("SummarizeStream: context enrichment failed: %v", err)
} else if ctxView != nil {
@@ -649,9 +679,19 @@ func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint
// RewriteStream streams a rewritten message via an onChunk callback.
func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint, req *TaskRewriteRequest, onChunk func(llm.StreamChunk) error) error {
tone := req.Tone
if tone == "" {
tone = "professional"
if s.llmProvider == nil {
return taskError(422, "Captain is disabled")
}
operation := req.Operation
if operation == "" {
operation = req.Tone
}
if operation == "" {
operation = "professional"
}
if !isAllowedRewriteOperation(operation) {
return taskError(422, "Invalid operation: "+operation)
}
language := req.Language
@@ -660,8 +700,20 @@ func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint,
}
styleGuidelines := ""
if req.ConversationDisplayID > 0 {
conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0)
if err != nil {
return taskError(422, "Conversation not found")
}
if operation == "improve" {
conversationContext, _ := s.fetchConversationContext(ctx, conversation.ID)
if conversationContext != "" {
styleGuidelines += "\nConversation context: " + conversationContext
}
}
}
if req.AssistantID > 0 {
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID)
if err == nil {
cfg, _ := assistant.GetConfig()
if cfg.Instructions != "" {
@@ -673,7 +725,7 @@ func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint,
}
}
systemPrompt := s.promptBuilder.BuildRewritePrompt(tone, language, styleGuidelines)
systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines)
chatMessages := []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
@@ -111,28 +111,21 @@ func TestCaptainTaskService_SummarizeStream_成功(t *testing.T) {
}
func TestCaptainTaskService_SummarizeStream_无消息(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
db, _, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, nil)
// When no messages, SummarizeStream still proceeds with empty context
// It won't return an error, but the LLM will receive minimal context
mockProvider.streamChunks = []llm.StreamChunk{
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "no content"}}}},
}
var collected []string
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.NotEmpty(t, collected)
assert.Error(t, err)
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Contains(t, message, "no messages found")
}
func TestCaptainTaskService_SummarizeStream_LLM流错误(t *testing.T) {
@@ -211,6 +204,53 @@ func TestCaptainTaskService_RewriteStream_LLM流错误(t *testing.T) {
assert.Error(t, err)
}
func TestCaptainTaskService_Stream_NoProviderDisabled(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
svc.llmProvider = nil
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "professional"}, func(chunk llm.StreamChunk) error {
return nil
})
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Equal(t, "Captain is disabled", message)
}
func TestCaptainTaskService_RewriteStream_InvalidOperation(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "pirate"}, func(chunk llm.StreamChunk) error {
return nil
})
status, message, ok := CaptainTaskErrorStatus(err)
require.True(t, ok)
assert.Equal(t, 422, status)
assert.Equal(t, "Invalid operation: pirate", message)
}
func TestCaptainTaskService_SummarizeStream_ChatwootDisplayID(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{{"contact", "hello"}})
displayID := uint(321)
require.NoError(t, db.Model(conv).Update("display_id", displayID).Error)
mockProvider.streamChunks = []llm.StreamChunk{{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "summary"}, FinishReason: "stop"}}}}
var collected []string
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{ConversationDisplayID: displayID}, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
collected = append(collected, chunk.Choices[0].Delta.Content)
}
return nil
})
require.NoError(t, err)
assert.Equal(t, []string{"summary"}, collected)
}
func TestCaptainTaskService_ReplySuggestionStream_回调中断(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)