feat(copilot): move provider configuration to settings

This commit is contained in:
2026-07-12 22:27:29 +08:00
parent f39943629f
commit df23caf7d9
29 changed files with 916 additions and 258 deletions
@@ -9,8 +9,10 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/security"
"github.com/gochat/gochat/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
@@ -30,14 +32,21 @@ func newCaptainPreferenceFixture(t *testing.T) *captainPreferenceFixture {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}))
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{}))
account := &model.Account{Name: "Captain Preferences", Active: true, CaptainModels: datatypes.JSON(`{}`), CaptainFeatures: datatypes.JSON(`{}`)}
require.NoError(t, db.Create(account).Error)
prefRepo := repository.NewCaptainPreferenceRepo(db)
accountRepo := repository.NewAccountRepo(db)
handler := NewCaptainPreferenceHandler(service.NewCaptainPreferenceService(prefRepo, accountRepo))
installationConfigRepo := repository.NewInstallationConfigRepo(db)
encryptor, err := security.NewEncryptor(security.DefaultEncryptionConfig())
require.NoError(t, err)
manager := llm.NewProviderManager()
copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, encryptor, manager)
preferenceService := service.NewCaptainPreferenceService(prefRepo, accountRepo)
preferenceService.SetCopilotConfigService(copilotConfigService)
handler := NewCaptainPreferenceHandler(preferenceService)
router := gin.New()
router.GET("/api/v1/accounts/:account_id/captain/preferences", handler.Get)
router.PUT("/api/v1/accounts/:account_id/captain/preferences", func(c *gin.Context) {
@@ -92,6 +101,7 @@ func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) {
require.Contains(t, payload, "providers")
require.Contains(t, payload, "models")
require.Contains(t, payload, "features")
require.Contains(t, payload, "provider_config")
features := payload["features"].(map[string]any)
editor := features["editor"].(map[string]any)
@@ -101,6 +111,27 @@ func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) {
require.NotEmpty(t, editor["models"].([]any))
}
func TestCaptainPreferencesUpdateProviderConfiguration(t *testing.T) {
f := newCaptainPreferenceFixture(t)
w := f.request(http.MethodPut, f.path(""), map[string]any{
"provider_config": map[string]any{
"provider": "openai_compatible",
"base_url": "https://llm.example.com/v1",
"model": "example-model",
"api_key": "secret-key-value",
},
})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
payload := decodeCaptainPreferencePayload(t, w)
providerConfig := payload["provider_config"].(map[string]any)
require.Equal(t, "openai_compatible", providerConfig["provider"])
require.Equal(t, "https://llm.example.com/v1", providerConfig["base_url"])
require.Equal(t, "example-model", providerConfig["model"])
require.Equal(t, true, providerConfig["api_key_configured"])
require.NotContains(t, w.Body.String(), "secret-key-value")
}
func TestCaptainPreferencesUpdateMergesAccountModelsAndFeatures(t *testing.T) {
f := newCaptainPreferenceFixture(t)
require.NoError(t, f.db.Model(&model.Account{}).Where("id = ?", f.account.ID).Updates(map[string]any{
@@ -22,9 +22,9 @@ func NewCaptainTaskExtendedHandler(svc *service.CaptainTaskExtendedService) *Cap
}
// LabelSuggestion returns AI-generated label suggestions for conversations.
// GET /api/v1/accounts/:id/captain/tasks/label_suggestion
// GET /api/v1/accounts/:account_id/captain/tasks/label_suggestion
func (h *CaptainTaskExtendedHandler) LabelSuggestion(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
@@ -77,9 +77,9 @@ func (h *CaptainTaskExtendedHandler) LabelSuggestion(c *gin.Context) {
}
// FollowUp returns AI-generated follow-up task suggestions for conversations.
// GET /api/v1/accounts/:id/captain/tasks/follow_up
// GET /api/v1/accounts/:account_id/captain/tasks/follow_up
func (h *CaptainTaskExtendedHandler) FollowUp(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
@@ -80,7 +80,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/label_suggestion?conversation_ids="+fmt.Sprintf("%d", conv.ID), nil)
handler.LabelSuggestion(c)
@@ -98,7 +98,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion_MissingConversationIDs(t *te
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/label_suggestion", nil)
handler.LabelSuggestion(c)
@@ -121,7 +121,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion_ChatwootPostRawPayload(t *te
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/label_suggestion", bytes.NewReader([]byte(`{"conversation_display_id":77}`)))
c.Request.Header.Set("Content-Type", "application/json")
@@ -159,7 +159,7 @@ func TestCaptainTaskExtendedHandler_FollowUp(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/follow_up?conversation_ids="+fmt.Sprintf("%d", conv.ID), nil)
handler.FollowUp(c)
@@ -173,7 +173,7 @@ func TestCaptainTaskExtendedHandler_FollowUp_MissingConversationIDs(t *testing.T
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/follow_up", nil)
handler.FollowUp(c)
@@ -204,7 +204,7 @@ func TestCaptainTaskExtendedHandler_FollowUp_ChatwootPostUpdatesContext(t *testi
}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/follow_up", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -30,7 +30,7 @@ func NewCaptainTaskHandler(svc *service.CaptainTaskService) *CaptainTaskHandler
// ReplySuggestion generates AI-powered reply suggestions for a conversation.
// POST /api/v1/accounts/:account_id/captain/tasks/reply_suggestion
func (h *CaptainTaskHandler) ReplySuggestion(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
@@ -55,7 +55,7 @@ func (h *CaptainTaskHandler) ReplySuggestion(c *gin.Context) {
// Summarize generates a concise summary of a conversation.
// POST /api/v1/accounts/:account_id/captain/tasks/summarize
func (h *CaptainTaskHandler) Summarize(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
@@ -80,7 +80,7 @@ func (h *CaptainTaskHandler) Summarize(c *gin.Context) {
// Rewrite rewrites a draft message to improve tone, clarity, or language.
// POST /api/v1/accounts/:account_id/captain/tasks/rewrite
func (h *CaptainTaskHandler) Rewrite(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
@@ -107,9 +107,9 @@ func (h *CaptainTaskHandler) Rewrite(c *gin.Context) {
// following the same SSE pattern as SSEStreamHandler.StreamCopilotMessage.
// StreamReplySuggestion streams AI-powered reply suggestions via SSE.
// POST /api/v1/accounts/:id/captain/tasks/reply_suggestion/stream
// POST /api/v1/accounts/:account_id/captain/tasks/reply_suggestion/stream
func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
@@ -149,9 +149,9 @@ func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) {
}
// StreamSummarize streams a conversation summary via SSE.
// POST /api/v1/accounts/:id/captain/tasks/summarize/stream
// POST /api/v1/accounts/:account_id/captain/tasks/summarize/stream
func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
@@ -191,9 +191,9 @@ func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) {
}
// StreamRewrite streams a rewritten message via SSE.
// POST /api/v1/accounts/:id/captain/tasks/rewrite/stream
// POST /api/v1/accounts/:account_id/captain/tasks/rewrite/stream
func (h *CaptainTaskHandler) StreamRewrite(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil {
captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id")
return
@@ -83,7 +83,7 @@ func TestCaptainTaskHandler_Summarize_ChatwootRawPayload(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/summarize", bytes.NewReader([]byte(`{"conversation_display_id":123}`)))
c.Request.Header.Set("Content-Type", "application/json")
@@ -96,12 +96,33 @@ func TestCaptainTaskHandler_Summarize_ChatwootRawPayload(t *testing.T) {
assert.NotContains(t, resp, "success")
}
func TestCaptainTaskHandler_ReplySuggestion_UsesAccountIDRouteParam(t *testing.T) {
provider := &mockCaptainTaskHandlerLLM{response: &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "Happy to help"}}}}}
handler, db := setupCaptainTaskHandlerTest(t, provider)
displayID := uint(3)
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)
router := gin.New()
router.POST("/api/v1/accounts/:account_id/captain/tasks/reply_suggestion", handler.ReplySuggestion)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/reply_suggestion", bytes.NewReader([]byte(`{"conversation_display_id":3}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "Happy to help", resp["message"])
}
func TestCaptainTaskHandler_Rewrite_NoProviderRawDisabled(t *testing.T) {
handler, _ := setupCaptainTaskHandlerTest(t, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_id", Value: "1"}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite", bytes.NewReader([]byte(`{"content":"hello","operation":"professional"}`)))
c.Request.Header.Set("Content-Type", "application/json")
@@ -119,7 +140,7 @@ func TestCaptainTaskHandler_StreamRewrite_NoProviderDisabledSSE(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_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")
@@ -146,7 +167,7 @@ func TestCaptainTaskHandler_StreamSummarize_ChatwootDisplayID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_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")
@@ -165,7 +186,7 @@ func TestCaptainTaskHandler_StreamRewrite_InvalidOperationSSE(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Params = gin.Params{{Key: "account_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")