feat(copilot): finish configuration center

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 0a69d80f7c
commit 8b9eedc0e2
60 changed files with 3040 additions and 1042 deletions
+2 -2
View File
@@ -152,10 +152,10 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error)
}
now := time.Now()
admin := &model.User{}
if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "User", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil {
if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "SuperAdmin", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil {
return nil, fmt.Errorf("seed admin user: %w", err)
}
if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "User", "active": true, "available": true, "confirmed_at": now}).Error; err != nil {
if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "SuperAdmin", "active": true, "available": true, "confirmed_at": now}).Error; err != nil {
return nil, fmt.Errorf("update admin user: %w", err)
}
+37 -35
View File
@@ -814,41 +814,43 @@ func Bootstrap(env string) (*App, error) {
contactMergeRepo := repository.NewContactMergeRepo(db)
contactMergeService := service.NewContactMergeService(contactMergeRepo, db)
handlers := &router.Handlers{
Auth: v1.NewAuthHandler(authService, oauthService, profileService),
MFA: v1.NewMFAHandler(mfaService),
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
Account: v1.NewAccountHandler(accountService),
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher),
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker),
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
WebWidget: v1.NewWebWidgetHandler(inboxService),
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
EmailWebhook: emailWebhookHandler,
Message: v1.NewMessageHandler(messageService),
Profile: v1.NewProfileHandler(profileService, uploadService),
Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher),
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
Team: v1.NewTeamHandler(teamService),
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
CopilotConfig: v1.NewCopilotConfigHandler(copilotConfigService, captainPreferenceService),
Auth: v1.NewAuthHandler(authService, oauthService, profileService),
MFA: v1.NewMFAHandler(mfaService),
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
Account: v1.NewAccountHandler(accountService),
EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService),
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher),
Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker),
Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService),
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
WebWidget: v1.NewWebWidgetHandler(inboxService),
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
EmailWebhook: emailWebhookHandler,
Message: v1.NewMessageHandler(messageService),
Profile: v1.NewProfileHandler(profileService, uploadService),
Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher),
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
Team: v1.NewTeamHandler(teamService),
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
CopilotConfig: v1.NewCopilotConfigHandler(copilotConfigService, captainPreferenceService).
WithAuditService(auditService).
WithArticleService(articleService),
CaptainTaskExtended: v1.NewCaptainTaskExtendedHandler(captainTaskExtendedService),
CaptainAssistantResponse: v1.NewCaptainAssistantResponseHandler(captainAssistantResponseService),
CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService),
+23 -9
View File
@@ -3,6 +3,7 @@ package auth
import (
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -17,9 +18,10 @@ import (
// Claims represents JWT token claims.
type Claims struct {
UserID uint `json:"user_id"`
AccountID uint `json:"account_id"` // current active account
Role string `json:"role"` // agent/administrator/custom_role
Provider string `json:"provider"` // email/google/saml
AccountID uint `json:"account_id"` // current active account
Role string `json:"role"` // agent/administrator/custom_role
UserType string `json:"user_type,omitempty"` // user/super_admin platform identity
Provider string `json:"provider"` // email/google/saml
CustomRoleID uint `json:"custom_role_id,omitempty"` // enterprise custom role
jwt.RegisteredClaims
}
@@ -45,14 +47,26 @@ func NewJWTService(cfg *config.JWTConfig) *JWTService {
// Access Token: 15min expiry with full Claims
// Refresh Token: 7 days expiry, only UserID + Provider
func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role string) (*TokenPair, error) {
userType := "user"
typeValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(user.Type), "_", ""))
if user.Role == "super_admin" || role == "super_admin" || typeValue == "superadmin" {
userType = "super_admin"
}
// Access Token
accessExpiry := time.Now().Add(time.Duration(s.cfg.ExpiryHours) * time.Hour)
accessClaims := &Claims{
UserID: user.ID,
AccountID: accountID,
Role: role,
Provider: user.Provider,
CustomRoleID: func() uint { if user.CustomRoleID != nil { return *user.CustomRoleID }; return 0 }(),
UserID: user.ID,
AccountID: accountID,
Role: role,
UserType: userType,
Provider: user.Provider,
CustomRoleID: func() uint {
if user.CustomRoleID != nil {
return *user.CustomRoleID
}
return 0
}(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExpiry),
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -161,4 +175,4 @@ func (s *JWTService) RefreshAccessToken(refreshTokenString string, accountID uin
}
return s.GenerateTokenPair(user, accountID, role)
}
}
@@ -146,6 +146,28 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
assertChatwootAuthUserFixture(t, data)
}
func TestChatwootAuthValidateTokenSerializesPlatformAdminType(t *testing.T) {
router, db, user := setupChatwootAuthTest(t)
require.NoError(t, db.Model(user).Updates(map[string]any{
"role": "super_admin",
"type": "User",
}).Error)
token := signInAndReturnAccessToken(t, router)
req, _ := http.NewRequest(http.MethodGet, "/auth/validate_token", nil)
req.Header.Set("access-token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
data := body["payload"].(map[string]any)["data"].(map[string]any)
require.Equal(t, "SuperAdmin", data["type"])
require.Equal(t, "administrator", data["role"])
}
func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
router, _, _ := setupChatwootAuthTest(t)
token := signInAndReturnAccessToken(t, router)
@@ -78,7 +78,7 @@ func (s *AutoReplyRuleHandlerTestSuite) SetupSuite() {
s.router = r
// Register routes matching the handler's expected URL patterns
accountsGroup := r.Group("/api/v1/accounts/:id/captain")
accountsGroup := r.Group("/api/v1/accounts/:account_id/captain")
{
accountsGroup.POST("/assistants/:assistant_id/auto_reply_rules", s.handler.Create)
accountsGroup.GET("/auto_reply_rules/:rule_id", s.handler.Get)
@@ -493,11 +493,8 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
}
s.Require().NoError(s.db.Create(rule).Error)
// Note: The handler sets accountID from path param but assigns to _ (not used in evalCtx).
// AutoReplyEvaluationContext.AccountID has no json tag, so it stays 0 from JSON binding.
// The service EvaluateRules will use AccountID=0 for FindActiveByInbox.
// This means even with a valid account in the path, the evaluate endpoint currently
// searches by account_id=0. We test the handler behavior as-is.
// The account path parameter is authoritative and is copied into the evaluation
// context before the service searches active rules.
body := `{"message_content": "This is an urgent matter", "sender_type": "contact", "conversation_status": "open"}`
w := s.doRequest(http.MethodPost,
fmt.Sprintf("/api/v1/accounts/%d/captain/auto_reply_rules/evaluate", account.ID),
@@ -511,8 +508,7 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
dataMap, ok := resp.Data.(map[string]interface{})
s.Require().True(ok)
// ShouldReply will be false because AccountID=0 in evalCtx doesn't match our rule's account_id
assert.Equal(s.T(), false, dataMap["should_reply"])
assert.Equal(s.T(), true, dataMap["should_reply"])
}
func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_InvalidJSON() {
@@ -555,4 +551,4 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_NoActiveRules() {
func TestAutoReplyRuleHandlerTestSuite(t *testing.T) {
suite.Run(t, new(AutoReplyRuleHandlerTestSuite))
}
}
@@ -175,3 +175,29 @@ func TestCaptainPreferencesInvalidAccountID(t *testing.T) {
w := f.request(http.MethodGet, "/api/v1/accounts/abc/captain/preferences", nil)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
}
func TestCaptainPreferencesUpdateAndClearBehavior(t *testing.T) {
f := newCaptainPreferenceFixture(t)
w := f.request(http.MethodPut, f.path(""), map[string]any{
"behavior": map[string]any{
"tone": "friendly",
"language": "auto",
"max_response_length": 750,
"custom_prompt_suffix": "Use short steps",
"auto_label_enabled": true,
"auto_follow_up_enabled": true,
"auto_reply_enabled": false,
},
})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
behavior := decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
require.Equal(t, "Use short steps", behavior["custom_prompt_suffix"])
w = f.request(http.MethodPut, f.path(""), map[string]any{
"behavior": map[string]any{"custom_prompt_suffix": ""},
})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
behavior = decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
require.Equal(t, "", behavior["custom_prompt_suffix"])
}
@@ -1,12 +1,15 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"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"
"github.com/gochat/gochat/pkg/response"
)
@@ -16,12 +19,24 @@ import (
type CopilotConfigHandler struct {
platform *service.CopilotConfigService
account *service.CaptainPreferenceService
audit *service.AuditService
articles *service.ArticleService
}
func NewCopilotConfigHandler(platform *service.CopilotConfigService, account *service.CaptainPreferenceService) *CopilotConfigHandler {
return &CopilotConfigHandler{platform: platform, account: account}
}
func (h *CopilotConfigHandler) WithAuditService(audit *service.AuditService) *CopilotConfigHandler {
h.audit = audit
return h
}
func (h *CopilotConfigHandler) WithArticleService(articles *service.ArticleService) *CopilotConfigHandler {
h.articles = articles
return h
}
func (h *CopilotConfigHandler) PlatformGet(c *gin.Context) {
payload, err := h.platform.Get(c.Request.Context())
if err != nil {
@@ -42,9 +57,62 @@ func (h *CopilotConfigHandler) PlatformUpdate(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
h.recordPlatformUpdate(c, input, payload)
c.JSON(http.StatusOK, payload)
}
func (h *CopilotConfigHandler) recordPlatformUpdate(c *gin.Context, input service.CopilotProviderConfigInput, payload *service.CopilotProviderConfigPayload) {
if h.audit == nil || payload == nil {
return
}
changes := map[string]any{
"chat": map[string]any{
"provider": payload.Chat.Provider,
"base_url": payload.Chat.BaseURL,
"model": payload.Chat.Model,
"api_key_configured": payload.Chat.APIKey.Configured,
"api_key_changed": input.Chat.APIKey != "" || input.Chat.ClearAPIKey,
},
"embedding": map[string]any{
"mode": payload.Embedding.Mode,
"provider": payload.Embedding.Provider,
"base_url": payload.Embedding.BaseURL,
"model": payload.Embedding.Model,
"dimensions": payload.Embedding.Dimensions,
"api_key_configured": payload.Embedding.APIKey.Configured,
"api_key_changed": input.Embedding.APIKey != "" || input.Embedding.ClearAPIKey,
},
"generation": payload.Generation,
"request": payload.Request,
"configured": payload.Configured,
}
raw, err := json.Marshal(changes)
if err != nil {
return
}
audit := &model.Audit{
AuditableType: "InstallationConfig",
AuditableID: 1,
Action: "update",
AuditedChanges: raw,
RemoteAddress: c.ClientIP(),
RequestUUID: firstNonEmpty(c.GetHeader("X-Request-ID"), c.GetHeader("X-Correlation-ID")),
Comment: "Copilot provider configuration updated",
}
if accountID := c.GetUint("account_id"); accountID != 0 {
audit.AccountID = &accountID
audit.AssociatedType = "Account"
audit.AssociatedID = &accountID
}
if userID := getUserID(c); userID != 0 {
audit.UserID = &userID
audit.UserType = "SuperAdmin"
}
if _, err := h.audit.CreateAudit(c.Request.Context(), audit); err != nil {
applogger.L().Warnf("Copilot provider audit skipped: %v", err)
}
}
func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
var input service.CopilotProviderConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
@@ -55,7 +123,8 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
if err != nil {
status := http.StatusUnprocessableEntity
if errors.Is(err, llm.ErrProviderNotConfigured) {
status = http.StatusConflict
response.AbortWithStatusError(c, http.StatusConflict, response.ErrCopilotNotConfigured, err.Error())
return
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -63,6 +132,31 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
c.JSON(http.StatusOK, payload)
}
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStatus(c *gin.Context) {
if h.articles == nil {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
return
}
c.JSON(http.StatusOK, h.articles.EmbeddingReindexStatus())
}
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStart(c *gin.Context) {
if h.articles == nil {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
return
}
status, err := h.articles.StartEmbeddingReindex()
if err != nil {
code := http.StatusUnprocessableEntity
if status.Running {
code = http.StatusConflict
}
c.JSON(code, gin.H{"error": err.Error(), "status": status})
return
}
c.JSON(http.StatusAccepted, status)
}
func (h *CopilotConfigHandler) AccountGet(c *gin.Context) {
if !captainPreferencesCanUpdate(c) {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "administrator role required")
@@ -0,0 +1,197 @@
package v1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type copilotConfigHandlerFixture struct {
db *gorm.DB
router *gin.Engine
account *model.Account
}
func newCopilotConfigHandlerFixture(t *testing.T) *copilotConfigHandlerFixture {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{}, &model.Audit{}))
account := &model.Account{Name: "Copilot Config", Active: true}
require.NoError(t, db.Create(account).Error)
manager := llm.NewProviderManager()
platformService := service.NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
preferenceService := service.NewCaptainPreferenceService(repository.NewCaptainPreferenceRepo(db), repository.NewAccountRepo(db))
preferenceService.SetCopilotConfigService(platformService)
handler := NewCopilotConfigHandler(platformService, preferenceService).
WithAuditService(service.NewAuditService(repository.NewAuditRepo(db)))
router := gin.New()
superAdmin := router.Group("/platform/api/v1/copilot", func(c *gin.Context) {
c.Set("user_type", "super_admin")
c.Set("account_id", account.ID)
c.Next()
}, middleware.SuperAdmin())
superAdmin.GET("/config", handler.PlatformGet)
superAdmin.PUT("/config", handler.PlatformUpdate)
superAdmin.POST("/config/test", handler.PlatformTest)
accountAdmin := router.Group("/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "administrator")
c.Next()
})
accountAdmin.GET("", handler.AccountGet)
accountAdmin.PUT("", handler.AccountUpdate)
router.PUT("/forbidden/platform/api/v1/copilot/config", func(c *gin.Context) {
c.Set("user_type", "user")
c.Next()
}, middleware.SuperAdmin(), handler.PlatformUpdate)
router.GET("/forbidden/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "agent")
c.Next()
}, handler.AccountGet)
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
require.NoError(t, dbErr)
require.NoError(t, sqlDB.Close())
})
return &copilotConfigHandlerFixture{db: db, router: router, account: account}
}
func (f *copilotConfigHandlerFixture) request(method, path string, body any) *httptest.ResponseRecorder {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
recorder := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, bytes.NewReader(raw))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
f.router.ServeHTTP(recorder, req)
return recorder
}
func copilotConfigRequest(baseURL, apiKey string) map[string]any {
return map[string]any{
"chat": map[string]any{
"provider": "openai_compatible",
"base_url": baseURL,
"model": "chat-model",
"api_key": apiKey,
},
"embedding": map[string]any{
"mode": "reuse_chat_credentials",
"provider": "openai_compatible",
"base_url": baseURL,
"model": "embedding-model",
"dimensions": 3,
},
"generation": map[string]any{"temperature": 0.2, "max_tokens": 512},
"request": map[string]any{"timeout_seconds": 10, "max_retries": 0},
}
}
func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
input := copilotConfigRequest("https://llm.example.com/v1", "plain-secret-key")
forbidden := f.request(http.MethodPut, "/forbidden/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusForbidden, forbidden.Code, forbidden.Body.String())
updated := f.request(http.MethodPut, "/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
require.NotContains(t, updated.Body.String(), "plain-secret-key")
require.Contains(t, updated.Body.String(), "pla****-key")
var stored model.InstallationConfig
require.NoError(t, f.db.Where("name = ?", "COPILOT_CHAT_API_KEY").First(&stored).Error)
require.Equal(t, "plain-secret-key", stored.Value)
var audit model.Audit
require.NoError(t, f.db.Where("auditable_type = ?", "InstallationConfig").First(&audit).Error)
require.NotContains(t, string(audit.AuditedChanges), "plain-secret-key")
require.NotContains(t, string(audit.AuditedChanges), "pla****-key")
require.Contains(t, string(audit.AuditedChanges), `"api_key_changed":true`)
require.NotNil(t, audit.AccountID)
require.Equal(t, f.account.ID, *audit.AccountID)
require.Equal(t, "Account", audit.AssociatedType)
accountPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(f.account.ID), 10) + "/copilot/config"
accountPayload := f.request(http.MethodGet, accountPath, nil)
require.Equal(t, http.StatusOK, accountPayload.Code, accountPayload.Body.String())
require.NotContains(t, accountPayload.Body.String(), "plain-secret-key")
require.NotContains(t, accountPayload.Body.String(), "pla****-key")
require.Contains(t, accountPayload.Body.String(), `"configured":true`)
agentPayload := f.request(http.MethodGet, "/forbidden"+accountPath, nil)
require.Equal(t, http.StatusForbidden, agentPayload.Code, agentPayload.Body.String())
}
func TestCopilotConfigHandlerTestEndpointDoesNotPersistCandidate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/chat/completions":
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
case "/embeddings":
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embedding-model"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
f := newCopilotConfigHandlerFixture(t)
tested := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", copilotConfigRequest(server.URL, "candidate-key"))
require.Equal(t, http.StatusOK, tested.Code, tested.Body.String())
require.Contains(t, tested.Body.String(), `"ok":true`)
require.NotContains(t, tested.Body.String(), "candidate-key")
current := f.request(http.MethodGet, "/platform/api/v1/copilot/config", nil)
require.Equal(t, http.StatusOK, current.Code, current.Body.String())
require.Contains(t, current.Body.String(), `"configured":false`)
}
func TestCopilotConfigHandlerUsesStandardNotConfiguredError(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
response := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", map[string]any{})
require.Equal(t, http.StatusConflict, response.Code, response.Body.String())
require.Contains(t, response.Body.String(), "COPILOT_NOT_CONFIGURED")
}
func TestCopilotProviderErrorsAreSanitized(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/provider-error", func(c *gin.Context) {
handleServiceError(c, fmt.Errorf("provider failed: %w", &llm.APIError{
StatusCode: http.StatusUnauthorized,
Message: "invalid key sk-secret-value",
}))
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/provider-error", nil)
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusBadGateway, recorder.Code)
require.Contains(t, recorder.Body.String(), "COPILOT_PROVIDER_AUTHENTICATION_FAILED")
require.NotContains(t, recorder.Body.String(), "sk-secret-value")
}
@@ -88,7 +88,7 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
return
}
if h.llmProvider == nil {
writeSSEMessage(c, "error", `{"error": "Captain is disabled", "status": 422, "done": true}`)
writeSSEMessage(c, "error", `{"code": "COPILOT_NOT_CONFIGURED", "error": "Copilot provider is not configured", "status": 503, "done": true}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
@@ -98,14 +98,15 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
// Stream from LLM using callback pattern
streamReq := llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: chatMessages,
Temperature: 0.7,
MaxTokens: 1024,
Stream: true,
}
err = h.llmProvider.ChatCompletionStream(c.Request.Context(), streamReq, func(chunk llm.StreamChunk) error {
streamCtx := llm.WithAccountFeature(c.Request.Context(), uint(accountID), "copilot")
err = h.llmProvider.ChatCompletionStream(streamCtx, streamReq, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
if content != "" {
+4 -4
View File
@@ -245,8 +245,8 @@ func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req ChatRe
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(httpResp.Body)
return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody))
_, _ = io.Copy(io.Discard, httpResp.Body)
return &APIError{StatusCode: httpResp.StatusCode, Message: "Anthropic provider request failed"}
}
// Parse Anthropic SSE format
@@ -306,7 +306,7 @@ func (p *AnthropicProvider) doRequestWithRetry(ctx context.Context, path string,
for attempt := 0; attempt <= p.maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
applogger.L().Infof("Anthropic retry (attempt %d/%d) after %v: %v", attempt, p.maxRetries, backoff, lastErr)
applogger.L().Infof("Anthropic retry (attempt %d/%d) after %v", attempt, p.maxRetries, backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -345,7 +345,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, path string, body []b
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
return nil, &APIError{StatusCode: resp.StatusCode, Message: "Anthropic provider request failed"}
}
return respBody, nil
}
+5 -4
View File
@@ -123,8 +123,9 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req ChatReque
if httpResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(httpResp.Body)
applogger.L().Errorf("ChatCompletionStream: unexpected status %d: %s", httpResp.StatusCode, string(respBody))
return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody))
apiErr := parseAPIError(httpResp.StatusCode, respBody)
applogger.L().Errorf("ChatCompletionStream: provider returned status %d", httpResp.StatusCode)
return apiErr
}
return p.parseSSEStream(httpResp.Body, onChunk)
@@ -164,7 +165,7 @@ func (p *OpenAIProvider) doRequestWithRetry(ctx context.Context, path string, bo
if attempt > 0 {
// Exponential backoff: 1s, 2s, 4s
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
applogger.L().Infof("Retrying request (attempt %d/%d) after %v: %v", attempt, p.maxRetries, backoff, lastErr)
applogger.L().Infof("Retrying provider request (attempt %d/%d) after %v", attempt, p.maxRetries, backoff)
select {
case <-ctx.Done():
@@ -210,7 +211,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, path string, body []byte
if resp.StatusCode >= 400 {
apiErr := parseAPIError(resp.StatusCode, respBody)
applogger.L().Errorf("API error (status %d): %v", resp.StatusCode, apiErr)
applogger.L().Errorf("Provider API error (status %d, type=%s, code=%s)", resp.StatusCode, apiErr.Type, apiErr.Code)
return nil, apiErr
}
+24 -23
View File
@@ -32,21 +32,21 @@ type StreamingProvider interface {
// ChatRequest represents a request to the chat completion API.
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Tools []ToolDefinition `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Tools []ToolDefinition `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// ChatMessage represents a single message in a chat conversation.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant message: tool calls initiated by the model
ToolCallID string `json:"tool_call_id,omitempty"` // tool role message: ID of the tool call this responds to
Name string `json:"name,omitempty"` // tool role message: name of the tool
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant message: tool calls initiated by the model
ToolCallID string `json:"tool_call_id,omitempty"` // tool role message: ID of the tool call this responds to
Name string `json:"name,omitempty"` // tool role message: name of the tool
}
// ToolCall represents a tool call requested by the LLM.
@@ -101,23 +101,24 @@ type TokenUsage struct {
// EmbeddingRequest represents a request to the embeddings API.
type EmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
Model string `json:"model"`
Input []string `json:"input"`
Dimensions int `json:"dimensions,omitempty"`
}
// EmbeddingResponse represents the response from an embeddings API.
type EmbeddingResponse struct {
Object string `json:"object"`
Object string `json:"object"`
Data []EmbeddingData `json:"data"`
Model string `json:"model"`
Usage TokenUsage `json:"usage"`
Model string `json:"model"`
Usage TokenUsage `json:"usage"`
}
// EmbeddingData represents a single embedding result.
type EmbeddingData struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
// StreamChunk represents a single chunk in a streaming response.
@@ -131,13 +132,13 @@ type StreamChunk struct {
// StreamChoice represents a single choice in a streaming chunk.
type StreamChoice struct {
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
// StreamDelta represents the delta content in a streaming chunk.
type StreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
}
}
+1
View File
@@ -272,6 +272,7 @@ func (m *ProviderManager) CreateEmbedding(ctx context.Context, req EmbeddingRequ
return nil, err
}
req.Model = snapshot.config.EmbeddingModel
req.Dimensions = snapshot.config.EmbeddingDimensions
return snapshot.embedding.CreateEmbedding(ctx, req)
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,6 +18,123 @@ func TestProviderManagerRequiresPageConfiguration(t *testing.T) {
require.ErrorIs(t, err, ErrProviderNotConfigured)
}
func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.T) {
var request ChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
manager := NewProviderManager()
manager.SetAccountModelResolver(func(_ context.Context, accountID uint, feature string) (string, error) {
assert.Equal(t, uint(42), accountID)
assert.Equal(t, "editor", feature)
return "account-editor-model", nil
})
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: EmbeddingModeReuseChat,
Temperature: 0.25,
MaxTokens: 777,
}))
ctx := WithAccountFeature(context.Background(), 42, "editor")
_, err := manager.ChatCompletion(ctx, ChatRequest{Model: "hard-coded-model"})
require.NoError(t, err)
assert.Equal(t, "account-editor-model", request.Model)
assert.Equal(t, 0.25, request.Temperature)
assert.Equal(t, 777, request.MaxTokens)
}
func TestProviderManagerUsesSeparateEmbeddingProvider(t *testing.T) {
var chatCalls atomic.Int32
chatServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
chatCalls.Add(1)
http.NotFound(w, r)
}))
defer chatServer.Close()
var embeddingRequest EmbeddingRequest
var authorization string
embeddingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&embeddingRequest))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embed-model"}`))
}))
defer embeddingServer.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: chatServer.URL,
ChatAPIKey: "chat-key",
ChatModel: "chat-model",
EmbeddingMode: EmbeddingModeSeparate,
EmbeddingProvider: "openai_compatible",
EmbeddingBaseURL: embeddingServer.URL,
EmbeddingAPIKey: "embedding-key",
EmbeddingModel: "embed-model",
EmbeddingDimensions: 3,
}))
_, err := manager.CreateEmbedding(context.Background(), EmbeddingRequest{Model: "ignored", Input: []string{"hello"}})
require.NoError(t, err)
assert.Equal(t, int32(0), chatCalls.Load())
assert.Equal(t, "Bearer embedding-key", authorization)
assert.Equal(t, "embed-model", embeddingRequest.Model)
assert.Equal(t, 3, embeddingRequest.Dimensions)
}
func TestProviderManagerExplicitZeroRetriesDoesNotRetry(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "temporary failure", http.StatusInternalServerError)
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "test-model",
EmbeddingMode: EmbeddingModeReuseChat,
MaxRetries: 0,
}))
_, err := manager.ChatCompletion(context.Background(), ChatRequest{})
require.Error(t, err)
assert.Equal(t, int32(1), calls.Load())
}
func TestProviderManagerFailedConfigurationKeepsWorkingSnapshot(t *testing.T) {
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai",
ChatAPIKey: "working-key",
ChatModel: "working-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
err := manager.Configure(RuntimeProviderConfig{
ChatProvider: "anthropic",
ChatAPIKey: "bad-key",
ChatModel: "bad-model",
EmbeddingMode: EmbeddingModeReuseChat,
})
require.Error(t, err)
snapshot, configured := manager.Snapshot()
require.True(t, configured)
assert.Equal(t, "working-model", snapshot.ChatModel)
}
func TestProviderManagerClearRemovesActiveProvider(t *testing.T) {
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
+1
View File
@@ -53,6 +53,7 @@ func AuthMiddlewareWithService(jwtSvc *auth.JWTService) gin.HandlerFunc {
c.Set("user_id", claims.UserID)
c.Set("account_id", claims.AccountID)
c.Set("role", claims.Role)
c.Set("user_type", claims.UserType)
c.Set("provider", claims.Provider)
c.Set("custom_role_id", claims.CustomRoleID)
c.Set("claims", claims) // full Claims struct for handlers that need it
+27
View File
@@ -118,6 +118,33 @@ func TestAuthMiddleware_ChatwootAccessTokenHeader(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestAuthMiddleware_AllowsPlatformAdminThroughSuperAdminGuard(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := makeJWTConfig()
jwtService := auth.NewJWTService(cfg)
user := &model.User{
Base: model.Base{ID: 1},
Provider: "email",
Role: "super_admin",
Type: "User",
}
pair, err := jwtService.GenerateTokenPair(user, 2, "administrator")
assert.NoError(t, err)
r := gin.New()
r.Use(AuthMiddleware(cfg), SuperAdmin())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("access-token", pair.AccessToken)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := makeJWTConfig()
+20 -23
View File
@@ -6,6 +6,7 @@ package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -17,30 +18,17 @@ import (
// Super admins are platform-level administrators that can manage all accounts,
// platform apps, and system configuration.
//
// This checks the user's type field (not the account-level role).
// user.type = "super_admin" is set at the User model level, not AccountUser.
// This checks the signed platform user type (not the account-level role).
//
// Usage:
// router.GET("/platform/accounts", SuperAdmin(), listAllAccounts)
// router.POST("/platform/apps", SuperAdmin(), createPlatformApp)
// router.GET("/platform/analytics", SuperAdmin(), viewPlatformAnalytics)
//
// router.GET("/platform/accounts", SuperAdmin(), listAllAccounts)
// router.POST("/platform/apps", SuperAdmin(), createPlatformApp)
// router.GET("/platform/analytics", SuperAdmin(), viewPlatformAnalytics)
func SuperAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
// Check for super_admin flag in context (set by AuthRequired middleware)
userType, exists := c.Get("user_type")
if !exists {
// No user_type in context — check claims for super_admin indication
_, claimsExists := c.Get("auth_claims")
if claimsExists {
// Claims exist but user_type not set — not super_admin
}
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"Super admin access required")
return
}
typeStr, ok := userType.(string)
if !ok || typeStr != "super_admin" {
if !exists || !isSuperAdminType(userType) {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"Super admin access required. Your account does not have platform administration privileges.")
return
@@ -57,14 +45,14 @@ func SuperAdmin() gin.HandlerFunc {
// Useful for endpoints that should be accessible to account admins and platform admins.
//
// Usage:
// router.DELETE("/accounts/:id", SuperAdminOrAdministrator(), deleteAccount)
//
// router.DELETE("/accounts/:id", SuperAdminOrAdministrator(), deleteAccount)
func SuperAdminOrAdministrator() gin.HandlerFunc {
return func(c *gin.Context) {
// Check super_admin first
userType, exists := c.Get("user_type")
if exists {
typeStr, ok := userType.(string)
if ok && typeStr == "super_admin" {
if isSuperAdminType(userType) {
c.Set("is_super_admin", true)
c.Next()
return
@@ -94,4 +82,13 @@ func SuperAdminOrAdministrator() gin.HandlerFunc {
c.Next()
}
}
}
func isSuperAdminType(value any) bool {
typeStr, ok := value.(string)
if !ok {
return false
}
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(typeStr), "_", ""))
return normalized == "superadmin"
}
@@ -47,6 +47,19 @@ func TestSuperAdmin_IsSuperAdmin(t *testing.T) {
assert.Equal(t, 200, w.Code)
}
func TestSuperAdmin_AcceptsChatwootSerializedType(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_type", "SuperAdmin"); c.Next() })
r.Use(SuperAdmin())
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
}
func TestSuperAdmin_ClaimsExistButNoType(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
@@ -58,4 +71,4 @@ func TestSuperAdmin_ClaimsExistButNoType(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 403, w.Code)
}
}
+3 -3
View File
@@ -11,11 +11,11 @@ import (
type ArticleEmbedding struct {
Base
ArticleID uint `gorm:"not null;index" json:"article_id"`
Embedding json.RawMessage `gorm:"type:jsonb" json:"embedding"` // original JSONB storage (backward compat)
VectorEmbedding pgvector.Vector `gorm:"type:vector(1536)" json:"-"` // pgvector column for cosine similarity search
Embedding json.RawMessage `gorm:"type:jsonb" json:"embedding"` // original JSONB storage (backward compat)
VectorEmbedding pgvector.Vector `gorm:"type:vector" json:"-"` // dimension follows the configured embedding model
Term string `gorm:"type:text;not null" json:"term"` // searchable text content
Article Article `gorm:"foreignKey:ArticleID" json:"article,omitempty"`
}
func (ArticleEmbedding) TableName() string { return "article_embeddings" }
func (ArticleEmbedding) TableName() string { return "article_embeddings" }
@@ -25,6 +25,9 @@ func (r *ArticleEmbeddingRepo) Upsert(ctx context.Context, emb *model.ArticleEmb
var existing model.ArticleEmbedding
err := r.db.WithContext(ctx).Where("article_id = ?", emb.ArticleID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
if r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
return r.db.WithContext(ctx).Omit("VectorEmbedding").Create(emb).Error
}
return r.db.WithContext(ctx).Create(emb).Error
}
if err != nil {
@@ -32,7 +35,11 @@ func (r *ArticleEmbeddingRepo) Upsert(ctx context.Context, emb *model.ArticleEmb
}
// Update existing
existing.Embedding = emb.Embedding
existing.VectorEmbedding = emb.VectorEmbedding
existing.Term = emb.Term
if r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
return r.db.WithContext(ctx).Omit("VectorEmbedding").Save(&existing).Error
}
return r.db.WithContext(ctx).Save(&existing).Error
}
+2
View File
@@ -2050,6 +2050,8 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
copilot.GET("/config", h.CopilotConfig.PlatformGet)
copilot.PUT("/config", h.CopilotConfig.PlatformUpdate)
copilot.POST("/config/test", h.CopilotConfig.PlatformTest)
copilot.GET("/embeddings/reindex", h.CopilotConfig.PlatformEmbeddingReindexStatus)
copilot.POST("/embeddings/reindex", h.CopilotConfig.PlatformEmbeddingReindexStart)
}
// PlatformApp CRUD
+65 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/gochat/gochat/internal/llm"
@@ -30,6 +31,18 @@ type ArticleService struct {
searchIndexer SearchIndexer
worker *worker.WorkerPool
translator ArticleTranslationBackend
reindexMu sync.RWMutex
reindexStatus EmbeddingReindexStatus
}
type EmbeddingReindexStatus struct {
Running bool `json:"running"`
Total int `json:"total"`
Processed int `json:"processed"`
Failed int `json:"failed"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
type ArticleTranslationBackend interface {
@@ -94,6 +107,55 @@ func (s *ArticleService) SetLLMProvider(provider llm.Provider) {
s.llmProvider = provider
}
func (s *ArticleService) EmbeddingReindexStatus() EmbeddingReindexStatus {
s.reindexMu.RLock()
defer s.reindexMu.RUnlock()
return s.reindexStatus
}
func (s *ArticleService) StartEmbeddingReindex() (EmbeddingReindexStatus, error) {
if s.embeddingRepo == nil || s.llmProvider == nil {
return EmbeddingReindexStatus{}, fmt.Errorf("embedding reindex is not configured")
}
s.reindexMu.Lock()
if s.reindexStatus.Running {
status := s.reindexStatus
s.reindexMu.Unlock()
return status, fmt.Errorf("embedding reindex is already running")
}
var articleIDs []uint
if err := s.repo.DB().Model(&model.Article{}).Order("id ASC").Pluck("id", &articleIDs).Error; err != nil {
s.reindexMu.Unlock()
return EmbeddingReindexStatus{}, fmt.Errorf("list articles for embedding reindex: %w", err)
}
now := time.Now().UTC()
s.reindexStatus = EmbeddingReindexStatus{Running: true, Total: len(articleIDs), StartedAt: &now}
status := s.reindexStatus
s.reindexMu.Unlock()
go s.runEmbeddingReindex(articleIDs)
return status, nil
}
func (s *ArticleService) runEmbeddingReindex(articleIDs []uint) {
ctx := context.Background()
for _, articleID := range articleIDs {
err := s.GenerateEmbedding(ctx, articleID)
s.reindexMu.Lock()
s.reindexStatus.Processed++
if err != nil {
s.reindexStatus.Failed++
s.reindexStatus.Error = err.Error()
}
s.reindexMu.Unlock()
}
now := time.Now().UTC()
s.reindexMu.Lock()
s.reindexStatus.Running = false
s.reindexStatus.CompletedAt = &now
s.reindexMu.Unlock()
}
func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) {
if s.searchIndexer != nil {
logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article))
@@ -893,7 +955,7 @@ func (s *ArticleService) SemanticSearch(ctx context.Context, portalID uint, quer
// Generate embedding for the query
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Model: "text-embedding-3-small",
Model: "",
Input: []string{query},
})
if err != nil {
@@ -946,7 +1008,7 @@ func (s *ArticleService) GenerateEmbedding(ctx context.Context, articleID uint)
// Generate embedding
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Model: "text-embedding-3-small",
Model: "",
Input: []string{text},
})
if err != nil {
@@ -967,6 +1029,7 @@ func (s *ArticleService) GenerateEmbedding(ctx context.Context, articleID uint)
VectorEmbedding: pgvector.NewVector(float32Emb),
Term: text,
}
emb.Embedding, _ = json.Marshal(embedResp.Data[0].Embedding)
return s.embeddingRepo.Upsert(ctx, emb)
}
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
@@ -24,6 +25,20 @@ type recordingArticleLLM struct {
requests []llm.ChatRequest
}
type embeddingArticleLLM struct{}
func (embeddingArticleLLM) ChatCompletion(context.Context, llm.ChatRequest) (*llm.ChatResponse, error) {
return nil, fmt.Errorf("not used")
}
func (embeddingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return &llm.EmbeddingResponse{Data: []llm.EmbeddingData{{Embedding: []float64{0.1, 0.2, 0.3}}}}, nil
}
func (embeddingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error {
return fmt.Errorf("not used")
}
func (m *recordingArticleLLM) ChatCompletion(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
m.requests = append(m.requests, req)
return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Content: " Texte traduit "}}}}, nil
@@ -48,6 +63,32 @@ func setupArticleService(t *testing.T) (*gorm.DB, *repository.ArticleRepo, *Arti
return db, repo, svc
}
func TestArticleServiceEmbeddingReindexTracksProgress(t *testing.T) {
db, _, svc := setupArticleService(t)
require.NoError(t, db.AutoMigrate(&model.ArticleEmbedding{}))
account := createTestAccount(t, db)
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-one" })
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-two" })
svc.SetEmbeddingRepo(repository.NewArticleEmbeddingRepo(db))
svc.SetLLMProvider(embeddingArticleLLM{})
status, err := svc.StartEmbeddingReindex()
require.NoError(t, err)
require.True(t, status.Running)
require.Equal(t, 2, status.Total)
require.Eventually(t, func() bool {
return !svc.EmbeddingReindexStatus().Running
}, time.Second, 10*time.Millisecond)
status = svc.EmbeddingReindexStatus()
require.Equal(t, 2, status.Processed)
require.Equal(t, 0, status.Failed, status.Error)
require.NotNil(t, status.CompletedAt)
var count int64
require.NoError(t, db.Model(&model.ArticleEmbedding{}).Count(&count).Error)
require.Equal(t, int64(2), count)
}
// ========== BulkActions ==========
func TestArticleService_BulkActions_Publish(t *testing.T) {
@@ -25,49 +25,49 @@ import (
// --- CRUD DTOs ---
type CreateAutoReplyRuleRequest struct {
AssistantID uint `json:"assistant_id" validate:"required"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name" validate:"required,min=1"`
Description string `json:"description,omitempty"`
Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed
Priority int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
AssistantID uint `json:"assistant_id" validate:"required"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name" validate:"required,min=1"`
Description string `json:"description,omitempty"`
Mode string `json:"mode" validate:"required,oneof=static llm mixed"` // static, llm, mixed
Priority int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
}
type UpdateAutoReplyRuleRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Status *model.AutoReplyRuleStatus `json:"status,omitempty"`
Mode *model.AutoReplyRuleMode `json:"mode,omitempty"`
Priority *int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText *string `json:"response_text,omitempty"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Status *model.AutoReplyRuleStatus `json:"status,omitempty"`
Mode *model.AutoReplyRuleMode `json:"mode,omitempty"`
Priority *int `json:"priority,omitempty"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText *string `json:"response_text,omitempty"`
LLMPromptOverride *string `json:"llm_prompt_override,omitempty"`
DelaySeconds *int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
DelaySeconds *int `json:"delay_seconds,omitempty"`
OneTimeOnly *bool `json:"one_time_only,omitempty"`
}
type AutoReplyRuleResult struct {
ID uint `json:"id"`
AccountID uint `json:"account_id"`
AssistantID uint `json:"assistant_id"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status model.AutoReplyRuleStatus `json:"status"`
Mode model.AutoReplyRuleMode `json:"mode"`
Priority int `json:"priority"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds"`
OneTimeOnly bool `json:"one_time_only"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
AccountID uint `json:"account_id"`
AssistantID uint `json:"assistant_id"`
InboxID *uint `json:"inbox_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status model.AutoReplyRuleStatus `json:"status"`
Mode model.AutoReplyRuleMode `json:"mode"`
Priority int `json:"priority"`
Conditions []model.AutoReplyCondition `json:"conditions,omitempty"`
ResponseText string `json:"response_text,omitempty"`
LLMPromptOverride string `json:"llm_prompt_override,omitempty"`
DelaySeconds int `json:"delay_seconds"`
OneTimeOnly bool `json:"one_time_only"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- Auto-Reply Execution DTOs ---
@@ -84,19 +84,19 @@ type AutoReplyEvaluationContext struct {
}
type AutoReplyMatchResult struct {
Rule *model.CaptainAutoReplyRule `json:"rule"`
ReplyContent string `json:"reply_content"`
ReplyMode model.AutoReplyRuleMode `json:"reply_mode"`
ShouldReply bool `json:"should_reply"`
Rule *model.CaptainAutoReplyRule `json:"rule"`
ReplyContent string `json:"reply_content"`
ReplyMode model.AutoReplyRuleMode `json:"reply_mode"`
ShouldReply bool `json:"should_reply"`
}
// AutoReplyRuleService provides CRUD + evaluation + execution for auto-reply rules.
type AutoReplyRuleService struct {
ruleRepo *repository.CaptainAutoReplyRuleRepo
assistantRepo *repository.CaptainAssistantRepo
ruleRepo *repository.CaptainAutoReplyRuleRepo
assistantRepo *repository.CaptainAssistantRepo
conversationRepo *repository.ConversationRepo
llmProvider llm.Provider
promptBuilder *SystemPromptBuilder
llmProvider llm.Provider
promptBuilder *SystemPromptBuilder
}
// NewAutoReplyRuleService creates a new AutoReplyRuleService.
@@ -415,6 +415,7 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
}
cfg, _ := assistant.GetConfig()
ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant")
// Build system prompt
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
@@ -433,16 +434,13 @@ func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.
}
modelName := cfg.Model
if modelName == "" {
modelName = "gpt-4"
}
temperature := cfg.Temperature
if temperature == 0 {
temperature = 0.7
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: modelName,
Model: modelName,
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: contextBuilder.String()},
@@ -483,4 +481,4 @@ func ruleToResult(rule *model.CaptainAutoReplyRule) *AutoReplyRuleResult {
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
}
}
}
@@ -65,6 +65,7 @@ type ProcessResponseResult struct {
// ProcessResponse generates an AI-powered response for a conversation and optionally stores it as a message.
func (s *CaptainAssistantResponseService) ProcessResponse(ctx context.Context, accountID uint, req *ProcessResponseRequest) (*ProcessResponseResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
// Validate request
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
@@ -125,6 +125,7 @@ func (s *CaptainConversationService) collectConversationMessages(ctx context.Con
}
func (s *CaptainConversationService) generateConversationResponse(ctx context.Context, accountID uint, conversation *model.Conversation, assistant *model.CaptainAssistant, history []CaptainConversationMessage) (*CaptainConversationResponse, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
if s.backend != nil {
return s.backend.GenerateCaptainConversationResponse(ctx, CaptainConversationResponseRequest{AccountID: accountID, Conversation: conversation, Assistant: assistant, Messages: history})
}
@@ -152,9 +153,6 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
}
modelName := cfg.Model
if modelName == "" {
modelName = "gpt-4"
}
temperature := cfg.Temperature
if temperature == 0 {
temperature = 0.7
@@ -622,7 +622,7 @@ func (s *CaptainDocumentService) generateResponseEmbedding(ctx context.Context,
if s.llmProvider == nil {
return pgvector.Vector{}, fmt.Errorf("embedding generation disabled")
}
result, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{Model: "text-embedding-3-small", Input: []string{content}})
result, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{Model: "", Input: []string{content}})
if err != nil {
return pgvector.Vector{}, fmt.Errorf("generate response embedding: %w", err)
}
@@ -707,7 +707,7 @@ func (s *CaptainDocumentService) ProcessDocument(ctx context.Context, id uint) e
// Generate embedding for the document content
_, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Input: []string{content},
Model: "text-embedding-ada-002",
Model: "",
})
if err != nil {
applogger.L().Errorf("ProcessDocument embedding: %v", err)
@@ -766,7 +766,7 @@ func (s *CaptainDocumentService) SyncDocument(ctx context.Context, id uint) erro
// Re-generate embedding for updated content
_, err = s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Input: []string{content},
Model: "text-embedding-ada-002",
Model: "",
})
if err != nil {
applogger.L().Errorf("SyncDocument embedding: %v", err)
@@ -51,14 +51,14 @@ type CreatePreferenceRequest struct {
// UpdatePreferenceRequest is the DTO for updating a preference.
// Reference: Chatwoot enterprise/app/models/captain/preference.rb
type UpdatePreferenceRequest struct {
Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"`
Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"`
ResponseGuidelines string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"`
AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"`
AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"`
AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"`
MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"`
CustomPromptSuffix string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"`
Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"`
Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"`
ResponseGuidelines *string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"`
AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"`
AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"`
AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"`
MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"`
CustomPromptSuffix *string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"`
}
// UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params.
@@ -109,44 +109,13 @@ type CaptainFeatureModel struct {
CreditMultiplier int `json:"credit_multiplier"`
}
var captainProviders = map[string]map[string]string{
"openai": {"display_name": "OpenAI"},
"anthropic": {"display_name": "Anthropic"},
"gemini": {"display_name": "Gemini"},
}
var captainModels = map[string]CaptainModelConfig{
"gpt-4.1": {Provider: "openai", DisplayName: "GPT-4.1", CreditMultiplier: 3},
"gpt-4.1-mini": {Provider: "openai", DisplayName: "GPT-4.1 Mini", CreditMultiplier: 1},
"gpt-4.1-nano": {Provider: "openai", DisplayName: "GPT-4.1 Nano", CreditMultiplier: 1},
"gpt-5.1": {Provider: "openai", DisplayName: "GPT-5.1", CreditMultiplier: 2},
"gpt-5-mini": {Provider: "openai", DisplayName: "GPT-5 Mini", CreditMultiplier: 1},
"gpt-5-nano": {Provider: "openai", DisplayName: "GPT-5 Nano", CreditMultiplier: 1},
"gpt-5.2": {Provider: "openai", DisplayName: "GPT-5.2", CreditMultiplier: 3},
"claude-haiku-4.5": {Provider: "anthropic", DisplayName: "Claude Haiku 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 2},
"claude-sonnet-4.5": {Provider: "anthropic", DisplayName: "Claude Sonnet 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 3},
"gemini-3-flash": {Provider: "gemini", DisplayName: "Gemini 3 Flash", ComingSoon: boolPtr(true), CreditMultiplier: 1},
"gemini-3-pro": {Provider: "gemini", DisplayName: "Gemini 3 Pro", ComingSoon: boolPtr(true), CreditMultiplier: 3},
"whisper-1": {Provider: "openai", DisplayName: "Whisper", CreditMultiplier: 1},
"text-embedding-3-small": {Provider: "openai", DisplayName: "Text Embedding 3 Small", CreditMultiplier: 1},
}
var captainFeatureModels = map[string][]string{
"editor": {"gpt-4.1-mini", "gpt-4.1-nano", "gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "gemini-3-flash", "gemini-3-pro"},
"assistant": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"},
"copilot": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"},
"label_suggestion": {"gpt-4.1-nano", "gpt-4.1-mini", "gpt-5-mini", "gemini-3-flash", "claude-haiku-4.5"},
"audio_transcription": {"whisper-1"},
"help_center_search": {"text-embedding-3-small"},
}
var captainFeatureDefaults = map[string]string{
"editor": "gpt-4.1-mini",
"assistant": "gpt-5.1",
"copilot": "gpt-5.1",
"label_suggestion": "gpt-4.1-nano",
"audio_transcription": "whisper-1",
"help_center_search": "text-embedding-3-small",
var captainFeatures = map[string]struct{}{
"editor": {},
"assistant": {},
"copilot": {},
"label_suggestion": {},
"help_center_search": {},
"audio_transcription": {}, // legacy API compatibility; not exposed by the new page.
}
var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "help_center_search"}
@@ -246,9 +215,17 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u
func (s *CaptainPreferenceService) providerConfigPayload(ctx context.Context) (*CopilotProviderConfigPayload, error) {
if s.copilotConfigService == nil {
return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil), nil
return copilotProviderPayload(defaultCopilotProviderSettings(), "", "", nil, nil), nil
}
return s.copilotConfigService.Get(ctx)
payload, err := s.copilotConfigService.Get(ctx)
if err != nil {
return nil, err
}
// Account-scoped APIs expose only whether credentials exist. Masked values
// are reserved for the SuperAdmin platform endpoint.
payload.Chat.APIKey.Masked = ""
payload.Embedding.APIKey.Masked = ""
return payload, nil
}
func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID uint) (*model.Account, error) {
@@ -328,22 +305,10 @@ func captainConfigPayload(account *model.Account, providerConfig *CopilotProvide
}
func isCaptainFeature(key string) bool {
_, ok := captainFeatureModels[key]
_, ok := captainFeatures[key]
return ok
}
func validCaptainModelFor(feature, modelName string) bool {
if modelName == "" {
return false
}
for _, allowed := range captainFeatureModels[feature] {
if allowed == modelName {
return true
}
}
return false
}
func jsonMapString(raw datatypes.JSON) map[string]string {
result := map[string]string{}
if len(raw) == 0 || string(raw) == "null" {
@@ -367,10 +332,6 @@ func marshalJSONMap(value any) datatypes.JSON {
return datatypes.JSON(raw)
}
func boolPtr(value bool) *bool {
return &value
}
func defaultCaptainPreference(accountID uint) *model.CaptainPreference {
return &model.CaptainPreference{
AccountID: accountID,
@@ -403,8 +364,8 @@ func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context,
if req.Language != "" {
pref.Language = req.Language
}
if req.ResponseGuidelines != "" {
pref.ResponseGuidelines = req.ResponseGuidelines
if req.ResponseGuidelines != nil {
pref.ResponseGuidelines = *req.ResponseGuidelines
}
if req.AutoLabelEnabled != nil {
pref.AutoLabelEnabled = *req.AutoLabelEnabled
@@ -418,8 +379,8 @@ func (s *CaptainPreferenceService) updateOrCreatePreference(ctx context.Context,
if req.MaxResponseLength != nil {
pref.MaxResponseLength = *req.MaxResponseLength
}
if req.CustomPromptSuffix != "" {
pref.CustomPromptSuffix = req.CustomPromptSuffix
if req.CustomPromptSuffix != nil {
pref.CustomPromptSuffix = *req.CustomPromptSuffix
}
if create {
if err := s.repo.Create(ctx, pref); err != nil {
@@ -518,8 +479,8 @@ func (s *CaptainPreferenceService) Update(ctx context.Context, accountID uint, r
if req.Language != "" {
pref.Language = req.Language
}
if req.ResponseGuidelines != "" {
pref.ResponseGuidelines = req.ResponseGuidelines
if req.ResponseGuidelines != nil {
pref.ResponseGuidelines = *req.ResponseGuidelines
}
if req.AutoLabelEnabled != nil {
pref.AutoLabelEnabled = *req.AutoLabelEnabled
@@ -533,8 +494,8 @@ func (s *CaptainPreferenceService) Update(ctx context.Context, accountID uint, r
if req.MaxResponseLength != nil {
pref.MaxResponseLength = *req.MaxResponseLength
}
if req.CustomPromptSuffix != "" {
pref.CustomPromptSuffix = req.CustomPromptSuffix
if req.CustomPromptSuffix != nil {
pref.CustomPromptSuffix = *req.CustomPromptSuffix
}
if err := s.repo.Update(ctx, pref); err != nil {
@@ -460,7 +460,7 @@ func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantI
// Generate embedding for the query
embResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Input: []string{query},
Model: "text-embedding-ada-002",
Model: "",
})
if err != nil {
return "", fmt.Errorf("create embedding: %w", err)
@@ -26,11 +26,11 @@ type ParticipantAnalysisRequest struct {
// ParticipantInfo holds analysis results for a single participant.
type ParticipantInfo struct {
Name string `json:"name"`
Role string `json:"role"` // "customer", "agent", "manager"
Sentiment string `json:"sentiment"` // "positive", "neutral", "negative"
Topics []string `json:"topics"` // main topics discussed
Engagement float64 `json:"engagement"` // engagement score 0-1
Name string `json:"name"`
Role string `json:"role"` // "customer", "agent", "manager"
Sentiment string `json:"sentiment"` // "positive", "neutral", "negative"
Topics []string `json:"topics"` // main topics discussed
Engagement float64 `json:"engagement"` // engagement score 0-1
}
// ParticipantAnalysisResult holds the participant analysis result.
@@ -50,7 +50,7 @@ type ActionItem struct {
Owner string `json:"owner,omitempty"` // person responsible
Deadline string `json:"deadline,omitempty"`
Priority string `json:"priority"` // "high", "medium", "low"
Status string `json:"status"` // "pending", "in_progress", "completed"
Status string `json:"status"` // "pending", "in_progress", "completed"
}
// ActionItemsResult holds the extracted action items.
@@ -60,8 +60,8 @@ type ActionItemsResult struct {
// LabelSuggestionRequest is the input for suggesting labels/priority for a conversation.
type LabelSuggestionRequest struct {
ConversationID uint `json:"conversation_id" validate:"required"`
AssistantID uint `json:"assistant_id,omitempty"` // optional: use assistant guidelines
ConversationID uint `json:"conversation_id" validate:"required"`
AssistantID uint `json:"assistant_id,omitempty"` // optional: use assistant guidelines
}
// InsightLabelSuggestionResult holds suggested labels and priority.
@@ -101,6 +101,7 @@ func NewConversationInsightService(
// AnalyzeParticipants analyzes the participants in a conversation.
func (s *ConversationInsightService) AnalyzeParticipants(ctx context.Context, accountID uint, req *ParticipantAnalysisRequest) (*ParticipantAnalysisResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
// Fetch conversation messages
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
if err != nil {
@@ -128,7 +129,7 @@ Return your analysis as JSON in this exact format:
}`
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: contextStr},
@@ -161,6 +162,7 @@ Return your analysis as JSON in this exact format:
// ExtractActionItems extracts action items from a conversation.
func (s *ConversationInsightService) ExtractActionItems(ctx context.Context, accountID uint, req *ActionItemsRequest) (*ActionItemsResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
if err != nil {
return nil, fmt.Errorf("fetch conversation context: %w", err)
@@ -185,7 +187,7 @@ Return as JSON array:
}`
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: contextStr},
@@ -217,6 +219,7 @@ Return as JSON array:
// SuggestLabels suggests labels and priority for a conversation.
func (s *ConversationInsightService) SuggestLabels(ctx context.Context, accountID uint, req *LabelSuggestionRequest) (*LabelSuggestionResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "label_suggestion")
contextStr, err := s.fetchConversationContext(ctx, req.ConversationID)
if err != nil {
return nil, fmt.Errorf("fetch conversation context: %w", err)
@@ -252,7 +255,7 @@ Return as JSON:
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: contextStr},
@@ -301,4 +304,4 @@ func (s *ConversationInsightService) fetchConversationContext(ctx context.Contex
}
return builder.String(), nil
}
}
@@ -77,11 +77,21 @@ type CopilotEmbeddingConfigInput struct {
CopilotSecretInput
}
type CopilotGenerationConfigInput struct {
Temperature *float64 `json:"temperature"`
MaxTokens *int `json:"max_tokens"`
}
type CopilotRequestConfigInput struct {
TimeoutSeconds *int `json:"timeout_seconds"`
MaxRetries *int `json:"max_retries"`
}
type CopilotProviderConfigInput struct {
Chat CopilotChatConfigInput `json:"chat"`
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
Generation CopilotGenerationSettings `json:"generation"`
Request CopilotRequestSettings `json:"request"`
Chat CopilotChatConfigInput `json:"chat"`
Embedding CopilotEmbeddingConfigInput `json:"embedding"`
Generation CopilotGenerationConfigInput `json:"generation"`
Request CopilotRequestConfigInput `json:"request"`
}
type CopilotSecretPayload struct {
@@ -126,6 +136,7 @@ type CopilotProviderConfigPayload struct {
Generation CopilotGenerationSettings `json:"generation"`
Request CopilotRequestSettings `json:"request"`
Configured bool `json:"configured"`
AppliedAt *time.Time `json:"applied_at,omitempty"`
Health *CopilotProviderHealth `json:"health,omitempty"`
}
@@ -181,7 +192,11 @@ func (s *CopilotConfigService) Get(ctx context.Context) (*CopilotProviderConfigP
if err != nil {
return nil, err
}
return copilotProviderPayload(settings, chatKey, embeddingKey, health), nil
appliedAt, err := s.loadAppliedAt(ctx)
if err != nil {
return nil, err
}
return copilotProviderPayload(settings, chatKey, embeddingKey, appliedAt, health), nil
}
func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderConfigPayload, error) {
@@ -212,12 +227,14 @@ func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProvider
if !configured {
s.manager.Clear()
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
now := time.Now().UTC()
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, nil), nil
}
if err := s.manager.Configure(runtimeCfg); err != nil {
return nil, err
}
return copilotProviderPayload(settings, chatKey, embeddingKey, nil), nil
now := time.Now().UTC()
return copilotProviderPayload(settings, chatKey, embeddingKey, &now, nil), nil
}
// Test validates and calls Chat and Embedding with candidate settings without
@@ -324,17 +341,17 @@ func (s *CopilotConfigService) mergedConfig(ctx context.Context, input CopilotPr
embeddingKey = strings.TrimSpace(input.Embedding.APIKey)
}
if input.Generation.MaxTokens != 0 {
settings.Generation.MaxTokens = input.Generation.MaxTokens
if input.Generation.MaxTokens != nil {
settings.Generation.MaxTokens = *input.Generation.MaxTokens
}
if input.Generation.Temperature >= 0 {
settings.Generation.Temperature = input.Generation.Temperature
if input.Generation.Temperature != nil {
settings.Generation.Temperature = *input.Generation.Temperature
}
if input.Request.TimeoutSeconds != 0 {
settings.Request.TimeoutSeconds = input.Request.TimeoutSeconds
if input.Request.TimeoutSeconds != nil {
settings.Request.TimeoutSeconds = *input.Request.TimeoutSeconds
}
if input.Request.MaxRetries >= 0 {
settings.Request.MaxRetries = input.Request.MaxRetries
if input.Request.MaxRetries != nil {
settings.Request.MaxRetries = *input.Request.MaxRetries
}
settings = normalizeCopilotProviderSettings(settings)
@@ -415,6 +432,18 @@ func (s *CopilotConfigService) loadMatchingHealth(ctx context.Context, settings
return &health, nil
}
func (s *CopilotConfigService) loadAppliedAt(ctx context.Context) (*time.Time, error) {
record, err := s.repo.FindByName(ctx, copilotProviderConfigKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load Copilot provider applied time: %w", err)
}
appliedAt := record.UpdatedAt.UTC()
return &appliedAt, nil
}
func normalizeCopilotProviderSettings(settings CopilotProviderSettings) CopilotProviderSettings {
settings.Chat.Provider = strings.ToLower(strings.TrimSpace(settings.Chat.Provider))
settings.Chat.BaseURL = strings.TrimRight(strings.TrimSpace(settings.Chat.BaseURL), "/")
@@ -514,6 +543,9 @@ func validateCopilotURL(raw, kind string) error {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("Copilot %s base URL must be a valid HTTP(S) URL", kind)
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("Copilot %s base URL must not contain credentials, query parameters, or fragments", kind)
}
return nil
}
@@ -546,7 +578,7 @@ func copilotConfigComplete(settings CopilotProviderSettings, chatKey, embeddingK
return true
}
func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embeddingKey string, health *CopilotProviderHealth) *CopilotProviderConfigPayload {
func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embeddingKey string, appliedAt *time.Time, health *CopilotProviderHealth) *CopilotProviderConfigPayload {
return &CopilotProviderConfigPayload{
Chat: CopilotChatConfigPayload{
Provider: settings.Chat.Provider,
@@ -565,6 +597,7 @@ func copilotProviderPayload(settings CopilotProviderSettings, chatKey, embedding
Generation: settings.Generation,
Request: settings.Request,
Configured: copilotConfigComplete(settings, chatKey, embeddingKey),
AppliedAt: appliedAt,
Health: health,
}
}
@@ -26,6 +26,8 @@ func setupCopilotConfigServiceTest(t *testing.T) (*CopilotConfigService, *gorm.D
return svc, db, manager
}
func float64Ptr(v float64) *float64 { return &v }
func testCopilotInput(baseURL string) CopilotProviderConfigInput {
return CopilotProviderConfigInput{
Chat: CopilotChatConfigInput{
@@ -41,8 +43,8 @@ func testCopilotInput(baseURL string) CopilotProviderConfigInput {
Model: "custom-embedding",
Dimensions: 3,
},
Generation: CopilotGenerationSettings{Temperature: 0.4, MaxTokens: 800},
Request: CopilotRequestSettings{TimeoutSeconds: 30, MaxRetries: 0},
Generation: CopilotGenerationConfigInput{Temperature: float64Ptr(0.4), MaxTokens: intPtr(800)},
Request: CopilotRequestConfigInput{TimeoutSeconds: intPtr(30), MaxRetries: intPtr(0)},
}
}
@@ -96,6 +98,13 @@ func TestCopilotConfigServiceRejectsAnthropicWithoutSeparateEmbedding(t *testing
require.ErrorContains(t, err, "separate embedding provider")
}
func TestCopilotConfigServiceRejectsCredentialsInBaseURL(t *testing.T) {
svc, _, _ := setupCopilotConfigServiceTest(t)
input := testCopilotInput("https://user:secret@llm.example.com/v1")
_, err := svc.Update(context.Background(), input)
require.ErrorContains(t, err, "must not contain credentials")
}
func TestCopilotConfigServiceTestsChatAndEmbeddingWithoutChangingSavedConfig(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+3 -3
View File
@@ -377,7 +377,7 @@ func (s *CopilotService) generateAssistantContent(ctx context.Context, accountID
// Call LLM for assistant response
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: messages,
Temperature: 0.7,
MaxTokens: 1024,
@@ -414,7 +414,7 @@ func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: messages,
Temperature: 0.7,
MaxTokens: 512,
@@ -456,7 +456,7 @@ func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID ui
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: messages,
Temperature: 0.3,
MaxTokens: 256,
+19 -19
View File
@@ -25,30 +25,30 @@ func NewIntentService(llmProvider llm.Provider) *IntentService {
type IntentType string
const (
IntentTypeQuestion IntentType = "question" // User asks a question
IntentTypeComplaint IntentType = "complaint" // User expresses dissatisfaction
IntentTypeRequest IntentType = "request" // User requests an action/feature
IntentTypeFeedback IntentType = "feedback" // User provides feedback
IntentTypeGreeting IntentType = "greeting" // User says hello/greetings
IntentTypeUrgent IntentType = "urgent" // User expresses urgency
IntentTypeCancellation IntentType = "cancellation" // User wants to cancel/stop
IntentTypeBilling IntentType = "billing" // User has billing/payment issue
IntentTypeTechnical IntentType = "technical" // User has technical/bug issue
IntentTypeOther IntentType = "other" // Unclassified intent
IntentTypeQuestion IntentType = "question" // User asks a question
IntentTypeComplaint IntentType = "complaint" // User expresses dissatisfaction
IntentTypeRequest IntentType = "request" // User requests an action/feature
IntentTypeFeedback IntentType = "feedback" // User provides feedback
IntentTypeGreeting IntentType = "greeting" // User says hello/greetings
IntentTypeUrgent IntentType = "urgent" // User expresses urgency
IntentTypeCancellation IntentType = "cancellation" // User wants to cancel/stop
IntentTypeBilling IntentType = "billing" // User has billing/payment issue
IntentTypeTechnical IntentType = "technical" // User has technical/bug issue
IntentTypeOther IntentType = "other" // Unclassified intent
)
// IntentResult holds the classification result.
type IntentResult struct {
Intent IntentType `json:"intent"`
Confidence float64 `json:"confidence"`
SubIntents []string `json:"sub_intents,omitempty"`
SuggestedTone string `json:"suggested_tone,omitempty"` // empathetic, formal, casual
KeyTopics []string `json:"key_topics,omitempty"`
Intent IntentType `json:"intent"`
Confidence float64 `json:"confidence"`
SubIntents []string `json:"sub_intents,omitempty"`
SuggestedTone string `json:"suggested_tone,omitempty"` // empathetic, formal, casual
KeyTopics []string `json:"key_topics,omitempty"`
}
// ClassifyIntentRequest is the DTO for intent classification.
type ClassifyIntentRequest struct {
Message string `json:"message" validate:"required"`
Message string `json:"message" validate:"required"`
Language string `json:"language,omitempty"` // optional language hint
}
@@ -64,7 +64,7 @@ func (s *IntentService) ClassifyIntent(ctx context.Context, req *ClassifyIntentR
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: messages,
Temperature: 0.1, // Low temperature for consistent classification
MaxTokens: 256,
@@ -126,7 +126,7 @@ func extractJSON(s string) string {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start != -1 && end != -1 && end > start {
return s[start:end+1]
return s[start : end+1]
}
return s
}
@@ -155,4 +155,4 @@ func parseIntentFromText(text string) IntentType {
}
}
return IntentTypeOther
}
}
@@ -823,6 +823,7 @@ func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req
// TranslateInConversation translates a message scoped to a conversation route and caches the result.
func (s *MessageService) TranslateInConversation(ctx context.Context, accountID, conversationID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "copilot")
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
+6 -1
View File
@@ -440,7 +440,12 @@ func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, ac
}
userType := user.Type
if userType == "" {
if user.Role == string(model.UserTypeSuperAdmin) {
// Chatwoot models platform administrators through STI, so the profile
// serializer exposes `type: "SuperAdmin"`. GoChat stores the equivalent
// platform role separately and normalizes it at the API boundary.
userType = "SuperAdmin"
} else if userType == "" {
userType = "User"
}
+11 -16
View File
@@ -7,8 +7,8 @@ import (
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/pgvector/pgvector-go"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/pgvector/pgvector-go"
)
// RAGService implements Retrieval-Augmented Generation for Captain knowledge base Q&A.
@@ -38,11 +38,11 @@ type RAGQueryResult struct {
// RAGSource references a source FAQ response used in the answer.
type RAGSource struct {
ResponseID uint `json:"response_id"`
Question string `json:"question"`
Answer string `json:"answer"`
DocumentID uint `json:"document_id,omitempty"`
Score float64 `json:"score"`
ResponseID uint `json:"response_id"`
Question string `json:"question"`
Answer string `json:"answer"`
DocumentID uint `json:"document_id,omitempty"`
Score float64 `json:"score"`
}
// AssistantRepoIface defines the repository interface RAGService needs from CaptainAssistantRepo.
@@ -81,6 +81,7 @@ func NewRAGService(
// Query performs a RAG Q&A: embed question → search FAQs → generate answer.
func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryRequest) (*RAGQueryResult, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
if req.TopK <= 0 {
req.TopK = 5
}
@@ -103,7 +104,7 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
// Step 2: Generate embedding for the question
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Model: "text-embedding-3-small",
Model: "",
Input: []string{req.Question},
})
if err != nil {
@@ -169,9 +170,6 @@ func (s *RAGService) Query(ctx context.Context, accountID uint, req *RAGQueryReq
// Step 6: Call LLM for answer generation
modelName := cfg.Model
if modelName == "" {
modelName = "gpt-4"
}
temperature := cfg.Temperature
if temperature == 0 {
temperature = 0.3 // lower temp for factual answers
@@ -212,16 +210,13 @@ func (s *RAGService) queryWithoutContext(ctx context.Context, assistant *model.C
systemPrompt += "\n\nNote: No relevant FAQ entries were found for this question. Answer based on your general knowledge, but indicate that the answer may not be specific to the product."
modelName := cfg.Model
if modelName == "" {
modelName = "gpt-4"
}
temperature := cfg.Temperature
if temperature == 0 {
temperature = 0.5
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: modelName,
Model: modelName,
Messages: []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: question},
@@ -256,7 +251,7 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error {
// Generate embedding from question + answer for better semantic matching
inputText := fmt.Sprintf("Q: %s\nA: %s", resp.Question, resp.Answer)
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Model: "text-embedding-3-small",
Model: "",
Input: []string{inputText},
})
if err != nil {
@@ -278,4 +273,4 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error {
applogger.L().Infof("RAG indexed response %d for assistant %d", resp.ID, resp.AssistantID)
return nil
}
}
@@ -194,6 +194,7 @@ func (s *ToolExecutionService) RunToolCallLoop(
maxTokens int,
maxIterations int,
) (string, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
if s.llmProvider == nil {
return "", fmt.Errorf("LLM provider not configured")
}
@@ -0,0 +1,13 @@
-- Restore the legacy 1536-dimensional column. Embeddings with any other
-- dimension cannot be represented and are cleared during rollback.
ALTER TABLE article_embeddings
ALTER COLUMN vector_embedding TYPE vector(1536)
USING CASE
WHEN vector_embedding IS NULL THEN NULL
WHEN vector_dims(vector_embedding) = 1536 THEN vector_embedding::vector(1536)
ELSE NULL
END;
CREATE INDEX IF NOT EXISTS idx_article_embeddings_vector
ON article_embeddings USING ivfflat (vector_embedding vector_cosine_ops)
WITH (lists = 100);
@@ -0,0 +1,7 @@
-- Copilot embedding dimensions are configured at runtime. Remove the fixed
-- vector(1536) typmod so rebuilt article embeddings can use the selected model.
DROP INDEX IF EXISTS idx_article_embeddings_vector;
ALTER TABLE article_embeddings
ALTER COLUMN vector_embedding TYPE vector
USING vector_embedding::vector;
+31 -22
View File
@@ -11,27 +11,33 @@ type ErrorCode string
const (
// General errors
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrNotFound ErrorCode = "NOT_FOUND"
ErrBadRequest ErrorCode = "BAD_REQUEST"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrForbidden ErrorCode = "FORBIDDEN"
ErrConflict ErrorCode = "CONFLICT"
ErrValidation ErrorCode = "VALIDATION_ERROR"
ErrRateLimit ErrorCode = "RATE_LIMITED"
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrNotFound ErrorCode = "NOT_FOUND"
ErrBadRequest ErrorCode = "BAD_REQUEST"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrForbidden ErrorCode = "FORBIDDEN"
ErrConflict ErrorCode = "CONFLICT"
ErrValidation ErrorCode = "VALIDATION_ERROR"
ErrRateLimit ErrorCode = "RATE_LIMITED"
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
ErrPaymentRequired ErrorCode = "PAYMENT_REQUIRED"
// Business-specific errors (ref: Chatwoot error patterns)
ErrAccountNotFound ErrorCode = "ACCOUNT_NOT_FOUND"
ErrInboxNotFound ErrorCode = "INBOX_NOT_FOUND"
ErrChannelInvalid ErrorCode = "CHANNEL_INVALID"
ErrContactNotFound ErrorCode = "CONTACT_NOT_FOUND"
ErrConversationNotFound ErrorCode = "CONVERSATION_NOT_FOUND"
ErrMessageNotFound ErrorCode = "MESSAGE_NOT_FOUND"
ErrUserNotFound ErrorCode = "USER_NOT_FOUND"
ErrDuplicateRecord ErrorCode = "DUPLICATE_RECORD"
ErrChannelNotEnabled ErrorCode = "CHANNEL_NOT_ENABLED"
ErrAccountNotFound ErrorCode = "ACCOUNT_NOT_FOUND"
ErrInboxNotFound ErrorCode = "INBOX_NOT_FOUND"
ErrChannelInvalid ErrorCode = "CHANNEL_INVALID"
ErrContactNotFound ErrorCode = "CONTACT_NOT_FOUND"
ErrConversationNotFound ErrorCode = "CONVERSATION_NOT_FOUND"
ErrMessageNotFound ErrorCode = "MESSAGE_NOT_FOUND"
ErrUserNotFound ErrorCode = "USER_NOT_FOUND"
ErrDuplicateRecord ErrorCode = "DUPLICATE_RECORD"
ErrChannelNotEnabled ErrorCode = "CHANNEL_NOT_ENABLED"
ErrCopilotNotConfigured ErrorCode = "COPILOT_NOT_CONFIGURED"
ErrCopilotProviderAuth ErrorCode = "COPILOT_PROVIDER_AUTHENTICATION_FAILED"
ErrCopilotProviderRateLimited ErrorCode = "COPILOT_PROVIDER_RATE_LIMITED"
ErrCopilotProviderUnreachable ErrorCode = "COPILOT_PROVIDER_UNREACHABLE"
ErrCopilotProviderTimeout ErrorCode = "COPILOT_PROVIDER_TIMEOUT"
ErrCopilotModelNotFound ErrorCode = "COPILOT_MODEL_NOT_FOUND"
// Knowledge Base / Help Center errors (M4)
ErrPortalNotFound ErrorCode = "PORTAL_NOT_FOUND"
@@ -63,9 +69,9 @@ func (e *AppError) Error() string {
// NewAppError creates a new AppError
func NewAppError(code ErrorCode, message string, status int) *AppError {
return &AppError{
Code: code,
Code: code,
Message: message,
Status: status,
Status: status,
}
}
@@ -122,9 +128,12 @@ func ErrorToHTTPStatus(code ErrorCode) int {
return http.StatusForbidden
case ErrConflict, ErrDuplicateRecord, ErrChannelInvalid:
return http.StatusConflict
case ErrRateLimit:
case ErrRateLimit, ErrCopilotProviderRateLimited:
return http.StatusTooManyRequests
case ErrServiceUnavail:
case ErrCopilotProviderTimeout:
return http.StatusGatewayTimeout
case ErrServiceUnavail, ErrCopilotNotConfigured, ErrCopilotProviderAuth,
ErrCopilotProviderUnreachable, ErrCopilotModelNotFound:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError