feat(copilot): finish configuration center

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 0a69d80f7c
commit 8b9eedc0e2
60 changed files with 3040 additions and 1042 deletions
@@ -146,6 +146,28 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
assertChatwootAuthUserFixture(t, data)
}
func TestChatwootAuthValidateTokenSerializesPlatformAdminType(t *testing.T) {
router, db, user := setupChatwootAuthTest(t)
require.NoError(t, db.Model(user).Updates(map[string]any{
"role": "super_admin",
"type": "User",
}).Error)
token := signInAndReturnAccessToken(t, router)
req, _ := http.NewRequest(http.MethodGet, "/auth/validate_token", nil)
req.Header.Set("access-token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
data := body["payload"].(map[string]any)["data"].(map[string]any)
require.Equal(t, "SuperAdmin", data["type"])
require.Equal(t, "administrator", data["role"])
}
func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
router, _, _ := setupChatwootAuthTest(t)
token := signInAndReturnAccessToken(t, router)
@@ -78,7 +78,7 @@ func (s *AutoReplyRuleHandlerTestSuite) SetupSuite() {
s.router = r
// Register routes matching the handler's expected URL patterns
accountsGroup := r.Group("/api/v1/accounts/:id/captain")
accountsGroup := r.Group("/api/v1/accounts/:account_id/captain")
{
accountsGroup.POST("/assistants/:assistant_id/auto_reply_rules", s.handler.Create)
accountsGroup.GET("/auto_reply_rules/:rule_id", s.handler.Get)
@@ -493,11 +493,8 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
}
s.Require().NoError(s.db.Create(rule).Error)
// Note: The handler sets accountID from path param but assigns to _ (not used in evalCtx).
// AutoReplyEvaluationContext.AccountID has no json tag, so it stays 0 from JSON binding.
// The service EvaluateRules will use AccountID=0 for FindActiveByInbox.
// This means even with a valid account in the path, the evaluate endpoint currently
// searches by account_id=0. We test the handler behavior as-is.
// The account path parameter is authoritative and is copied into the evaluation
// context before the service searches active rules.
body := `{"message_content": "This is an urgent matter", "sender_type": "contact", "conversation_status": "open"}`
w := s.doRequest(http.MethodPost,
fmt.Sprintf("/api/v1/accounts/%d/captain/auto_reply_rules/evaluate", account.ID),
@@ -511,8 +508,7 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_Success() {
dataMap, ok := resp.Data.(map[string]interface{})
s.Require().True(ok)
// ShouldReply will be false because AccountID=0 in evalCtx doesn't match our rule's account_id
assert.Equal(s.T(), false, dataMap["should_reply"])
assert.Equal(s.T(), true, dataMap["should_reply"])
}
func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_InvalidJSON() {
@@ -555,4 +551,4 @@ func (s *AutoReplyRuleHandlerTestSuite) TestEvaluate_NoActiveRules() {
func TestAutoReplyRuleHandlerTestSuite(t *testing.T) {
suite.Run(t, new(AutoReplyRuleHandlerTestSuite))
}
}
@@ -175,3 +175,29 @@ func TestCaptainPreferencesInvalidAccountID(t *testing.T) {
w := f.request(http.MethodGet, "/api/v1/accounts/abc/captain/preferences", nil)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
}
func TestCaptainPreferencesUpdateAndClearBehavior(t *testing.T) {
f := newCaptainPreferenceFixture(t)
w := f.request(http.MethodPut, f.path(""), map[string]any{
"behavior": map[string]any{
"tone": "friendly",
"language": "auto",
"max_response_length": 750,
"custom_prompt_suffix": "Use short steps",
"auto_label_enabled": true,
"auto_follow_up_enabled": true,
"auto_reply_enabled": false,
},
})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
behavior := decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
require.Equal(t, "Use short steps", behavior["custom_prompt_suffix"])
w = f.request(http.MethodPut, f.path(""), map[string]any{
"behavior": map[string]any{"custom_prompt_suffix": ""},
})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
behavior = decodeCaptainPreferencePayload(t, w)["behavior"].(map[string]any)
require.Equal(t, "", behavior["custom_prompt_suffix"])
}
@@ -1,12 +1,15 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
@@ -16,12 +19,24 @@ import (
type CopilotConfigHandler struct {
platform *service.CopilotConfigService
account *service.CaptainPreferenceService
audit *service.AuditService
articles *service.ArticleService
}
func NewCopilotConfigHandler(platform *service.CopilotConfigService, account *service.CaptainPreferenceService) *CopilotConfigHandler {
return &CopilotConfigHandler{platform: platform, account: account}
}
func (h *CopilotConfigHandler) WithAuditService(audit *service.AuditService) *CopilotConfigHandler {
h.audit = audit
return h
}
func (h *CopilotConfigHandler) WithArticleService(articles *service.ArticleService) *CopilotConfigHandler {
h.articles = articles
return h
}
func (h *CopilotConfigHandler) PlatformGet(c *gin.Context) {
payload, err := h.platform.Get(c.Request.Context())
if err != nil {
@@ -42,9 +57,62 @@ func (h *CopilotConfigHandler) PlatformUpdate(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
h.recordPlatformUpdate(c, input, payload)
c.JSON(http.StatusOK, payload)
}
func (h *CopilotConfigHandler) recordPlatformUpdate(c *gin.Context, input service.CopilotProviderConfigInput, payload *service.CopilotProviderConfigPayload) {
if h.audit == nil || payload == nil {
return
}
changes := map[string]any{
"chat": map[string]any{
"provider": payload.Chat.Provider,
"base_url": payload.Chat.BaseURL,
"model": payload.Chat.Model,
"api_key_configured": payload.Chat.APIKey.Configured,
"api_key_changed": input.Chat.APIKey != "" || input.Chat.ClearAPIKey,
},
"embedding": map[string]any{
"mode": payload.Embedding.Mode,
"provider": payload.Embedding.Provider,
"base_url": payload.Embedding.BaseURL,
"model": payload.Embedding.Model,
"dimensions": payload.Embedding.Dimensions,
"api_key_configured": payload.Embedding.APIKey.Configured,
"api_key_changed": input.Embedding.APIKey != "" || input.Embedding.ClearAPIKey,
},
"generation": payload.Generation,
"request": payload.Request,
"configured": payload.Configured,
}
raw, err := json.Marshal(changes)
if err != nil {
return
}
audit := &model.Audit{
AuditableType: "InstallationConfig",
AuditableID: 1,
Action: "update",
AuditedChanges: raw,
RemoteAddress: c.ClientIP(),
RequestUUID: firstNonEmpty(c.GetHeader("X-Request-ID"), c.GetHeader("X-Correlation-ID")),
Comment: "Copilot provider configuration updated",
}
if accountID := c.GetUint("account_id"); accountID != 0 {
audit.AccountID = &accountID
audit.AssociatedType = "Account"
audit.AssociatedID = &accountID
}
if userID := getUserID(c); userID != 0 {
audit.UserID = &userID
audit.UserType = "SuperAdmin"
}
if _, err := h.audit.CreateAudit(c.Request.Context(), audit); err != nil {
applogger.L().Warnf("Copilot provider audit skipped: %v", err)
}
}
func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
var input service.CopilotProviderConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
@@ -55,7 +123,8 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
if err != nil {
status := http.StatusUnprocessableEntity
if errors.Is(err, llm.ErrProviderNotConfigured) {
status = http.StatusConflict
response.AbortWithStatusError(c, http.StatusConflict, response.ErrCopilotNotConfigured, err.Error())
return
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -63,6 +132,31 @@ func (h *CopilotConfigHandler) PlatformTest(c *gin.Context) {
c.JSON(http.StatusOK, payload)
}
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStatus(c *gin.Context) {
if h.articles == nil {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
return
}
c.JSON(http.StatusOK, h.articles.EmbeddingReindexStatus())
}
func (h *CopilotConfigHandler) PlatformEmbeddingReindexStart(c *gin.Context) {
if h.articles == nil {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrServiceUnavail, "embedding reindex is not configured")
return
}
status, err := h.articles.StartEmbeddingReindex()
if err != nil {
code := http.StatusUnprocessableEntity
if status.Running {
code = http.StatusConflict
}
c.JSON(code, gin.H{"error": err.Error(), "status": status})
return
}
c.JSON(http.StatusAccepted, status)
}
func (h *CopilotConfigHandler) AccountGet(c *gin.Context) {
if !captainPreferencesCanUpdate(c) {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "administrator role required")
@@ -0,0 +1,197 @@
package v1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type copilotConfigHandlerFixture struct {
db *gorm.DB
router *gin.Engine
account *model.Account
}
func newCopilotConfigHandlerFixture(t *testing.T) *copilotConfigHandlerFixture {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{}, &model.Audit{}))
account := &model.Account{Name: "Copilot Config", Active: true}
require.NoError(t, db.Create(account).Error)
manager := llm.NewProviderManager()
platformService := service.NewCopilotConfigService(repository.NewInstallationConfigRepo(db), manager)
preferenceService := service.NewCaptainPreferenceService(repository.NewCaptainPreferenceRepo(db), repository.NewAccountRepo(db))
preferenceService.SetCopilotConfigService(platformService)
handler := NewCopilotConfigHandler(platformService, preferenceService).
WithAuditService(service.NewAuditService(repository.NewAuditRepo(db)))
router := gin.New()
superAdmin := router.Group("/platform/api/v1/copilot", func(c *gin.Context) {
c.Set("user_type", "super_admin")
c.Set("account_id", account.ID)
c.Next()
}, middleware.SuperAdmin())
superAdmin.GET("/config", handler.PlatformGet)
superAdmin.PUT("/config", handler.PlatformUpdate)
superAdmin.POST("/config/test", handler.PlatformTest)
accountAdmin := router.Group("/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "administrator")
c.Next()
})
accountAdmin.GET("", handler.AccountGet)
accountAdmin.PUT("", handler.AccountUpdate)
router.PUT("/forbidden/platform/api/v1/copilot/config", func(c *gin.Context) {
c.Set("user_type", "user")
c.Next()
}, middleware.SuperAdmin(), handler.PlatformUpdate)
router.GET("/forbidden/api/v1/accounts/:account_id/copilot/config", func(c *gin.Context) {
c.Set("role", "agent")
c.Next()
}, handler.AccountGet)
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
require.NoError(t, dbErr)
require.NoError(t, sqlDB.Close())
})
return &copilotConfigHandlerFixture{db: db, router: router, account: account}
}
func (f *copilotConfigHandlerFixture) request(method, path string, body any) *httptest.ResponseRecorder {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
recorder := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, bytes.NewReader(raw))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
f.router.ServeHTTP(recorder, req)
return recorder
}
func copilotConfigRequest(baseURL, apiKey string) map[string]any {
return map[string]any{
"chat": map[string]any{
"provider": "openai_compatible",
"base_url": baseURL,
"model": "chat-model",
"api_key": apiKey,
},
"embedding": map[string]any{
"mode": "reuse_chat_credentials",
"provider": "openai_compatible",
"base_url": baseURL,
"model": "embedding-model",
"dimensions": 3,
},
"generation": map[string]any{"temperature": 0.2, "max_tokens": 512},
"request": map[string]any{"timeout_seconds": 10, "max_retries": 0},
}
}
func TestCopilotConfigHandlerPlatformPermissionsAndSecretPresentation(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
input := copilotConfigRequest("https://llm.example.com/v1", "plain-secret-key")
forbidden := f.request(http.MethodPut, "/forbidden/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusForbidden, forbidden.Code, forbidden.Body.String())
updated := f.request(http.MethodPut, "/platform/api/v1/copilot/config", input)
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
require.NotContains(t, updated.Body.String(), "plain-secret-key")
require.Contains(t, updated.Body.String(), "pla****-key")
var stored model.InstallationConfig
require.NoError(t, f.db.Where("name = ?", "COPILOT_CHAT_API_KEY").First(&stored).Error)
require.Equal(t, "plain-secret-key", stored.Value)
var audit model.Audit
require.NoError(t, f.db.Where("auditable_type = ?", "InstallationConfig").First(&audit).Error)
require.NotContains(t, string(audit.AuditedChanges), "plain-secret-key")
require.NotContains(t, string(audit.AuditedChanges), "pla****-key")
require.Contains(t, string(audit.AuditedChanges), `"api_key_changed":true`)
require.NotNil(t, audit.AccountID)
require.Equal(t, f.account.ID, *audit.AccountID)
require.Equal(t, "Account", audit.AssociatedType)
accountPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(f.account.ID), 10) + "/copilot/config"
accountPayload := f.request(http.MethodGet, accountPath, nil)
require.Equal(t, http.StatusOK, accountPayload.Code, accountPayload.Body.String())
require.NotContains(t, accountPayload.Body.String(), "plain-secret-key")
require.NotContains(t, accountPayload.Body.String(), "pla****-key")
require.Contains(t, accountPayload.Body.String(), `"configured":true`)
agentPayload := f.request(http.MethodGet, "/forbidden"+accountPath, nil)
require.Equal(t, http.StatusForbidden, agentPayload.Code, agentPayload.Body.String())
}
func TestCopilotConfigHandlerTestEndpointDoesNotPersistCandidate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/chat/completions":
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`))
case "/embeddings":
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embedding-model"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
f := newCopilotConfigHandlerFixture(t)
tested := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", copilotConfigRequest(server.URL, "candidate-key"))
require.Equal(t, http.StatusOK, tested.Code, tested.Body.String())
require.Contains(t, tested.Body.String(), `"ok":true`)
require.NotContains(t, tested.Body.String(), "candidate-key")
current := f.request(http.MethodGet, "/platform/api/v1/copilot/config", nil)
require.Equal(t, http.StatusOK, current.Code, current.Body.String())
require.Contains(t, current.Body.String(), `"configured":false`)
}
func TestCopilotConfigHandlerUsesStandardNotConfiguredError(t *testing.T) {
f := newCopilotConfigHandlerFixture(t)
response := f.request(http.MethodPost, "/platform/api/v1/copilot/config/test", map[string]any{})
require.Equal(t, http.StatusConflict, response.Code, response.Body.String())
require.Contains(t, response.Body.String(), "COPILOT_NOT_CONFIGURED")
}
func TestCopilotProviderErrorsAreSanitized(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/provider-error", func(c *gin.Context) {
handleServiceError(c, fmt.Errorf("provider failed: %w", &llm.APIError{
StatusCode: http.StatusUnauthorized,
Message: "invalid key sk-secret-value",
}))
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/provider-error", nil)
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusBadGateway, recorder.Code)
require.Contains(t, recorder.Body.String(), "COPILOT_PROVIDER_AUTHENTICATION_FAILED")
require.NotContains(t, recorder.Body.String(), "sk-secret-value")
}
@@ -88,7 +88,7 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
return
}
if h.llmProvider == nil {
writeSSEMessage(c, "error", `{"error": "Captain is disabled", "status": 422, "done": true}`)
writeSSEMessage(c, "error", `{"code": "COPILOT_NOT_CONFIGURED", "error": "Copilot provider is not configured", "status": 503, "done": true}`)
writeSSEMessage(c, "done", `{"done": true}`)
return
}
@@ -98,14 +98,15 @@ func (h *SSEStreamHandler) StreamCopilotMessage(c *gin.Context) {
// Stream from LLM using callback pattern
streamReq := llm.ChatRequest{
Model: "gpt-4",
Model: "",
Messages: chatMessages,
Temperature: 0.7,
MaxTokens: 1024,
Stream: true,
}
err = h.llmProvider.ChatCompletionStream(c.Request.Context(), streamReq, func(chunk llm.StreamChunk) error {
streamCtx := llm.WithAccountFeature(c.Request.Context(), uint(accountID), "copilot")
err = h.llmProvider.ChatCompletionStream(streamCtx, streamReq, func(chunk llm.StreamChunk) error {
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
if content != "" {