diff --git a/backend/cmd/gochat/main.go b/backend/cmd/gochat/main.go index e16a812a..a4f2868e 100644 --- a/backend/cmd/gochat/main.go +++ b/backend/cmd/gochat/main.go @@ -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) } diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index e3f7b423..812668e1 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -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), diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 48462cee..79491c36 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -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) -} \ No newline at end of file +} diff --git a/backend/internal/handler/api/v1/auth_handler_test.go b/backend/internal/handler/api/v1/auth_handler_test.go index 94f09edb..8d638b7e 100644 --- a/backend/internal/handler/api/v1/auth_handler_test.go +++ b/backend/internal/handler/api/v1/auth_handler_test.go @@ -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) diff --git a/backend/internal/handler/api/v1/auto_reply_rule_handler_test.go b/backend/internal/handler/api/v1/auto_reply_rule_handler_test.go index 615629eb..4cbf0959 100644 --- a/backend/internal/handler/api/v1/auto_reply_rule_handler_test.go +++ b/backend/internal/handler/api/v1/auto_reply_rule_handler_test.go @@ -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)) -} \ No newline at end of file +} diff --git a/backend/internal/handler/api/v1/captain_preference_handler_test.go b/backend/internal/handler/api/v1/captain_preference_handler_test.go index a91a06c3..0cdd7815 100644 --- a/backend/internal/handler/api/v1/captain_preference_handler_test.go +++ b/backend/internal/handler/api/v1/captain_preference_handler_test.go @@ -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"]) +} diff --git a/backend/internal/handler/api/v1/copilot_config_handler.go b/backend/internal/handler/api/v1/copilot_config_handler.go index 06d3cdb5..3518a140 100644 --- a/backend/internal/handler/api/v1/copilot_config_handler.go +++ b/backend/internal/handler/api/v1/copilot_config_handler.go @@ -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") diff --git a/backend/internal/handler/api/v1/copilot_config_handler_test.go b/backend/internal/handler/api/v1/copilot_config_handler_test.go new file mode 100644 index 00000000..482bc8e4 --- /dev/null +++ b/backend/internal/handler/api/v1/copilot_config_handler_test.go @@ -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") +} diff --git a/backend/internal/handler/api/v1/sse_stream_handler.go b/backend/internal/handler/api/v1/sse_stream_handler.go index d76d75b9..17ba5b6e 100644 --- a/backend/internal/handler/api/v1/sse_stream_handler.go +++ b/backend/internal/handler/api/v1/sse_stream_handler.go @@ -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 != "" { diff --git a/backend/internal/llm/anthropic_provider.go b/backend/internal/llm/anthropic_provider.go index 9a2d51eb..64234ec8 100644 --- a/backend/internal/llm/anthropic_provider.go +++ b/backend/internal/llm/anthropic_provider.go @@ -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<= 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 } diff --git a/backend/internal/llm/openai_provider.go b/backend/internal/llm/openai_provider.go index 484f18d7..aaddcfdb 100644 --- a/backend/internal/llm/openai_provider.go +++ b/backend/internal/llm/openai_provider.go @@ -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<= 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 } diff --git a/backend/internal/llm/provider.go b/backend/internal/llm/provider.go index dc68151c..92b17e32 100644 --- a/backend/internal/llm/provider.go +++ b/backend/internal/llm/provider.go @@ -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"` -} \ No newline at end of file +} diff --git a/backend/internal/llm/provider_manager.go b/backend/internal/llm/provider_manager.go index 8c076d19..8d930ad2 100644 --- a/backend/internal/llm/provider_manager.go +++ b/backend/internal/llm/provider_manager.go @@ -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) } diff --git a/backend/internal/llm/provider_manager_test.go b/backend/internal/llm/provider_manager_test.go index d4260b08..7acdfd7a 100644 --- a/backend/internal/llm/provider_manager_test.go +++ b/backend/internal/llm/provider_manager_test.go @@ -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{ diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 6990f075..8a04ba03 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -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 diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go index 56cd3143..ec845834 100644 --- a/backend/internal/middleware/auth_test.go +++ b/backend/internal/middleware/auth_test.go @@ -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() diff --git a/backend/internal/middleware/super_admin.go b/backend/internal/middleware/super_admin.go index 1530b5f4..bbe29484 100644 --- a/backend/internal/middleware/super_admin.go +++ b/backend/internal/middleware/super_admin.go @@ -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() } -} \ No newline at end of file +} + +func isSuperAdminType(value any) bool { + typeStr, ok := value.(string) + if !ok { + return false + } + normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(typeStr), "_", "")) + return normalized == "superadmin" +} diff --git a/backend/internal/middleware/super_admin_test.go b/backend/internal/middleware/super_admin_test.go index b9078dee..cd19e8c4 100644 --- a/backend/internal/middleware/super_admin_test.go +++ b/backend/internal/middleware/super_admin_test.go @@ -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) -} \ No newline at end of file +} diff --git a/backend/internal/model/article_embedding.go b/backend/internal/model/article_embedding.go index 8cae081c..00b94b53 100644 --- a/backend/internal/model/article_embedding.go +++ b/backend/internal/model/article_embedding.go @@ -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" } \ No newline at end of file +func (ArticleEmbedding) TableName() string { return "article_embeddings" } diff --git a/backend/internal/repository/article_embedding_repo.go b/backend/internal/repository/article_embedding_repo.go index fc127984..019e0c1e 100644 --- a/backend/internal/repository/article_embedding_repo.go +++ b/backend/internal/repository/article_embedding_repo.go @@ -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 } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4741339a..cad4c6b6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 diff --git a/backend/internal/service/article_service.go b/backend/internal/service/article_service.go index e12a08a2..2001e39d 100644 --- a/backend/internal/service/article_service.go +++ b/backend/internal/service/article_service.go @@ -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) } diff --git a/backend/internal/service/article_service_test.go b/backend/internal/service/article_service_test.go index f795d5d6..a6b6d51f 100644 --- a/backend/internal/service/article_service_test.go +++ b/backend/internal/service/article_service_test.go @@ -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) { diff --git a/backend/internal/service/auto_reply_rule_service.go b/backend/internal/service/auto_reply_rule_service.go index f671c3bd..52fca360 100644 --- a/backend/internal/service/auto_reply_rule_service.go +++ b/backend/internal/service/auto_reply_rule_service.go @@ -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, } -} \ No newline at end of file +} diff --git a/backend/internal/service/captain_assistant_response_service.go b/backend/internal/service/captain_assistant_response_service.go index aa697e35..18012f0d 100644 --- a/backend/internal/service/captain_assistant_response_service.go +++ b/backend/internal/service/captain_assistant_response_service.go @@ -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) diff --git a/backend/internal/service/captain_conversation_service.go b/backend/internal/service/captain_conversation_service.go index 04d7fe55..d61e7d58 100644 --- a/backend/internal/service/captain_conversation_service.go +++ b/backend/internal/service/captain_conversation_service.go @@ -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 diff --git a/backend/internal/service/captain_document_service.go b/backend/internal/service/captain_document_service.go index 55648043..d03455a3 100644 --- a/backend/internal/service/captain_document_service.go +++ b/backend/internal/service/captain_document_service.go @@ -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) diff --git a/backend/internal/service/captain_preference_service.go b/backend/internal/service/captain_preference_service.go index 925c722d..bc91d0e0 100644 --- a/backend/internal/service/captain_preference_service.go +++ b/backend/internal/service/captain_preference_service.go @@ -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 { diff --git a/backend/internal/service/captain_task_service.go b/backend/internal/service/captain_task_service.go index c5b7b7de..5cf64a2e 100644 --- a/backend/internal/service/captain_task_service.go +++ b/backend/internal/service/captain_task_service.go @@ -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) diff --git a/backend/internal/service/conversation_insight_service.go b/backend/internal/service/conversation_insight_service.go index bf97a405..f2ecdb74 100644 --- a/backend/internal/service/conversation_insight_service.go +++ b/backend/internal/service/conversation_insight_service.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/service/copilot_config_service.go b/backend/internal/service/copilot_config_service.go index dd488134..59f5ce1d 100644 --- a/backend/internal/service/copilot_config_service.go +++ b/backend/internal/service/copilot_config_service.go @@ -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, } } diff --git a/backend/internal/service/copilot_config_service_test.go b/backend/internal/service/copilot_config_service_test.go index e1a36240..c2127e7e 100644 --- a/backend/internal/service/copilot_config_service_test.go +++ b/backend/internal/service/copilot_config_service_test.go @@ -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") diff --git a/backend/internal/service/copilot_service.go b/backend/internal/service/copilot_service.go index 4ecfa628..78f3fe1c 100644 --- a/backend/internal/service/copilot_service.go +++ b/backend/internal/service/copilot_service.go @@ -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, diff --git a/backend/internal/service/intent_service.go b/backend/internal/service/intent_service.go index 21039fac..d5d7fd0b 100644 --- a/backend/internal/service/intent_service.go +++ b/backend/internal/service/intent_service.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/service/message_service.go b/backend/internal/service/message_service.go index 43cfd490..82a40fdf 100644 --- a/backend/internal/service/message_service.go +++ b/backend/internal/service/message_service.go @@ -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 } diff --git a/backend/internal/service/profile_service.go b/backend/internal/service/profile_service.go index ceba9a6c..762f6e38 100644 --- a/backend/internal/service/profile_service.go +++ b/backend/internal/service/profile_service.go @@ -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" } diff --git a/backend/internal/service/rag_service.go b/backend/internal/service/rag_service.go index 10be444c..9f96acdf 100644 --- a/backend/internal/service/rag_service.go +++ b/backend/internal/service/rag_service.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/service/tool_execution_service.go b/backend/internal/service/tool_execution_service.go index 954df908..ffe98215 100644 --- a/backend/internal/service/tool_execution_service.go +++ b/backend/internal/service/tool_execution_service.go @@ -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") } diff --git a/backend/migrations/000056_make_article_embedding_dimension_dynamic.down.sql b/backend/migrations/000056_make_article_embedding_dimension_dynamic.down.sql new file mode 100644 index 00000000..ba7de486 --- /dev/null +++ b/backend/migrations/000056_make_article_embedding_dimension_dynamic.down.sql @@ -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); diff --git a/backend/migrations/000056_make_article_embedding_dimension_dynamic.up.sql b/backend/migrations/000056_make_article_embedding_dimension_dynamic.up.sql new file mode 100644 index 00000000..ad772cd4 --- /dev/null +++ b/backend/migrations/000056_make_article_embedding_dimension_dynamic.up.sql @@ -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; diff --git a/backend/pkg/response/error.go b/backend/pkg/response/error.go index 296d0481..1130930b 100644 --- a/backend/pkg/response/error.go +++ b/backend/pkg/response/error.go @@ -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 diff --git a/docs/plans/2026-07-12-copilot-configuration.md b/docs/plans/2026-07-12-copilot-configuration.md index e73828e0..bbaad341 100644 --- a/docs/plans/2026-07-12-copilot-configuration.md +++ b/docs/plans/2026-07-12-copilot-configuration.md @@ -1,7 +1,7 @@ # Copilot 配置中心实施计划 > 日期:2026-07-12 -> 状态:已实施(当前范围:对话 Provider 配置) +> 状态:已实施并验证(平台 Provider、账户模型/行为、Embedding 重建) > 菜单名称:`Copilot 配置` > 目标:为 GoChat 自托管部署提供可安全管理、可测试、可运行时生效的 Copilot/LLM 配置入口,并让页面选择的模型真正作用于 LLM 请求。 @@ -112,7 +112,7 @@ Provider 预设只负责填充默认 Endpoint,不锁死模型: | Base URL | `embedding.base_url` | URL | 自定义兼容 Provider 必填 | | API Key | `embedding.api_key` | secret | 与 Chat Key 分开存储;允许复用但不能在响应中回显 | | 模型 | `embedding.model` | string,默认 `text-embedding-3-small` | 必填;连接测试必须实际调用 embeddings API | -| 向量维度 | `embedding.dimensions` | integer,默认 `1536` | 范围 1-4096;修改后要求重新索引,不直接热切换现有向量数据 | +| 向量维度 | `embedding.dimensions` | integer,默认 `1536` | 范围 1-4096;修改时二次确认,并通过重建接口迁移现有文章向量 | Embedding 配置不能隐式依赖 Anthropic,因为 Anthropic 当前没有 Embeddings API。 @@ -204,6 +204,8 @@ Assistant 自身的 Temperature、Guardrails、Response Guidelines 优先级高 | GET | `/platform/api/v1/copilot/config` | 获取平台配置、掩码和状态 | | PUT | `/platform/api/v1/copilot/config` | 校验并保存平台配置 | | POST | `/platform/api/v1/copilot/config/test` | 用候选配置测试 Chat 和 Embedding,不保存 | +| GET | `/platform/api/v1/copilot/embeddings/reindex` | 获取文章 Embedding 重建进度 | +| POST | `/platform/api/v1/copilot/embeddings/reindex` | 后台启动文章 Embedding 重建 | 连接测试返回: @@ -229,7 +231,7 @@ Assistant 自身的 Temperature、Guardrails、Response Guidelines 优先级高 ## 10. 运行时 Provider 设计 -新增 `CopilotProviderManager`,替代 bootstrap 中固定注入单个 `llm.Provider`: +使用 `llm.ProviderManager` 替代 bootstrap 中固定注入单个 `llm.Provider`: ```text CopilotConfigService @@ -249,7 +251,8 @@ CopilotConfigService 5. 所有硬编码 `gpt-4`、静态模型和直接读取启动配置的调用都必须迁移到 resolver。 6. Provider 配置不完整时返回明确业务错误,不向外部服务发请求。 -Embedding 维度变化不自动替换在线索引;页面应先提示重新索引,完成后再切换。 +Embedding 维度由迁移 `000056_make_article_embedding_dimension_dynamic` 改为动态 pgvector +列;保存维度变更后由 SuperAdmin 通过重建接口重新生成全部文章向量,页面展示进度和失败状态。 --- @@ -302,47 +305,48 @@ Embedding 维度变化不自动替换在线索引;页面应先提示重新索 --- -## 13. 验证计划 +## 13. 验证结果 -### 后端 +### 后端自动化 -- 数据库配置读取、默认建议值和非法组合单元测试。 -- API Key 明文存储、API 掩码、保留、替换、清除测试。 -- SuperAdmin/Administrator/Agent 权限测试。 -- OpenAI-compatible 与 Anthropic 协议适配测试。 -- 使用本地 Fake LLM Server 验证 Chat、Streaming、Embedding、401、404、429、5xx、Timeout。 -- Provider 热切换测试:失败配置不替换旧 Provider,成功配置对新请求立即生效。 -- 检查所有 LLM Service 使用 resolver,不再硬编码模型。 +- `GOCHAT_TEST_DB=sqlite go test ./internal/... ./pkg/... ./cmd/...` 通过。 +- `go test ./...` 通过。 +- 覆盖明文存储、掩码/不回显、保留/替换/清除、候选测试不落库、Provider 热切换、 + OpenAI/OpenAI-compatible/Anthropic、超时/重试、统一安全错误、动态 Embedding 维度和重建状态。 +- SuperAdmin 平台权限、Administrator 账户权限和 Agent 禁止访问均有 Handler/Middleware 回归测试。 +- 配置变更写入 `audits`,只记录 Provider、模型、维度、配置状态和是否变更 Key,不记录 Key 或掩码。 -### 前端 +### 前端自动化 -- 菜单和路由权限测试。 -- API Key 不回填、掩码状态、清除确认测试。 -- Provider 条件字段和无效组合校验测试。 -- Account Administrator 只读平台配置测试。 -- 保存、测试连接、错误提示和配置不完整状态测试。 +- Vitest 覆盖菜单/路由、Pinia 平台与账户请求、SuperAdmin 识别、Provider 条件字段、 + API Key 不回填/清除确认、Anthropic 非法组合、维度确认、回复行为和自动回复确认。 +- `pnpm build` 通过。 -### 回归 +### 真实运行验证 -- `cd backend && GOCHAT_TEST_DB=sqlite go test ./internal/... ./pkg/... ./cmd/...` -- `cd frontend && pnpm build` -- 浏览器验证:SuperAdmin 配置 → 测试连接 → 保存 → Copilot 实际回复 → 请求使用所选模型。 -- 日志扫描确认不出现 API Key 或 Authorization Header。 +- Playwright 在 `http://127.0.0.1:3036/app/accounts/1/settings/copilot` 验证页面名称、路由、 + SuperAdmin 可编辑态、无框架错误覆盖,以及 1440×1000 和 390×844 布局。 +- 本地 Fake LLM Server 验证候选连接测试同时调用 Chat 与 Embedding;请求使用页面填写的 + `gpt-4o-mini`、`text-embedding-3-small`、Bearer Key 和 `dimensions: 1536`。 +- 候选测试前数据库无 Copilot Key;保存后 `COPILOT_CHAT_API_KEY` 明文为测试 Key;刷新页面后 + 输入框为空,仅显示掩码,再次测试使用已保存 Key 成功。 +- PostgreSQL 审计记录包含 Account、SuperAdmin、Provider/模型/维度和 Key 变更布尔值, + 不包含 API Key 或掩码。 --- ## 14. 验收标准 -1. 自托管 Community 安装可以进入“设置 → Copilot 配置”。 -2. 只有 SuperAdmin 可以修改平台 Provider 和 API Key。 -3. API Key 明文落库,所有读取接口只返回掩码/状态且日志不记录密钥。 -4. 页面可以独立测试 Chat 与 Embedding 连接。 -5. 保存有效配置后不重启即可让新 LLM 请求使用新 Provider。 -6. 页面选择的 editor/copilot/assistant 模型真实出现在对应 Provider 请求中。 -7. Anthropic Chat 可以搭配独立 OpenAI-compatible Embedding 配置。 -8. Provider 不可用或未配置时返回清晰错误,不出现空 Key 请求或模糊 500。 -9. 配置失败不会破坏当前正在工作的 Provider。 -10. 全部相关后端测试、前端构建和浏览器端到端验证通过。 +1. ✅ 自托管 Community 安装可以进入“设置 → Copilot 配置”。 +2. ✅ 只有 SuperAdmin 可以修改平台 Provider 和 API Key。 +3. ✅ API Key 明文落库,所有读取接口只返回掩码/状态且日志不记录密钥。 +4. ✅ 页面可以独立测试 Chat 与 Embedding 连接。 +5. ✅ 保存有效配置后不重启即可让新 LLM 请求使用新 Provider。 +6. ✅ editor/copilot/assistant/label suggestion 使用账户模型,未指定时回退平台默认模型。 +7. ✅ Anthropic Chat 可以搭配独立 OpenAI-compatible Embedding 配置。 +8. ✅ Provider 不可用或未配置时返回稳定错误码,不使用空 Key 请求外部服务。 +9. ✅ 候选测试和失败配置不会替换当前正在工作的 Provider。 +10. ✅ 相关后端测试、前端测试/构建和浏览器端到端验证通过。 --- diff --git a/docs/product/08-ai-roadmap.md b/docs/product/08-ai-roadmap.md index 5264da50..926d2c83 100644 --- a/docs/product/08-ai-roadmap.md +++ b/docs/product/08-ai-roadmap.md @@ -5,8 +5,8 @@ > 不提供总开关,不定义 Captain/Copilot 环境变量或 YAML Provider 配置;Provider、 > Base URL、API Key 与模型仅通过页面写入数据库。API Key 明文存储,API/UI 仅返回配置状态和掩码。 -> 调研日期:2026-07-08(初版)/ 2026-07-09 更新 -> 基于代码库:main 分支 @ 805402f +> 调研日期:2026-07-08(初版)/ 2026-07-12 更新 +> 基于代码库:2026-07-12 Copilot 配置中心实现状态 > 对标项目:Chatwoot Captain AI (enterprise edition) > 注:2026-07-09 状态更新 — Eino 框架已替换手写 LLM 层,多 LLM Provider(OpenAI/Anthropic)已接入, > Function Calling 已实现,Help Center pgvector 语义搜索已实现,AutoReplyRule 已集成到消息流程。 @@ -27,20 +27,21 @@ ## 1. 评估摘要 -GoChat 已搭建了一套对标 Chatwoot Captain AI 的完整基础设施,覆盖了 LLM Provider 抽象层、 -数据模型、Service 业务逻辑、Handler/路由、前端 UI 组件和后台 Worker。**约 90% 的 AI 代码 -已经写好**,但存在若干"最后一公里"接缝未缝合的问题,导致部分功能无法实际运行。 +GoChat 已搭建一套对标 Chatwoot Captain AI 的完整基础设施,覆盖 LLM Provider 抽象层、 +数据库驱动的 Copilot 配置中心、数据模型、Service、Handler/路由、前端 UI 和后台 Worker。 +Provider 配置与运行链路已闭环,保存后无需重启即可生效。 核心结论: -- **可用功能**:Copilot 侧边栏对话、回复建议、会话摘要、改写润色、标签建议、跟进任务、 - 会话洞察、文档同步、批量 AI 操作、助手 CRUD/Playground、帮助中心文章 AI 翻译 -- **代码就绪但未接入**:RAG 知识库问答(路由未注册)、自动回复规则(未接入消息流程)、 - CaptainConversationService(被 `_ =` 忽略) -- **完全缺失**:帮助中心语义搜索、AgentBot + Captain 端到端 AI 客服、多 LLM Provider 支持 -- **配置缺口**:缺少数据库驱动的 Copilot 配置页面,Provider/API Key 与实际运行链路尚未闭环 +- **已闭环**:Copilot 配置中心、OpenAI/Anthropic/OpenAI-compatible、Chat/Embedding 分离、 + 运行时热切换、账户功能模型、回复行为、连接测试、Embedding 重建和无密钥审计。 +- **可用功能**:Copilot 对话、回复建议、摘要、改写、标签/跟进、RAG、帮助中心语义搜索、 + 自动回复规则、Captain Conversation、AgentBot + Captain、Function Calling、文档同步和 AI 翻译。 +- **后续重点**:Token 用量/配额、调用级可观测性与质量评估、动态模型发现、多 Provider fallback、 + 独立语音转写 Provider。 -整体评估:基础设施成熟度高(9/10),功能可用度中等(5/10),需补齐配置与接缝工作。 +整体评估:基础设施成熟度高,核心功能已进入可配置、可验证、可热更新状态;后续工作以运营、 +成本、质量与容灾能力为主。 --- @@ -48,7 +49,8 @@ GoChat 已搭建了一套对标 Chatwoot Captain AI 的完整基础设施,覆 ### 2.1 LLM Provider 层 -**文件**:`backend/internal/llm/provider.go` + `openai_provider.go` +**文件**:`backend/internal/llm/provider.go`、`openai_provider.go`、`anthropic_provider.go`、 +`provider_manager.go` - `Provider` 接口定义三个核心能力: - `ChatCompletion` — 同步对话补全 @@ -61,7 +63,8 @@ GoChat 已搭建了一套对标 Chatwoot Captain AI 的完整基础设施,覆 - API 错误结构化解析(`APIError` 类型) - 数据结构:`ChatRequest`/`ChatResponse`/`ChatMessage`/`EmbeddingRequest`/`EmbeddingResponse`/ `StreamChunk`/`ToolDefinition`/`ToolFunction` -- **问题**:`ToolDefinition` 已定义但从未在调用时传入 LLM +- `ProviderManager` 原子替换 Chat/Embedding Provider Snapshot;账户模型按功能解析后进入请求。 +- `ToolExecutionService` 已将 Custom Tool 转为 `ToolDefinition`,执行 tool-call 循环并回传结果。 ### 2.2 数据模型层 @@ -152,7 +155,7 @@ bulk_actions POST | 组件/文件 | 路径 | 说明 | |-----------|------|------| -| Captain 设置页 | `routes/dashboard/settings/captain/Index.vue` | 模型选择 + 功能开关(label_suggestion/help_center_search/audio_transcription) | +| Copilot 配置页 | `routes/dashboard/settings/captain/Index.vue` | 平台 Provider、账户模型/功能、回复行为、连接测试与 Embedding 重建 | | ModelSelector | `routes/dashboard/settings/captain/components/ModelSelector.vue` | 按 feature 选择 LLM 模型 | | FeatureToggle | `routes/dashboard/settings/captain/components/FeatureToggle.vue` | AI 功能开关 | | CopilotContainer | `components/copilot/CopilotContainer.vue` | 侧边栏 Copilot 聊天面板 | @@ -160,7 +163,8 @@ bulk_actions POST | useCaptain | `composables/useCaptain.js` | Captain 功能开关/配额/错误处理 | | useCopilotReply | `composables/useCopilotReply.js` | 回复建议/改写/摘要的 composable | | useLabelSuggestions | `composables/useLabelSuggestions.js` | 标签建议 | -| API 客户端 | `api/captain/` | 12 个文件:assistant/document/tasks/copilotThreads/copilotMessages/preferences/scenarios/tools/bulkActions/inboxes/customTools/response | +| 配置 API 客户端 | `api/copilotConfig.js` | 平台配置/测试/重建与账户聚合配置 | +| Captain API 客户端 | `api/captain/` | Assistant、Document、Task、Copilot Thread/Message、Tool 等业务 API | **Feature Flags**(`featureFlags.js`): - `CAPTAIN` = `captain_integration` @@ -175,7 +179,7 @@ bulk_actions POST |--------|------|----------|------| | CaptainDocumentWorker | `captain_document_worker.go` | 6 种任务 | 文档同步/爬取/页面解析/embedding 更新/调度 | | CopilotResponseWorker | `copilot_response_worker.go` | 1 种任务 | 异步 Copilot 响应生成 | -| CaptainConversationWorker | `captain_conversation_service.go` 内 | 1 种任务 | 会话响应构建(但 service 被 `_ =` 忽略) | +| CaptainConversationWorker | `captain_conversation_service.go` 内 | 1 种任务 | 会话响应构建、tool-call 与 handoff;已接入 AgentBot Listener | ### 2.7 配置层 @@ -196,89 +200,77 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 | Rewrite(改写润色) | ✅ 完整 | 含流式,7 种操作 | | Label Suggestion(标签建议) | ✅ 完整 | 单会话 + 批量 | | Follow-up Task(跟进任务) | ✅ 完整 | 批量 | -| Knowledge Base / RAG(知识库问答) | ⚠️ 代码完整,路由未注册 | RAGService + RAGHandler 存在但未接入 | +| Knowledge Base / RAG(知识库问答) | ✅ 完整 | RAGService、Handler 和 `/captain/rag/*` 路由已注册 | | Document Sync(文档爬取同步) | ✅ 完整 | 6 种 Worker 任务 | -| Custom Tools(Function Calling 定义) | ✅ 模型+服务完整 | 但 LLM 调用时未传 tools 参数 | +| Custom Tools(Function Calling) | ✅ 完整 | ToolDefinition、HTTP 执行和 tool-call 循环已接入 | | Captain Preferences(AI 偏好) | ✅ 完整 | tone/language/auto_label/auto_reply | -| Auto-Reply Rules(自动回复规则) | ⚠️ 模型完整,未接入 | 模型+基础 service,无路由,无消息钩子 | +| Auto-Reply Rules(自动回复规则) | ✅ 完整 | CRUD、条件匹配、static/llm/mixed 和消息 Listener 已接入 | | Conversation Insight(会话洞察) | ✅ 完整 | 参与者分析/行动项/标签 | | Bulk Actions(批量 AI 操作) | ✅ 完整 | | -| Help Center 语义搜索 | ❌ 缺失 | ArticleEmbedding 表存在但无搜索方法 | -| AgentBot + Captain(端到端 AI 客服) | ❌ 缺失 | AgentBot 仅支持 webhook 类型 | -| Captain Conversation Auto-Response | ⚠️ 代码存在,被忽略 | bootstrap.go:594 `_ = captainConversationService` | +| Help Center 语义搜索 | ✅ 完整 | pgvector 搜索、文章向量生成和重建进度已接入 | +| AgentBot + Captain(端到端 AI 客服) | ✅ 完整 | `captain` BotType 路由到 CaptainConversationService | +| Captain Conversation Auto-Response | ✅ 完整 | Worker、Listener、tool-call 与 handoff 已接入 | | Article AI 翻译 | ✅ 完整 | LLMArticleTranslationBackend | -| Multi-Provider LLM | ❌ 仅 OpenAI | 无 Anthropic/国内模型 provider 实现 | +| Multi-Provider LLM | ✅ 完整 | OpenAI、Anthropic、OpenAI-compatible,支持自定义兼容端点 | | Token 用量统计与配额 | ❌ 缺失 | 前端有 captainLimits 结构,后端无统计 | -| AI 审计日志 | ❌ 缺失 | | +| AI 配置审计 | ✅ 完整 | Provider 配置变更审计不记录 Key/掩码;调用级审计仍属后续能力 | --- -## 4. 关键缺口分析 +## 4. 已完成闭环与剩余缺口 -### 缺口 G1:缺少数据库驱动的 Copilot 配置中心 +### 已完成 G1:数据库驱动的 Copilot 配置中心 -- **影响**:Provider/API Key 不能从页面配置,账户模型选择与实际运行 Provider 脱节 -- **位置**:前端 Captain 设置页、`installation_configs`、`bootstrap.go` 固定 Provider 注入 -- **修复成本**:中(配置 API、页面、Provider Manager 和模型解析) +- Provider/API Key 由“设置 → Copilot 配置”写入 `installation_configs`。 +- API Key 明文存储,但读取接口、页面和审计只暴露状态/掩码。 +- `ProviderManager` 热替换运行时 Provider;账户功能模型进入实际请求。 -### 缺口 G2:RAG 路由未注册 +### 已完成 G2:RAG 路由与运行服务 -- **影响**:知识库问答 API 无法访问 -- **位置**:`RAGHandler` 代码完整(`rag_handler.go`),但 `bootstrap.go` 未实例化 - `RAGService`/`RAGHandler`,`router.go` 未注册 `/captain/rag/*` 路由 -- **修复成本**:低(~20 行 bootstrap + router 代码) +- `RAGService`/`RAGHandler` 已实例化并注册 `/captain/rag/query` 与索引路由。 -### 缺口 G3:AutoReplyRule 未接入 +### 已完成 G3:AutoReplyRule 消息闭环 -- **影响**:无法实现"消息进来 → AI 自动回复" -- **位置**:`auto_reply_rule_models.go` 模型完整(static/llm/mixed),但: - - 无完整的条件匹配引擎 - - 无消息接收时的规则匹配钩子 - - 无路由注册 -- **修复成本**:中(需实现条件匹配 + 消息流程集成) +- 已实现规则 CRUD、条件匹配、static/llm/mixed 回复和消息 Listener。 -### 缺口 G4:CaptainConversationService 被忽略 +### 已完成 G4:CaptainConversationService 接入 -- **影响**:会话级 AI 自动响应(handoff 模式)不可用 -- **位置**:`bootstrap.go:594` `_ = captainConversationService` -- **修复成本**:中(需接入消息接收流程 + handoff 逻辑) +- 已注入 Handler、Worker、AgentBot Listener 和 ToolExecutionService,支持 handoff。 -### 缺口 G5:AgentBot 仅支持 Webhook +### 已完成 G5:AgentBot + Captain -- **影响**:无法实现"AgentBot 绑定 Captain Assistant → 端到端 AI 客服" -- **位置**:`agent_bot.go` 只有 webhook 推送模式 -- **修复成本**:中高(需扩展 AgentBot 类型 + 消息路由) +- `captain` BotType 已路由到 CaptainConversationService;Webhook Bot 行为保持兼容。 -### 缺口 G6:Help Center 语义搜索缺失 +### 已完成 G6:Help Center 语义搜索 -- **影响**:帮助中心文章无法语义搜索 -- **位置**:`ArticleEmbedding` 表存在,`article_service.go` 无搜索方法 -- **修复成本**:中(需实现 embedding 生成 + pgvector 搜索 + API + 前端) +- ArticleService 已生成查询/文章 Embedding 并通过 pgvector 搜索;维度可动态迁移并后台重建。 -### 缺口 G7:Function Calling 未实际使用 +### 已完成 G7:Function Calling -- **影响**:CustomTool 定义了但 LLM 调用时未传 tools 参数,AI 无法调用工具 -- **位置**:`llm/provider.go` 的 `ToolDefinition` 已定义; - `captain_task_service.go` / `captain_conversation_service.go` 的 `ChatRequest` 未设置 `Tools` 字段 -- **修复成本**:中(需实现 tool_call 循环 + HTTP 执行 + 结果回传) +- ToolExecutionService 已完成工具定义转换、HTTP 执行、结果回传和多轮 tool-call loop。 -### 缺口 G8:多 LLM Provider 支持 +### 已完成 G8:多 LLM Provider -- **影响**:仅支持 OpenAI 兼容 API,无法直接使用 Anthropic/本地模型 -- **位置**:`llm/` 下只有 `openai_provider.go` -- **修复成本**:中(每个 provider ~200 行实现 + 接口适配) +- OpenAI、Anthropic 和 OpenAI-compatible 均可通过数据库配置并热切换。 + +### 剩余缺口 + +- Token 用量、成本和账户配额。 +- AI 调用级审计、Langfuse/OpenTelemetry 观测和质量评估。 +- Provider 动态模型发现与缓存、多 Provider fallback。 +- 独立语音转写 Provider 和账户级配额策略。 --- ## 5. 开发路线图 -### 阶段 1:激活现有 AI 功能(P0,1-2 天) +### 阶段 1:激活现有 AI 功能(P0,已完成) **目标**:让已写好的 90% 代码真正跑起来,实现"配置即可用"。 **前提**:需要一个可用的 LLM API Key(OpenAI 或兼容端点)。 -#### 任务 1.1:通过页面配置 Copilot Provider +#### 任务 1.1:通过页面配置 Copilot Provider(✅ 已完成) 在“设置 → Copilot 配置”中保存 Provider、Base URL、API Key 和默认模型。 配置写入数据库并热替换运行时 Provider,不使用环境变量或 `config*.yaml`。 @@ -289,7 +281,7 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 **验收**:保存后无需重启,新请求立即使用页面配置的 Provider。 -#### 任务 1.2:注册 RAG 路由 +#### 任务 1.2:注册 RAG 路由(✅ 已完成) **文件修改**: @@ -311,18 +303,13 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 **验收**:`curl -X POST /api/v1/accounts/1/captain/rag/query -d '{"assistant_id":1,"question":"test"}'` 返回非 404。 -#### 任务 1.3:取消 CaptainConversationService 忽略 +#### 任务 1.3:接入 CaptainConversationService(✅ 已完成) -**文件**:`backend/internal/app/bootstrap.go:594` +服务已注入 Handler、Worker、AgentBot Listener 和 ToolExecutionService。 -将 `_ = captainConversationService` 改为注入到 Handlers 或消息处理流程。 +**验收**:会话级响应、tool-call 和 handoff 路径均有自动化覆盖。 -最小改动:将其传入 `CaptainAssistantHandler` 或新建一个 handler 方法, -供后续阶段 2 的 AgentBot 集成使用。 - -**验收**:编译通过,`captainConversationService` 不再被忽略。 - -#### 任务 1.4:端到端验证 +#### 任务 1.4:端到端验证(✅ 配置链路已完成) 配置真实 LLM API Key 后测试: 1. Playground 对话:`POST /captain/assistants/:id/playground` @@ -335,11 +322,11 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 --- -### 阶段 2:自动回复与端到端 AI 客服(P1,3-5 天) +### 阶段 2:自动回复与端到端 AI 客服(P1,核心链路已完成) **目标**:实现"客户消息进来 → AI 自动响应"的闭环。 -#### 任务 2.1:AutoReplyRule 完整接入 +#### 任务 2.1:AutoReplyRule 完整接入(✅ 已完成) **子任务**: @@ -377,7 +364,7 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 **验收**:创建一条 llm 模式规则 → 发送匹配消息 → AI 自动回复。 -#### 任务 2.2:AgentBot + Captain 集成 +#### 任务 2.2:AgentBot + Captain 集成(✅ 已完成) **子任务**: @@ -399,7 +386,7 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 **验收**:配置一个 captain 类型 AgentBot → 客户发消息 → AI 自动回复 → AI 判断需转人工时 handoff。 -#### 任务 2.3:CaptainConversationService 完整接入 +#### 任务 2.3:CaptainConversationService 完整接入(✅ 已完成) **子任务**: @@ -419,7 +406,7 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 ### 阶段 3:增强 AI 能力深度(P2,1-2 周) -#### 任务 3.1:Help Center 语义搜索 +#### 任务 3.1:Help Center 语义搜索(✅ 已完成) **子任务**: @@ -432,14 +419,14 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 **验收**:搜索"如何重置密码"能找到相关文章(即使标题不含"重置")。 -#### 任务 3.2:Function Calling 完整实现 +#### 任务 3.2:Function Calling 完整实现(✅ 已完成) 详见阶段 2 任务 2.3 的工具调用实现。此阶段将其推广到所有 AI 服务: - `CopilotService` — Copilot 对话中可调用工具 - `CaptainTaskService` — 回复建议时可调用 FAQ 查询工具 - `CaptainAssistantService.GenerateResponse` — Playground 对话中可调用工具 -#### 任务 3.3:多 LLM Provider 支持 +#### 任务 3.3:多 LLM Provider 支持(✅ 已完成) **子任务**: @@ -504,7 +491,7 @@ Captain/Copilot 模型字段,也不读取相关环境变量或 YAML 配置。 | 风险 | 影响 | 缓解措施 | |------|------|----------| | LLM API 调用超时/失败 | AI 功能不可用 | 已有重试机制(3 次指数退避);需加 fallback 策略(降级到静态回复) | -| pgvector 维度不匹配 | RAG 搜索失败 | 当前固定 1536 维(text-embedding-3-small);更换 embedding 模型时需迁移 | +| pgvector 维度不匹配 | RAG 搜索失败 | 使用动态维度迁移、保存确认和后台 Embedding 重建进度控制 | | Token 消耗成本 | 生产环境费用 | 阶段 4 实现配额限制;阶段 1-3 开发环境用 gpt-4o-mini 控制成本 | | SSE 连接稳定性 | 流式响应中断 | 已有 `X-Accel-Buffering: no`;需加心跳机制和断线重连 | | 并发 LLM 调用 | 速率限制 | 需实现请求队列 + 限流(令牌桶) | @@ -546,12 +533,12 @@ ModelSelector、FeatureToggle)。阶段 1-2 的前端改动极小,主要是 | `backend/internal/service/copilot_context_service.go` | 会话上下文组装 | | `backend/internal/service/rag_service.go` | RAG 全流程 | | `backend/internal/service/conversation_insight_service.go` | 会话洞察 | -| `backend/internal/service/captain_conversation_service.go` | 会话级 AI 响应(被忽略) | +| `backend/internal/service/captain_conversation_service.go` | 会话级 AI 响应、tool-call 与 handoff | | `backend/internal/service/captain_document_service.go` | 文档同步 | | `backend/internal/service/captain_document_worker.go` | 文档 Worker | | `backend/internal/service/copilot_response_worker.go` | Copilot 响应 Worker | | `backend/internal/service/system_prompt_builder.go` | Prompt 构建 | -| `backend/internal/handler/api/v1/rag_handler.go` | RAG Handler(未接入) | +| `backend/internal/handler/api/v1/rag_handler.go` | 已注册的 RAG Handler | | `backend/internal/handler/api/v1/sse_stream_handler.go` | SSE 流式 Handler | | `backend/internal/handler/api/v1/captain_assistant_handler.go` | 助手 Handler | | `backend/internal/handler/api/v1/captain_task_handler_test.go` | 任务 Handler 测试 | diff --git a/frontend/app/javascript/dashboard/api/copilotConfig.js b/frontend/app/javascript/dashboard/api/copilotConfig.js new file mode 100644 index 00000000..b1d9b61e --- /dev/null +++ b/frontend/app/javascript/dashboard/api/copilotConfig.js @@ -0,0 +1,38 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CopilotConfigAPI extends ApiClient { + constructor() { + super('copilot/config', { accountScoped: true }); + } + + getAccountConfig() { + return axios.get(this.url); + } + + updateAccountConfig(data) { + return axios.put(this.url, data); + } + + getPlatformConfig() { + return axios.get('/platform/api/v1/copilot/config'); + } + + updatePlatformConfig(data) { + return axios.put('/platform/api/v1/copilot/config', data); + } + + testPlatformConfig(data) { + return axios.post('/platform/api/v1/copilot/config/test', data); + } + + getEmbeddingReindexStatus() { + return axios.get('/platform/api/v1/copilot/embeddings/reindex'); + } + + startEmbeddingReindex() { + return axios.post('/platform/api/v1/copilot/embeddings/reindex'); + } +} + +export default new CopilotConfigAPI(); diff --git a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index fb8a229e..8b5197c8 100644 --- a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -646,10 +646,10 @@ const menuItems = computed(() => { to: accountScopedRoute('general_settings_index'), }, { - name: 'Settings Captain', + name: 'Settings Copilot', label: t('SIDEBAR.CAPTAIN_AI'), icon: 'i-woot-captain', - to: accountScopedRoute('captain_settings_index'), + to: accountScopedRoute('copilot_settings_index'), }, { name: 'Settings Agents', diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json index 18a790d3..93e9c7cd 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json @@ -387,63 +387,131 @@ }, "CAPTAIN_SETTINGS": { "TITLE": "Copilot Configuration", - "DESCRIPTION": "Configure the AI provider and models used by Copilot. Configuration is stored securely and takes effect without restarting the service.", - "LOADING": "Loading Captain configuration...", - "LINK_TEXT": "Learn more about Captain Credits", - "NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.", + "DESCRIPTION": "Configure the platform AI provider, account feature models, and response behavior. Copilot is always available, and saved provider changes take effect without a restart.", + "LOADING": "Loading Copilot configuration...", + "NOT_AVAILABLE": "Copilot configuration could not be loaded.", + "STATUS": { + "TITLE": "Runtime status", + "DESCRIPTION": "Review platform configuration completeness and the latest connection test.", + "CONFIGURATION": "Provider configuration", + "ALWAYS_ENABLED": "Copilot is globally enabled and cannot be disabled", + "APPLIED": "{provider} · {model}, applied at {time}", + "CONFIGURED": "Configured", + "INCOMPLETE": "Incomplete", + "CONNECTION": "Connection status", + "NOT_TESTED": "Connection has not been tested", + "CONNECTED": "Connected", + "ERROR": "Connection error", + "UNKNOWN": "Unknown" + }, "PROVIDER": { - "TITLE": "Provider Configuration", - "DESCRIPTION": "Configure the provider connection used by all Copilot and Captain AI requests.", + "TITLE": "Platform provider", + "DESCRIPTION": "Chat and embedding settings apply to the entire GoChat installation. Only a SuperAdmin can edit or test them.", + "READ_ONLY": "Account administrators can only view platform configuration status. Contact a SuperAdmin to edit the provider or run a connection test.", + "CHAT_TITLE": "Chat model", + "EMBEDDING_TITLE": "Embedding model", + "RELIABILITY_TITLE": "Generation and reliability", "PROVIDER": "Provider", "MODEL": "Default model", "BASE_URL": "Base URL", "API_KEY": "API key", "API_KEY_PLACEHOLDER": "Enter API key", "API_KEY_CONFIGURED": "Configured: {masked}. Leave blank to keep it unchanged.", - "API_KEY_HELP": "The API key is encrypted at rest and is never returned by the API.", - "SAVE": "Save provider configuration", + "API_KEY_MASKED": "saved", + "API_KEY_HELP": "The API key is stored as plaintext in the protected installation configuration table; the page and read APIs never return it.", + "CLEAR_CHAT_KEY": "Clear the saved Chat API key", + "CLEAR_EMBEDDING_KEY": "Clear the saved Embedding API key", + "CLEAR_KEY_CONFIRM": "Clearing an API key immediately makes the related provider configuration incomplete. Continue?", + "EMBEDDING_MODE": "Credential mode", + "REUSE_CHAT": "Reuse Chat credentials", + "SEPARATE_EMBEDDING": "Separate Embedding configuration", + "EMBEDDING_PROVIDER": "Embedding provider", + "EMBEDDING_BASE_URL": "Embedding Base URL", + "EMBEDDING_API_KEY": "Embedding API key", + "EMBEDDING_MODEL": "Embedding model", + "DIMENSIONS": "Vector dimensions", + "DIMENSIONS_CONFIRM": "Changing embedding dimensions requires rebuilding existing indexes or semantic search will stop working. Save the new dimensions?", + "ANTHROPIC_EMBEDDING_ERROR": "Anthropic does not provide an embeddings API. Select a separate OpenAI or OpenAI-compatible embedding provider.", + "MAX_TOKENS": "Maximum output tokens", + "TIMEOUT": "Timeout (seconds)", + "RETRIES": "Maximum retries", + "REINDEX_TITLE": "Knowledge base embedding rebuild", + "REINDEX_DESCRIPTION": "Rebuild all help center article vectors after changing the embedding model or dimensions.", + "REINDEX": "Start rebuild", + "REINDEXING": "Rebuilding...", + "REINDEX_CONFIRM": "Semantic search results may be incomplete during the rebuild. Rebuild embeddings for all articles now?", + "REINDEX_PROGRESS": "Processed {processed}/{total}, failed {failed}", + "TEST": "Test connection", + "TESTING": "Testing...", + "SAVE": "Save platform configuration", "SAVING": "Saving..." }, "MODEL_CONFIG": { - "TITLE": "Model Configuration", - "DESCRIPTION": "Select AI models for different features.", - "SELECT_MODEL": "Select model", - "CREDITS_PER_MESSAGE": "{credits} credit/message", - "COMING_SOON": "Coming soon", + "TITLE": "Current account models", + "DESCRIPTION": "Assign model IDs to individual features. Keep the platform default or enter any provider-compatible model ID.", + "MODEL_ID_PLACEHOLDER": "Enter a model ID", + "APPLY": "Apply", "EDITOR": { - "TITLE": "Editor Features", - "DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor." + "TITLE": "Editor rewriting", + "DESCRIPTION": "Powers smart compose, grammar correction, tone adjustment, and content enhancement." }, "ASSISTANT": { "TITLE": "Assistant", - "DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions." + "DESCRIPTION": "Used by Assistant Playground, automated assistance, and knowledge-grounded answers." }, "COPILOT": { - "TITLE": "Co-pilot", - "DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations." + "TITLE": "Copilot conversation", + "DESCRIPTION": "Used for sidebar conversations, reply suggestions, summaries, and translations." } }, "FEATURES": { - "TITLE": "Features", - "DESCRIPTION": "Enable or disable AI-powered features.", - "AUDIO_TRANSCRIPTION": { - "TITLE": "Audio Transcription", - "DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts." - }, + "TITLE": "Current account features", + "DESCRIPTION": "Control specific AI capabilities for this account. These switches do not disable system-wide Copilot.", "HELP_CENTER_SEARCH": { - "TITLE": "Help Center Search Indexing", - "DESCRIPTION": "Use AI for context aware search inside your help center articles." + "TITLE": "Knowledge base semantic search", + "DESCRIPTION": "Use the platform embedding model for semantic search across help center articles." }, "LABEL_SUGGESTION": { - "TITLE": "Label Suggestion", - "DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.", - "MODEL_TITLE": "Label Suggestion Model", - "MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels" + "TITLE": "Label suggestions", + "DESCRIPTION": "Suggest relevant labels from conversation context. Automatic application is controlled below.", + "MODEL_TITLE": "Label suggestion model", + "MODEL_DESCRIPTION": "Optionally use a compatible model ID different from the account default." } }, + "BEHAVIOR": { + "TITLE": "Response behavior", + "DESCRIPTION": "Set account defaults for tone, language, length, and automation. Assistant-specific settings take priority.", + "TONE": "Response tone", + "TONES": { + "PROFESSIONAL": "Professional", + "FRIENDLY": "Friendly", + "CASUAL": "Casual", + "FORMAL": "Formal" + }, + "LANGUAGE": "Response language", + "LANGUAGE_PLACEHOLDER": "auto or a language code", + "MAX_LENGTH": "Maximum response length", + "CUSTOM_PROMPT": "Additional instructions", + "CUSTOM_PROMPT_PLACEHOLDER": "For example: prioritize actionable next steps.", + "CUSTOM_PROMPT_HELP": "Appended to this account's default prompt, up to 1,000 characters.", + "AUTO_LABEL": "Automatically apply labels", + "AUTO_LABEL_HELP": "Apply label suggestion results automatically.", + "AUTO_FOLLOW_UP": "Automatically create follow-ups", + "AUTO_FOLLOW_UP_HELP": "Create follow-up tasks from AI suggestions.", + "AUTO_REPLY": "Automatically reply to customers", + "AUTO_REPLY_HELP": "High-risk capability; verify the Assistant and Inbox binding before enabling it.", + "AUTO_REPLY_CONFIRM": "Automatic replies send AI content directly to customers. Confirm that the Assistant, knowledge base, guardrails, and Inbox binding are configured. Enable it anyway?", + "SAVE": "Save response behavior", + "SAVING": "Saving..." + }, "API": { - "SUCCESS": "Captain settings updated successfully.", - "ERROR": "Failed to update Captain settings. Please try again." + "ACCOUNT_SUCCESS": "Account Copilot configuration updated.", + "PROVIDER_SUCCESS": "Platform provider configuration saved and applied immediately.", + "TEST_SUCCESS": "Chat and Embedding connection tests passed.", + "TEST_ERROR": "The connection test completed, but at least one check failed.", + "REINDEX_STARTED": "Knowledge base embedding rebuild started.", + "REINDEX_COMPLETE": "Knowledge base embedding rebuild completed.", + "ERROR": "Failed to update Copilot configuration. Please try again." } }, "BILLING_SETTINGS": { diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json index b1f26bcd..4f222160 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json @@ -387,63 +387,131 @@ }, "CAPTAIN_SETTINGS": { "TITLE": "Copilot 配置", - "DESCRIPTION": "配置 Copilot 使用的 AI 服务商和模型。保存后立即生效,无需重启服务。", + "DESCRIPTION": "配置平台 AI 服务商、账户功能模型与回复行为。Copilot 在系统中始终可用,Provider 配置保存后无需重启即可生效。", "LOADING": "正在加载 Copilot 配置...", - "LINK_TEXT": "了解 Copilot", - "NOT_ENABLED": "Copilot 配置暂不可用。", + "NOT_AVAILABLE": "无法加载 Copilot 配置。", + "STATUS": { + "TITLE": "运行状态", + "DESCRIPTION": "查看平台配置完整性和最近一次连接测试结果。", + "CONFIGURATION": "Provider 配置", + "ALWAYS_ENABLED": "Copilot 全局启用且不可关闭", + "APPLIED": "{provider} · {model},生效于 {time}", + "CONFIGURED": "配置完整", + "INCOMPLETE": "配置不完整", + "CONNECTION": "连接状态", + "NOT_TESTED": "尚未测试连接", + "CONNECTED": "连接正常", + "ERROR": "连接异常", + "UNKNOWN": "未知" + }, "PROVIDER": { - "TITLE": "服务商配置", - "DESCRIPTION": "配置所有 Copilot 与 Captain AI 请求使用的模型服务。", + "TITLE": "平台 Provider", + "DESCRIPTION": "Chat 与 Embedding 配置对整套 GoChat 安装生效。只有 SuperAdmin 可以修改和测试。", + "READ_ONLY": "当前账户管理员只能查看平台配置状态。请联系 SuperAdmin 修改 Provider 或执行连接测试。", + "CHAT_TITLE": "对话模型", + "EMBEDDING_TITLE": "Embedding 模型", + "RELIABILITY_TITLE": "生成与可靠性", "PROVIDER": "服务商", "MODEL": "默认模型", "BASE_URL": "接口地址", "API_KEY": "API Key", "API_KEY_PLACEHOLDER": "请输入 API Key", "API_KEY_CONFIGURED": "已配置:{masked}。留空表示保持不变。", - "API_KEY_HELP": "API Key 加密保存,接口不会返回明文。", - "SAVE": "保存服务商配置", + "API_KEY_MASKED": "已保存", + "API_KEY_HELP": "API Key 明文写入受保护的安装配置表;页面和读取接口永不返回明文。", + "CLEAR_CHAT_KEY": "清除已保存的 Chat API Key", + "CLEAR_EMBEDDING_KEY": "清除已保存的 Embedding API Key", + "CLEAR_KEY_CONFIRM": "清除 API Key 会让相关 Provider 立即变为配置不完整。确认继续吗?", + "EMBEDDING_MODE": "凭据模式", + "REUSE_CHAT": "复用 Chat 凭据", + "SEPARATE_EMBEDDING": "独立 Embedding 配置", + "EMBEDDING_PROVIDER": "Embedding 服务商", + "EMBEDDING_BASE_URL": "Embedding 接口地址", + "EMBEDDING_API_KEY": "Embedding API Key", + "EMBEDDING_MODEL": "Embedding 模型", + "DIMENSIONS": "向量维度", + "DIMENSIONS_CONFIRM": "修改 Embedding 向量维度后必须重建已有索引,否则语义搜索不可用。确认保存新维度吗?", + "ANTHROPIC_EMBEDDING_ERROR": "Anthropic 不提供 Embedding API。请选择独立 Embedding 配置并使用 OpenAI 或 OpenAI Compatible 服务商。", + "MAX_TOKENS": "最大输出 Tokens", + "TIMEOUT": "超时时间(秒)", + "RETRIES": "最大重试次数", + "REINDEX_TITLE": "知识库 Embedding 重建", + "REINDEX_DESCRIPTION": "在修改 Embedding 模型或向量维度后,重建所有帮助中心文章向量。", + "REINDEX": "开始重建", + "REINDEXING": "重建中...", + "REINDEX_CONFIRM": "重建期间知识库语义搜索结果可能暂时不完整。确认开始重建全部文章 Embedding 吗?", + "REINDEX_PROGRESS": "已处理 {processed}/{total},失败 {failed}", + "TEST": "测试连接", + "TESTING": "测试中...", + "SAVE": "保存平台配置", "SAVING": "保存中..." }, "MODEL_CONFIG": { - "TITLE": "Model Configuration", - "DESCRIPTION": "Select AI models for different features.", - "SELECT_MODEL": "Select model", - "CREDITS_PER_MESSAGE": "{credits} credit/message", - "COMING_SOON": "Coming soon", + "TITLE": "当前账户模型", + "DESCRIPTION": "为各项功能指定模型 ID;留在平台默认值即可跟随 Provider 默认模型。支持输入任意兼容模型 ID。", + "MODEL_ID_PLACEHOLDER": "输入模型 ID", + "APPLY": "应用", "EDITOR": { - "TITLE": "Editor Features", - "DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor." + "TITLE": "编辑器改写", + "DESCRIPTION": "用于智能撰写、语法修正、语气调整和内容润色。" }, "ASSISTANT": { "TITLE": "助手", - "DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions." + "DESCRIPTION": "用于 Assistant Playground、自动辅助和知识库回答。" }, "COPILOT": { - "TITLE": "Co-pilot", - "DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations." + "TITLE": "Copilot 对话", + "DESCRIPTION": "用于客服侧边栏问答、回复建议、摘要和翻译。" } }, "FEATURES": { - "TITLE": "特性", - "DESCRIPTION": "Enable or disable AI-powered features.", - "AUDIO_TRANSCRIPTION": { - "TITLE": "Audio Transcription", - "DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts." - }, + "TITLE": "当前账户功能", + "DESCRIPTION": "控制当前账户的具体 AI 能力;这些开关不会关闭系统级 Copilot。", "HELP_CENTER_SEARCH": { - "TITLE": "Help Center Search Indexing", - "DESCRIPTION": "Use AI for context aware search inside your help center articles." + "TITLE": "知识库语义搜索", + "DESCRIPTION": "使用平台 Embedding 模型对帮助中心文章执行语义搜索。" }, "LABEL_SUGGESTION": { - "TITLE": "Label Suggestion", - "DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.", - "MODEL_TITLE": "Label Suggestion Model", - "MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels" + "TITLE": "标签建议", + "DESCRIPTION": "根据会话内容建议相关标签;是否自动应用由下方回复行为控制。", + "MODEL_TITLE": "标签建议模型", + "MODEL_DESCRIPTION": "可指定不同于账户默认值的兼容模型 ID。" } }, + "BEHAVIOR": { + "TITLE": "回复行为", + "DESCRIPTION": "设置当前账户的默认语气、语言、长度和自动化策略。Assistant 自身配置优先于这些默认值。", + "TONE": "回复语气", + "TONES": { + "PROFESSIONAL": "专业", + "FRIENDLY": "友好", + "CASUAL": "轻松", + "FORMAL": "正式" + }, + "LANGUAGE": "回复语言", + "LANGUAGE_PLACEHOLDER": "auto 或语言代码", + "MAX_LENGTH": "最大回复长度", + "CUSTOM_PROMPT": "附加指令", + "CUSTOM_PROMPT_PLACEHOLDER": "例如:回复中优先给出可执行步骤。", + "CUSTOM_PROMPT_HELP": "附加到当前账户的默认提示词,最多 1000 字。", + "AUTO_LABEL": "自动应用标签", + "AUTO_LABEL_HELP": "自动应用标签建议结果。", + "AUTO_FOLLOW_UP": "自动创建跟进", + "AUTO_FOLLOW_UP_HELP": "根据 AI 建议创建跟进任务。", + "AUTO_REPLY": "自动回复客户", + "AUTO_REPLY_HELP": "高风险能力;启用前请确认 Assistant 与 Inbox 已正确绑定。", + "AUTO_REPLY_CONFIRM": "自动回复会直接向客户发送 AI 内容。请确认 Assistant、知识库、Guardrails 和 Inbox 绑定均已配置,仍要启用吗?", + "SAVE": "保存回复行为", + "SAVING": "保存中..." + }, "API": { - "SUCCESS": "Captain settings updated successfully.", - "ERROR": "Failed to update Captain settings. Please try again." + "ACCOUNT_SUCCESS": "账户 Copilot 配置已更新。", + "PROVIDER_SUCCESS": "平台 Provider 配置已保存并立即生效。", + "TEST_SUCCESS": "Chat 与 Embedding 连接测试通过。", + "TEST_ERROR": "连接测试已完成,但至少有一项失败。", + "REINDEX_STARTED": "知识库 Embedding 重建已开始。", + "REINDEX_COMPLETE": "知识库 Embedding 重建已完成。", + "ERROR": "Copilot 配置更新失败,请重试。" } }, "BILLING_SETTINGS": { diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue index d74f5d50..5942453c 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue @@ -1,8 +1,9 @@ diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js index 1a5bb949..f571b5e0 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js @@ -3,28 +3,28 @@ import SettingsWrapper from '../SettingsWrapper.vue'; import Index from './Index.vue'; export default { - routes: [ - { - path: frontendURL('accounts/:accountId/settings/captain'), - meta: { - permissions: ['administrator'], - }, - component: SettingsWrapper, - props: { - headerTitle: 'CAPTAIN_SETTINGS.TITLE', - icon: 'i-lucide-bot', - showNewButton: false, - }, - children: [ - { - path: '', - name: 'captain_settings_index', - component: Index, - meta: { - permissions: ['administrator'], - }, - }, - ], - }, - ], + routes: [ + { + path: frontendURL('accounts/:accountId/settings/copilot'), + meta: { + permissions: ['administrator'], + }, + component: SettingsWrapper, + props: { + headerTitle: 'CAPTAIN_SETTINGS.TITLE', + icon: 'i-lucide-bot', + showNewButton: false, + }, + children: [ + { + path: '', + name: 'copilot_settings_index', + component: Index, + meta: { + permissions: ['administrator'], + }, + }, + ], + }, + ], }; diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.spec.js new file mode 100644 index 00000000..5d5ab931 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.spec.js @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; + +describe('Copilot settings route', () => { + it('uses the Copilot path and administrator permission without install gating', () => { + const source = readFileSync( + 'app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js', + 'utf8' + ); + + expect(source).toContain('settings/copilot'); + expect(source).toContain("name: 'copilot_settings_index'"); + expect(source).toContain("permissions: ['administrator']"); + expect(source).not.toContain('featureFlag'); + expect(source).not.toContain('installationTypes'); + }); +}); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.spec.js new file mode 100644 index 00000000..79ce90b3 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.spec.js @@ -0,0 +1,45 @@ +import { mount } from '@vue/test-utils'; +import BehaviorConfiguration from './BehaviorConfiguration.vue'; + +describe('BehaviorConfiguration', () => { + it('requires confirmation before enabling automatic replies', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + const wrapper = mount(BehaviorConfiguration, { + props: { + behavior: { + tone: 'professional', + language: 'auto', + max_response_length: 500, + auto_reply_enabled: false, + }, + }, + }); + + await wrapper.get('[data-test-id="auto-reply"]').setValue(true); + await wrapper.get('[data-test-id="save-behavior"]').trigger('click'); + + expect(confirm).toHaveBeenCalledOnce(); + expect(wrapper.emitted('save')).toBeUndefined(); + confirm.mockRestore(); + }); + + it('emits the complete behavior payload after confirmation', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + const wrapper = mount(BehaviorConfiguration, { + props: { behavior: { max_response_length: 500 } }, + }); + + await wrapper.get('[data-test-id="auto-reply"]').setValue(true); + await wrapper.get('[data-test-id="save-behavior"]').trigger('click'); + + expect(wrapper.emitted('save')[0][0]).toEqual( + expect.objectContaining({ + tone: 'professional', + language: 'auto', + max_response_length: 500, + auto_reply_enabled: true, + }) + ); + confirm.mockRestore(); + }); +}); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.vue new file mode 100644 index 00000000..5528e737 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/BehaviorConfiguration.vue @@ -0,0 +1,206 @@ + + +