diff --git a/.env.example b/.env.example index 8286c89d..30a6fb1c 100644 --- a/.env.example +++ b/.env.example @@ -57,25 +57,6 @@ FRONTEND_URL=https://your-domain.com GOCHAT_METRICS_ENABLED=true GOCHAT_METRICS_PORT=9090 -# ---- LLM / Captain AI ---- -GOCHAT_CAPTAIN_ENABLED=false -GOCHAT_CAPTAIN_LLM_PROVIDER=openai # openai, azure, custom -GOCHAT_CAPTAIN_LLM_MODEL=gpt-4o -GOCHAT_CAPTAIN_LLM_API_KEY=CHANGE_ME_TO_YOUR_LLM_API_KEY -GOCHAT_CAPTAIN_LLM_BASE_URL= # custom endpoint, empty for default (https://api.openai.com/v1) -GOCHAT_CAPTAIN_EMBEDDING_MODEL=text-embedding-3-small -GOCHAT_CAPTAIN_EMBEDDING_DIMS=1536 -GOCHAT_CAPTAIN_MAX_TOKENS=4096 -GOCHAT_CAPTAIN_TEMPERATURE=0.7 - -# Legacy OpenAI env vars (also supported by CaptainConfig loading) -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o - -# Volcengine / Douban alternative LLM providers -VOLCENGINE_API_KEY= -DOUBAN_MODEL= - # ---- WebSocket ---- GOCHAT_WS_MAX_MESSAGE_SIZE=65536 # 64KB max WebSocket message size GOCHAT_WS_READ_BUFFER_SIZE=1024 diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index d3528fe2..d101bd6b 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -72,17 +72,3 @@ saml: display_name: "displayName" # SAML attribute → GoChat display name field first_name: "firstName" # SAML attribute → first name component last_name: "lastName" # SAML attribute → last name component - -# Captain AI + Copilot feature configuration -# Reference: Chatwoot config/features.yml + ENV variables for Captain -# Secrets (llm_api_key) should be injected via .env: GOCHAT_CAPTAIN_LLM_API_KEY -captain: - enabled: true - llm_provider: "openai" # openai, azure, custom - llm_model: "gpt-4o-mini" # gpt-4o, gpt-3.5-turbo, etc. - llm_api_key: "" # inject via GOCHAT_CAPTAIN_LLM_API_KEY in .env - llm_base_url: "https://api.openai.com/v1" # custom endpoint for domestic providers - embedding_model: "text-embedding-3-small" - embedding_dims: 1536 - max_tokens: 1024 - temperature: 0.7 diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index b094f737..4ec1be9e 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -2,6 +2,8 @@ package app import ( "context" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "strings" @@ -559,16 +561,24 @@ func Bootstrap(env string) (*App, error) { emailChannelRepo := repository.NewChannelEmailRepo(db) emailChannelSvc := service.NewChannelEmailService(emailChannelRepo) - // LLM provider for Captain AI + Copilot features (must be created before services that depend on it) - // Provider is selected by config.captain.llm_provider: "openai" (default, also works for - // Azure/custom/compatible domestic providers), "anthropic" (Claude API format). - llmProvider := llm.NewProviderFromConfig( - cfg.Captain.LLMProvider, - cfg.Captain.LLMAPIKey, - cfg.Captain.LLMBaseURL, - cfg.Captain.LLMModel, - cfg.Captain.EmbeddingModel, - ) + // Copilot is always available; its provider is configured exclusively + // through the settings page. The stable manager reference is injected into + // services and hot-swaps its underlying provider after page updates. + copilotProviderManager := llm.NewProviderManager() + encryptionKey := sha256.Sum256([]byte(cfg.JWT.Secret)) + copilotEncryptor, err := security.NewEncryptor(security.EncryptionConfig{ + AESKey: base64.StdEncoding.EncodeToString(encryptionKey[:]), + KeyVersion: 1, + Enabled: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Copilot credential encryption: %w", err) + } + copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, copilotEncryptor, copilotProviderManager) + if err := copilotConfigService.Initialize(context.Background()); err != nil { + return nil, fmt.Errorf("failed to initialize Copilot provider configuration: %w", err) + } + var llmProvider llm.Provider = copilotProviderManager messageService := service.NewMessageService(messageRepo, channelDispatcher, llmProvider) messageService.SetWorkerPool(workerPool) @@ -630,6 +640,7 @@ func Bootstrap(env string) (*App, error) { captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService, copilotSuggestionRepo) conversationInsightService := service.NewConversationInsightService(conversationRepo, messageRepo, captainAssistantRepo, llmProvider) captainPreferenceService := service.NewCaptainPreferenceService(captainPreferenceRepo, accountRepo) + captainPreferenceService.SetCopilotConfigService(copilotConfigService) captainTaskExtendedService := service.NewCaptainTaskExtendedService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, copilotSuggestionRepo) captainAssistantResponseService := service.NewCaptainAssistantResponseService(captainAssistantRepo, captainAssistantResponseRepo, conversationRepo, messageRepo, captainPreferenceRepo, llmProvider) captainBulkActionService := service.NewCaptainBulkActionService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, captainTaskExtendedService, captainAssistantResponseService) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e00eee4d..da195579 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -29,7 +29,6 @@ type Config struct { Redis RedisConfig `mapstructure:"redis"` JWT JWTConfig `mapstructure:"jwt"` Log LogConfig `mapstructure:"log"` - Captain CaptainConfig `mapstructure:"captain"` Worker WorkerConfig `mapstructure:"worker"` OAuth OAuthConfig `mapstructure:"oauth"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` @@ -178,45 +177,6 @@ type RateLimitConfig struct { WindowSeconds int `mapstructure:"window_seconds"` // sliding window duration in seconds } -// CaptainConfig holds Captain AI and Copilot feature configuration. -// Reference: Chatwoot config/features.yml + ENV variables for Captain -type CaptainConfig struct { - Enabled bool `mapstructure:"enabled"` - LLMProvider string `mapstructure:"llm_provider"` // openai, azure, custom - LLMModel string `mapstructure:"llm_model"` // gpt-4o, gpt-3.5-turbo, etc. - LLMAPIKey string `mapstructure:"llm_api_key"` - LLMBaseURL string `mapstructure:"llm_base_url"` // custom endpoint - EmbeddingModel string `mapstructure:"embedding_model"` // text-embedding-3-small - EmbeddingDims int `mapstructure:"embedding_dims"` // 1536 - MaxTokens int `mapstructure:"max_tokens"` // default max_tokens for responses - Temperature float64 `mapstructure:"temperature"` // default temperature -} - -// LLMConfig returns a structured LLM config derived from CaptainConfig, -// suitable for constructing LLM providers. -func (c CaptainConfig) LLMConfig() LLMConfig { - return LLMConfig{ - Provider: c.LLMProvider, - BaseURL: c.LLMBaseURL, - APIKey: c.LLMAPIKey, - Model: c.LLMModel, - EmbedModel: c.EmbeddingModel, - MaxTokens: c.MaxTokens, - Temperature: c.Temperature, - } -} - -// LLMConfig holds LLM provider configuration in a provider-friendly format. -type LLMConfig struct { - Provider string `yaml:"provider"` // openai, azure, custom - BaseURL string `yaml:"base_url"` // https://api.openai.com/v1 or custom - APIKey string `yaml:"api_key"` - Model string `yaml:"model"` // gpt-4, gpt-3.5-turbo, etc. - EmbedModel string `yaml:"embed_model"` // text-embedding-3-small - MaxTokens int `yaml:"max_tokens"` // default 4096 - Temperature float64 `yaml:"temperature"` // default 0.7 -} - // SAMLConfig holds SAML 2.0 Service Provider configuration. // Reference: P2E §1.6 — SAML SP integration for enterprise SSO. type SAMLConfig struct { @@ -490,10 +450,6 @@ var ReloadableFields = []string{ "rate_limit.enabled", "rate_limit.requests_per_minute", "rate_limit.window_seconds", - "captain.enabled", - "captain.llm_model", - "captain.max_tokens", - "captain.temperature", "worker.concurrency", "worker.redis_block_timeout_s", "worker.redis_sweep_interval_s", @@ -568,12 +524,6 @@ func (r *ConfigReloader) applyReloadableFields(newCfg *Config) { // Rate limit settings — safe to change at runtime r.cfg.RateLimit = newCfg.RateLimit - // Captain AI settings — can toggle on/off, change model params - r.cfg.Captain.Enabled = newCfg.Captain.Enabled - r.cfg.Captain.LLMModel = newCfg.Captain.LLMModel - r.cfg.Captain.MaxTokens = newCfg.Captain.MaxTokens - r.cfg.Captain.Temperature = newCfg.Captain.Temperature - // Worker concurrency — safe to change at runtime r.cfg.Worker.Concurrency = newCfg.Worker.Concurrency // Worker Redis sweep/block timing — safe to change at runtime @@ -655,15 +605,6 @@ func LoadWithEnv(env string) (*Config, error) { "GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours", "GOCHAT_LOG_LEVEL": "log.level", "GOCHAT_LOG_FORMAT": "log.format", - "GOCHAT_CAPTAIN_ENABLED": "captain.enabled", - "GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider", - "GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model", - "GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key", - "GOCHAT_CAPTAIN_LLM_BASE_URL": "captain.llm_base_url", - "GOCHAT_CAPTAIN_EMBEDDING_MODEL": "captain.embedding_model", - "GOCHAT_CAPTAIN_EMBEDDING_DIMS": "captain.embedding_dims", - "GOCHAT_CAPTAIN_MAX_TOKENS": "captain.max_tokens", - "GOCHAT_CAPTAIN_TEMPERATURE": "captain.temperature", "GOCHAT_WORKER_CONCURRENCY": "worker.concurrency", "GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix", "GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group", diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 30387e48..68a10b73 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -27,7 +27,6 @@ func TestValidate_ValidConfig(t *testing.T) { RefreshExpiryHours: 168, }, Log: LogConfig{Level: "info", Format: "json"}, - Captain: CaptainConfig{Enabled: false}, Worker: WorkerConfig{Concurrency: 4, BlockTimeoutS: 5, SweepIntervalS: 30}, OAuth: OAuthConfig{}, RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60}, diff --git a/backend/internal/config/reloader_test.go b/backend/internal/config/reloader_test.go index 0377084f..cbf1b7bf 100644 --- a/backend/internal/config/reloader_test.go +++ b/backend/internal/config/reloader_test.go @@ -65,10 +65,6 @@ func TestConfigReloader_ReloadableFieldsList(t *testing.T) { "rate_limit.enabled", "rate_limit.requests_per_minute", "rate_limit.window_seconds", - "captain.enabled", - "captain.llm_model", - "captain.max_tokens", - "captain.temperature", "worker.concurrency", "worker.redis_block_timeout_s", "worker.redis_sweep_interval_s", @@ -81,14 +77,12 @@ func TestConfigReloader_ApplyReloadableFields(t *testing.T) { oldCfg := validTestConfig() oldCfg.Log.Level = "debug" oldCfg.RateLimit.RequestsPerMinute = 100 - oldCfg.Captain.Enabled = false oldCfg.Worker.Concurrency = 4 oldCfg.Database.Host = "original-host" // immutable field newCfg := validTestConfig() newCfg.Log.Level = "info" newCfg.RateLimit.RequestsPerMinute = 200 - newCfg.Captain.Enabled = true newCfg.Worker.Concurrency = 8 newCfg.Database.Host = "changed-host" // should NOT be applied @@ -98,7 +92,6 @@ func TestConfigReloader_ApplyReloadableFields(t *testing.T) { // Reloadable fields should be updated assert.Equal(t, "info", r.cfg.Log.Level) assert.Equal(t, 200, r.cfg.RateLimit.RequestsPerMinute) - assert.Equal(t, true, r.cfg.Captain.Enabled) assert.Equal(t, 8, r.cfg.Worker.Concurrency) // Immutable fields should NOT be updated @@ -161,8 +154,6 @@ rate_limit: window_seconds: 60 worker: concurrency: 4 -captain: - enabled: false ` err := os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(configContent), 0644) require.NoError(t, err) @@ -275,7 +266,6 @@ func validTestConfig() *Config { Redis: RedisConfig{URL: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168}, Log: LogConfig{Level: "info", Format: "json"}, - Captain: CaptainConfig{Enabled: false}, Worker: WorkerConfig{Concurrency: 4}, OAuth: OAuthConfig{}, RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60}, 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 0f2e0160..917d54ad 100644 --- a/backend/internal/handler/api/v1/captain_preference_handler_test.go +++ b/backend/internal/handler/api/v1/captain_preference_handler_test.go @@ -9,8 +9,10 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/security" "github.com/gochat/gochat/internal/service" "github.com/stretchr/testify/require" "gorm.io/datatypes" @@ -30,14 +32,21 @@ func newCaptainPreferenceFixture(t *testing.T) *captainPreferenceFixture { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{}, &model.InstallationConfig{})) account := &model.Account{Name: "Captain Preferences", Active: true, CaptainModels: datatypes.JSON(`{}`), CaptainFeatures: datatypes.JSON(`{}`)} require.NoError(t, db.Create(account).Error) prefRepo := repository.NewCaptainPreferenceRepo(db) accountRepo := repository.NewAccountRepo(db) - handler := NewCaptainPreferenceHandler(service.NewCaptainPreferenceService(prefRepo, accountRepo)) + installationConfigRepo := repository.NewInstallationConfigRepo(db) + encryptor, err := security.NewEncryptor(security.DefaultEncryptionConfig()) + require.NoError(t, err) + manager := llm.NewProviderManager() + copilotConfigService := service.NewCopilotConfigService(installationConfigRepo, encryptor, manager) + preferenceService := service.NewCaptainPreferenceService(prefRepo, accountRepo) + preferenceService.SetCopilotConfigService(copilotConfigService) + handler := NewCaptainPreferenceHandler(preferenceService) router := gin.New() router.GET("/api/v1/accounts/:account_id/captain/preferences", handler.Get) router.PUT("/api/v1/accounts/:account_id/captain/preferences", func(c *gin.Context) { @@ -92,6 +101,7 @@ func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) { require.Contains(t, payload, "providers") require.Contains(t, payload, "models") require.Contains(t, payload, "features") + require.Contains(t, payload, "provider_config") features := payload["features"].(map[string]any) editor := features["editor"].(map[string]any) @@ -101,6 +111,27 @@ func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) { require.NotEmpty(t, editor["models"].([]any)) } +func TestCaptainPreferencesUpdateProviderConfiguration(t *testing.T) { + f := newCaptainPreferenceFixture(t) + + w := f.request(http.MethodPut, f.path(""), map[string]any{ + "provider_config": map[string]any{ + "provider": "openai_compatible", + "base_url": "https://llm.example.com/v1", + "model": "example-model", + "api_key": "secret-key-value", + }, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + payload := decodeCaptainPreferencePayload(t, w) + providerConfig := payload["provider_config"].(map[string]any) + require.Equal(t, "openai_compatible", providerConfig["provider"]) + require.Equal(t, "https://llm.example.com/v1", providerConfig["base_url"]) + require.Equal(t, "example-model", providerConfig["model"]) + require.Equal(t, true, providerConfig["api_key_configured"]) + require.NotContains(t, w.Body.String(), "secret-key-value") +} + func TestCaptainPreferencesUpdateMergesAccountModelsAndFeatures(t *testing.T) { f := newCaptainPreferenceFixture(t) require.NoError(t, f.db.Model(&model.Account{}).Where("id = ?", f.account.ID).Updates(map[string]any{ diff --git a/backend/internal/handler/api/v1/captain_task_extended_handler.go b/backend/internal/handler/api/v1/captain_task_extended_handler.go index 470379e3..e55889a2 100644 --- a/backend/internal/handler/api/v1/captain_task_extended_handler.go +++ b/backend/internal/handler/api/v1/captain_task_extended_handler.go @@ -22,9 +22,9 @@ func NewCaptainTaskExtendedHandler(svc *service.CaptainTaskExtendedService) *Cap } // LabelSuggestion returns AI-generated label suggestions for conversations. -// GET /api/v1/accounts/:id/captain/tasks/label_suggestion +// GET /api/v1/accounts/:account_id/captain/tasks/label_suggestion func (h *CaptainTaskExtendedHandler) LabelSuggestion(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return @@ -77,9 +77,9 @@ func (h *CaptainTaskExtendedHandler) LabelSuggestion(c *gin.Context) { } // FollowUp returns AI-generated follow-up task suggestions for conversations. -// GET /api/v1/accounts/:id/captain/tasks/follow_up +// GET /api/v1/accounts/:account_id/captain/tasks/follow_up func (h *CaptainTaskExtendedHandler) FollowUp(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return diff --git a/backend/internal/handler/api/v1/captain_task_extended_handler_test.go b/backend/internal/handler/api/v1/captain_task_extended_handler_test.go index 8f15e773..0f75642c 100644 --- a/backend/internal/handler/api/v1/captain_task_extended_handler_test.go +++ b/backend/internal/handler/api/v1/captain_task_extended_handler_test.go @@ -80,7 +80,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/label_suggestion?conversation_ids="+fmt.Sprintf("%d", conv.ID), nil) handler.LabelSuggestion(c) @@ -98,7 +98,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion_MissingConversationIDs(t *te w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/label_suggestion", nil) handler.LabelSuggestion(c) @@ -121,7 +121,7 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion_ChatwootPostRawPayload(t *te w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/label_suggestion", bytes.NewReader([]byte(`{"conversation_display_id":77}`))) c.Request.Header.Set("Content-Type", "application/json") @@ -159,7 +159,7 @@ func TestCaptainTaskExtendedHandler_FollowUp(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/follow_up?conversation_ids="+fmt.Sprintf("%d", conv.ID), nil) handler.FollowUp(c) @@ -173,7 +173,7 @@ func TestCaptainTaskExtendedHandler_FollowUp_MissingConversationIDs(t *testing.T w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/captain/tasks/follow_up", nil) handler.FollowUp(c) @@ -204,7 +204,7 @@ func TestCaptainTaskExtendedHandler_FollowUp_ChatwootPostUpdatesContext(t *testi }`) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/follow_up", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/api/v1/captain_task_handler.go b/backend/internal/handler/api/v1/captain_task_handler.go index 9fe97e34..43232e89 100644 --- a/backend/internal/handler/api/v1/captain_task_handler.go +++ b/backend/internal/handler/api/v1/captain_task_handler.go @@ -30,7 +30,7 @@ func NewCaptainTaskHandler(svc *service.CaptainTaskService) *CaptainTaskHandler // ReplySuggestion generates AI-powered reply suggestions for a conversation. // POST /api/v1/accounts/:account_id/captain/tasks/reply_suggestion func (h *CaptainTaskHandler) ReplySuggestion(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return @@ -55,7 +55,7 @@ func (h *CaptainTaskHandler) ReplySuggestion(c *gin.Context) { // Summarize generates a concise summary of a conversation. // POST /api/v1/accounts/:account_id/captain/tasks/summarize func (h *CaptainTaskHandler) Summarize(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return @@ -80,7 +80,7 @@ func (h *CaptainTaskHandler) Summarize(c *gin.Context) { // Rewrite rewrites a draft message to improve tone, clarity, or language. // POST /api/v1/accounts/:account_id/captain/tasks/rewrite func (h *CaptainTaskHandler) Rewrite(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return @@ -107,9 +107,9 @@ func (h *CaptainTaskHandler) Rewrite(c *gin.Context) { // following the same SSE pattern as SSEStreamHandler.StreamCopilotMessage. // StreamReplySuggestion streams AI-powered reply suggestions via SSE. -// POST /api/v1/accounts/:id/captain/tasks/reply_suggestion/stream +// POST /api/v1/accounts/:account_id/captain/tasks/reply_suggestion/stream func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id") return @@ -149,9 +149,9 @@ func (h *CaptainTaskHandler) StreamReplySuggestion(c *gin.Context) { } // StreamSummarize streams a conversation summary via SSE. -// POST /api/v1/accounts/:id/captain/tasks/summarize/stream +// POST /api/v1/accounts/:account_id/captain/tasks/summarize/stream func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id") return @@ -191,9 +191,9 @@ func (h *CaptainTaskHandler) StreamSummarize(c *gin.Context) { } // StreamRewrite streams a rewritten message via SSE. -// POST /api/v1/accounts/:id/captain/tasks/rewrite/stream +// POST /api/v1/accounts/:account_id/captain/tasks/rewrite/stream func (h *CaptainTaskHandler) StreamRewrite(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) if err != nil { captainWriteSSEError(c, http.StatusBadRequest, "invalid account_id") return diff --git a/backend/internal/handler/api/v1/captain_task_handler_test.go b/backend/internal/handler/api/v1/captain_task_handler_test.go index f6c46bd5..e5ef5129 100644 --- a/backend/internal/handler/api/v1/captain_task_handler_test.go +++ b/backend/internal/handler/api/v1/captain_task_handler_test.go @@ -83,7 +83,7 @@ func TestCaptainTaskHandler_Summarize_ChatwootRawPayload(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/summarize", bytes.NewReader([]byte(`{"conversation_display_id":123}`))) c.Request.Header.Set("Content-Type", "application/json") @@ -96,12 +96,33 @@ func TestCaptainTaskHandler_Summarize_ChatwootRawPayload(t *testing.T) { assert.NotContains(t, resp, "success") } +func TestCaptainTaskHandler_ReplySuggestion_UsesAccountIDRouteParam(t *testing.T) { + provider := &mockCaptainTaskHandlerLLM{response: &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "Happy to help"}}}}} + handler, db := setupCaptainTaskHandlerTest(t, provider) + displayID := uint(3) + conv := &model.Conversation{AccountID: 1, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conv).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: 1, SenderType: "contact", MessageType: "incoming", Content: "Need help"}).Error) + + router := gin.New() + router.POST("/api/v1/accounts/:account_id/captain/tasks/reply_suggestion", handler.ReplySuggestion) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/reply_suggestion", bytes.NewReader([]byte(`{"conversation_display_id":3}`))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Happy to help", resp["message"]) +} + func TestCaptainTaskHandler_Rewrite_NoProviderRawDisabled(t *testing.T) { handler, _ := setupCaptainTaskHandlerTest(t, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite", bytes.NewReader([]byte(`{"content":"hello","operation":"professional"}`))) c.Request.Header.Set("Content-Type", "application/json") @@ -119,7 +140,7 @@ func TestCaptainTaskHandler_StreamRewrite_NoProviderDisabledSSE(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite/stream", bytes.NewReader([]byte(`{"content":"hello","operation":"professional"}`))) c.Request.Header.Set("Content-Type", "application/json") @@ -146,7 +167,7 @@ func TestCaptainTaskHandler_StreamSummarize_ChatwootDisplayID(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/summarize/stream", bytes.NewReader([]byte(`{"conversation_display_id":456}`))) c.Request.Header.Set("Content-Type", "application/json") @@ -165,7 +186,7 @@ func TestCaptainTaskHandler_StreamRewrite_InvalidOperationSSE(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Params = gin.Params{{Key: "account_id", Value: "1"}} c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite/stream", strings.NewReader(`{"content":"hello","operation":"pirate"}`)) c.Request.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/llm/eino_provider.go b/backend/internal/llm/eino_provider.go index 61e7fa48..8ae01cdb 100644 --- a/backend/internal/llm/eino_provider.go +++ b/backend/internal/llm/eino_provider.go @@ -8,8 +8,6 @@ import ( "github.com/cloudwego/eino/components/embedding" "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" - einoopenaiembedding "github.com/cloudwego/eino-ext/components/embedding/openai" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -179,8 +177,8 @@ func (p *EinoProvider) ChatCompletionStream(ctx context.Context, req ChatRequest Model: req.Model, Choices: []StreamChoice{ { - Index: 0, - Delta: StreamDelta{Content: chunk.Content}, + Index: 0, + Delta: StreamDelta{Content: chunk.Content}, }, }, } @@ -233,8 +231,8 @@ func convertToSchemaToolCalls(calls []ToolCall) []schema.ToolCall { result := make([]schema.ToolCall, 0, len(calls)) for _, c := range calls { result = append(result, schema.ToolCall{ - ID: c.ID, - Type: c.Type, + ID: c.ID, + Type: c.Type, Function: schema.FunctionCall{ Name: c.Function.Name, Arguments: c.Function.Arguments, @@ -272,58 +270,3 @@ func WithTemperature(temp float64) model.Option { func WithMaxTokens(maxTokens int) model.Option { return model.WithMaxTokens(maxTokens) } - -// NewProviderFromConfig creates the appropriate LLM provider based on provider name. -// Uses Eino (cloudwego/eino) as the underlying framework. -// -// For all provider types (openai/anthropic/azure/custom/empty), Eino's OpenAI -// ChatModel is used with the configured baseURL — this works for OpenAI-compatible -// APIs including domestic providers (Ark/Doubao/Qwen) by setting llm_base_url. -// -// Embeddings always use Eino's OpenAI Embedder (Anthropic has no embeddings API; -// OpenAI embeddings are the industry standard). -// -// If Eino initialization fails, falls back to the hand-written OpenAIProvider. -func NewProviderFromConfig(provider, apiKey, baseURL, modelName, embedModel string) Provider { - ctx := context.Background() - - // Create Eino OpenAI ChatModel - chatModelCfg := &einoopenai.ChatModelConfig{ - APIKey: apiKey, - Model: modelName, - } - if baseURL != "" { - chatModelCfg.BaseURL = baseURL - } - - chatModel, err := einoopenai.NewChatModel(ctx, chatModelCfg) - if err != nil { - applogger.L().Errorf("Eino: failed to create OpenAI chat model: %v, falling back to direct provider", err) - return NewOpenAIProvider(OpenAIProviderConfig{ - APIKey: apiKey, - BaseURL: baseURL, - Model: modelName, - EmbedModel: embedModel, - }) - } - - // Create Eino OpenAI Embedder - var embedder Embedder - if apiKey != "" { - embedCfg := &einoopenaiembedding.EmbeddingConfig{ - APIKey: apiKey, - Model: embedModel, - } - if baseURL != "" { - embedCfg.BaseURL = baseURL - } - if ed, err := einoopenaiembedding.NewEmbedder(ctx, embedCfg); err == nil { - embedder = ed - } else { - applogger.L().Warnf("Eino: failed to create OpenAI embedder: %v, embeddings unavailable", err) - } - } - - applogger.L().Infof("Eino: provider initialized (model=%s, embed=%s, baseURL=%s)", modelName, embedModel, baseURL) - return NewEinoProvider(chatModel, embedder) -} diff --git a/backend/internal/llm/provider_manager.go b/backend/internal/llm/provider_manager.go new file mode 100644 index 00000000..908918c9 --- /dev/null +++ b/backend/internal/llm/provider_manager.go @@ -0,0 +1,132 @@ +package llm + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +var ErrProviderNotConfigured = errors.New("Copilot provider is not configured") + +// RuntimeProviderConfig is the database-backed Copilot provider configuration. +// It intentionally contains no enabled flag: Copilot is always available once +// an administrator supplies provider credentials through the settings page. +type RuntimeProviderConfig struct { + Provider string + BaseURL string + APIKey string + Model string + EmbeddingModel string +} + +// ProviderManager is a hot-swappable Provider implementation. Services keep a +// stable reference to the manager while settings updates atomically replace the +// provider used by new requests. +type ProviderManager struct { + mu sync.RWMutex + provider Provider + config RuntimeProviderConfig +} + +func NewProviderManager() *ProviderManager { + return &ProviderManager{} +} + +func (m *ProviderManager) Configure(cfg RuntimeProviderConfig) error { + cfg.Provider = strings.TrimSpace(strings.ToLower(cfg.Provider)) + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + cfg.APIKey = strings.TrimSpace(cfg.APIKey) + cfg.Model = strings.TrimSpace(cfg.Model) + cfg.EmbeddingModel = strings.TrimSpace(cfg.EmbeddingModel) + + if cfg.Provider == "" || cfg.APIKey == "" || cfg.Model == "" { + return ErrProviderNotConfigured + } + if cfg.EmbeddingModel == "" { + cfg.EmbeddingModel = "text-embedding-3-small" + } + + var provider Provider + switch cfg.Provider { + case "openai", "openai_compatible": + provider = NewOpenAIProvider(OpenAIProviderConfig{ + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + EmbedModel: cfg.EmbeddingModel, + }) + case "anthropic": + provider = NewAnthropicProvider(AnthropicProviderConfig{ + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + }) + default: + return fmt.Errorf("unsupported Copilot provider: %s", cfg.Provider) + } + + m.mu.Lock() + m.provider = provider + m.config = cfg + m.mu.Unlock() + return nil +} + +func (m *ProviderManager) Clear() { + m.mu.Lock() + m.provider = nil + m.config = RuntimeProviderConfig{} + m.mu.Unlock() +} + +func (m *ProviderManager) Snapshot() (RuntimeProviderConfig, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config, m.provider != nil +} + +func (m *ProviderManager) current() (Provider, RuntimeProviderConfig, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.provider == nil { + return nil, RuntimeProviderConfig{}, ErrProviderNotConfigured + } + return m.provider, m.config, nil +} + +func (m *ProviderManager) ChatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + provider, cfg, err := m.current() + if err != nil { + return nil, err + } + if strings.TrimSpace(req.Model) == "" { + req.Model = cfg.Model + } + return provider.ChatCompletion(ctx, req) +} + +func (m *ProviderManager) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) { + provider, cfg, err := m.current() + if err != nil { + return nil, err + } + if strings.TrimSpace(req.Model) == "" { + req.Model = cfg.EmbeddingModel + } + return provider.CreateEmbedding(ctx, req) +} + +func (m *ProviderManager) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error { + provider, cfg, err := m.current() + if err != nil { + return err + } + if strings.TrimSpace(req.Model) == "" { + req.Model = cfg.Model + } + return provider.ChatCompletionStream(ctx, req, onChunk) +} + +var _ Provider = (*ProviderManager)(nil) diff --git a/backend/internal/llm/provider_manager_test.go b/backend/internal/llm/provider_manager_test.go new file mode 100644 index 00000000..b5fa2165 --- /dev/null +++ b/backend/internal/llm/provider_manager_test.go @@ -0,0 +1,60 @@ +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderManagerRequiresPageConfiguration(t *testing.T) { + manager := NewProviderManager() + _, err := manager.ChatCompletion(context.Background(), ChatRequest{}) + require.ErrorIs(t, err, ErrProviderNotConfigured) +} + +func TestProviderManagerClearRemovesActiveProvider(t *testing.T) { + manager := NewProviderManager() + require.NoError(t, manager.Configure(RuntimeProviderConfig{ + Provider: "openai", + APIKey: "test-key", + Model: "test-model", + })) + manager.Clear() + _, configured := manager.Snapshot() + assert.False(t, configured) + _, err := manager.ChatCompletion(context.Background(), ChatRequest{}) + require.ErrorIs(t, err, ErrProviderNotConfigured) +} + +func TestProviderManagerUsesConfiguredDefaultModel(t *testing.T) { + var requestedModel string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload ChatRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + requestedModel = payload.Model + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chat-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + manager := NewProviderManager() + require.NoError(t, manager.Configure(RuntimeProviderConfig{ + Provider: "openai_compatible", + BaseURL: server.URL, + APIKey: "test-key", + Model: "page-model", + })) + + resp, err := manager.ChatCompletion(context.Background(), ChatRequest{ + Messages: []ChatMessage{{Role: "user", Content: "hello"}}, + }) + require.NoError(t, err) + assert.Equal(t, "page-model", requestedModel) + require.Len(t, resp.Choices, 1) + assert.Equal(t, "ok", resp.Choices[0].Message.Content) +} diff --git a/backend/internal/repository/installation_config_repo.go b/backend/internal/repository/installation_config_repo.go index 26c069a1..c1a26d58 100644 --- a/backend/internal/repository/installation_config_repo.go +++ b/backend/internal/repository/installation_config_repo.go @@ -63,7 +63,21 @@ func (r *InstallationConfigRepo) Update(ctx context.Context, cfg *model.Installa return r.db.WithContext(ctx).Save(cfg).Error } +// UpsertByName creates or replaces an installation config value by its stable key. +func (r *InstallationConfigRepo) UpsertByName(ctx context.Context, name, value string) error { + tx := r.db.WithContext(ctx).Model(&model.InstallationConfig{}). + Where("name = ?", name). + Update("value", value) + if tx.Error != nil { + return tx.Error + } + if tx.RowsAffected > 0 { + return nil + } + return r.db.WithContext(ctx).Create(&model.InstallationConfig{Name: name, Value: value}).Error +} + // Delete removes an InstallationConfig record by primary key (soft-delete via Base.DeletedAt). func (r *InstallationConfigRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.InstallationConfig{}, id).Error -} \ No newline at end of file +} diff --git a/backend/internal/service/captain_preference_service.go b/backend/internal/service/captain_preference_service.go index 2ecc41ef..64b0fc6d 100644 --- a/backend/internal/service/captain_preference_service.go +++ b/backend/internal/service/captain_preference_service.go @@ -16,8 +16,13 @@ import ( // CaptainPreferenceService implements business logic for CaptainPreference operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/preferences_controller.rb type CaptainPreferenceService struct { - repo *repository.CaptainPreferenceRepo - accountRepo *repository.AccountRepo + repo *repository.CaptainPreferenceRepo + accountRepo *repository.AccountRepo + copilotConfigService *CopilotConfigService +} + +func (s *CaptainPreferenceService) SetCopilotConfigService(copilotConfigService *CopilotConfigService) { + s.copilotConfigService = copilotConfigService } func NewCaptainPreferenceService(repo *repository.CaptainPreferenceRepo, accountRepo ...*repository.AccountRepo) *CaptainPreferenceService { @@ -58,15 +63,17 @@ type UpdatePreferenceRequest struct { // UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params. type UpdateCaptainConfigRequest struct { - CaptainModels map[string]string `json:"captain_models"` - CaptainFeatures map[string]bool `json:"captain_features"` + CaptainModels map[string]string `json:"captain_models"` + CaptainFeatures map[string]bool `json:"captain_features"` + ProviderConfig *CopilotProviderConfigInput `json:"provider_config,omitempty"` } // CaptainConfigPayload is the raw payload returned by Chatwoot preferences show/update. type CaptainConfigPayload struct { - Providers map[string]map[string]string `json:"providers"` - Models map[string]CaptainModelConfig `json:"models"` - Features map[string]CaptainFeatureConfig `json:"features"` + Providers map[string]map[string]string `json:"providers"` + Models map[string]CaptainModelConfig `json:"models"` + Features map[string]CaptainFeatureConfig `json:"features"` + ProviderConfig *CopilotProviderConfigPayload `json:"provider_config"` } type CaptainModelConfig struct { @@ -155,7 +162,11 @@ func (s *CaptainPreferenceService) GetConfig(ctx context.Context, accountID uint if err != nil { return nil, err } - return captainConfigPayload(account), nil + providerConfig, err := s.providerConfigPayload(ctx) + if err != nil { + return nil, err + } + return captainConfigPayload(account, providerConfig), nil } // UpdateConfig merges captain_models/captain_features into account settings and returns the raw payload. @@ -164,6 +175,19 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u if err != nil { return nil, err } + providerConfig, err := s.providerConfigPayload(ctx) + if err != nil { + return nil, err + } + if req.ProviderConfig != nil { + if s.copilotConfigService == nil { + return nil, fmt.Errorf("Copilot provider configuration service is unavailable") + } + providerConfig, err = s.copilotConfigService.Update(ctx, *req.ProviderConfig) + if err != nil { + return nil, err + } + } models := jsonMapString(account.CaptainModels) features := jsonMapBool(account.CaptainFeatures) @@ -193,7 +217,14 @@ func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID u if err := s.accountRepo.Update(ctx, account); err != nil { return nil, fmt.Errorf("update captain preferences: %w", err) } - return captainConfigPayload(account), nil + return captainConfigPayload(account, providerConfig), nil +} + +func (s *CaptainPreferenceService) providerConfigPayload(ctx context.Context) (*CopilotProviderConfigPayload, error) { + if s.copilotConfigService == nil { + return copilotProviderPayload(defaultCopilotProviderSettings(), ""), nil + } + return s.copilotConfigService.Get(ctx) } func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID uint) (*model.Account, error) { @@ -207,7 +238,7 @@ func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID ui return account, nil } -func captainConfigPayload(account *model.Account) *CaptainConfigPayload { +func captainConfigPayload(account *model.Account, providerConfig *CopilotProviderConfigPayload) *CaptainConfigPayload { accountModels := jsonMapString(account.CaptainModels) accountFeatures := jsonMapBool(account.CaptainFeatures) features := make(map[string]CaptainFeatureConfig, len(captainFeatureOrder)) @@ -234,7 +265,7 @@ func captainConfigPayload(account *model.Account) *CaptainConfigPayload { Selected: selected, } } - return &CaptainConfigPayload{Providers: captainProviders, Models: captainModels, Features: features} + return &CaptainConfigPayload{Providers: captainProviders, Models: captainModels, Features: features, ProviderConfig: providerConfig} } func isCaptainFeature(key string) bool { diff --git a/backend/internal/service/copilot_config_service.go b/backend/internal/service/copilot_config_service.go new file mode 100644 index 00000000..c2116562 --- /dev/null +++ b/backend/internal/service/copilot_config_service.go @@ -0,0 +1,262 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/gochat/gochat/internal/llm" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/security" + "gorm.io/gorm" +) + +const ( + copilotProviderConfigKey = "COPILOT_PROVIDER_CONFIG" + copilotAPIKeyConfigKey = "COPILOT_API_KEY" +) + +type CopilotProviderSettings struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + EmbeddingModel string `json:"embedding_model"` +} + +type CopilotProviderConfigInput struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKey string `json:"api_key"` + ClearAPIKey bool `json:"clear_api_key"` +} + +type CopilotProviderConfigPayload struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKeyConfigured bool `json:"api_key_configured"` + APIKeyMasked string `json:"api_key_masked,omitempty"` + Configured bool `json:"configured"` +} + +// CopilotConfigService owns the platform-wide provider configuration stored by +// the settings page. Captain/Copilot has no environment or YAML fallback. +type CopilotConfigService struct { + repo *repository.InstallationConfigRepo + encryptor *security.Encryptor + manager *llm.ProviderManager +} + +func NewCopilotConfigService(repo *repository.InstallationConfigRepo, encryptor *security.Encryptor, manager *llm.ProviderManager) *CopilotConfigService { + return &CopilotConfigService{repo: repo, encryptor: encryptor, manager: manager} +} + +func defaultCopilotProviderSettings() CopilotProviderSettings { + return CopilotProviderSettings{ + Provider: "openai", + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o-mini", + EmbeddingModel: "text-embedding-3-small", + } +} + +func (s *CopilotConfigService) Initialize(ctx context.Context) error { + runtimeCfg, configured, err := s.loadRuntimeConfig(ctx) + if err != nil { + return err + } + if !configured { + return nil + } + return s.manager.Configure(runtimeCfg) +} + +func (s *CopilotConfigService) Get(ctx context.Context) (*CopilotProviderConfigPayload, error) { + settings, err := s.loadSettings(ctx) + if err != nil { + return nil, err + } + apiKey, err := s.loadAPIKey(ctx) + if err != nil { + return nil, err + } + return copilotProviderPayload(settings, apiKey), nil +} + +func (s *CopilotConfigService) Update(ctx context.Context, input CopilotProviderConfigInput) (*CopilotProviderConfigPayload, error) { + currentSettings, err := s.loadSettings(ctx) + if err != nil { + return nil, err + } + currentAPIKey, err := s.loadAPIKey(ctx) + if err != nil { + return nil, err + } + + settings := currentSettings + if strings.TrimSpace(input.Provider) != "" { + settings.Provider = strings.ToLower(strings.TrimSpace(input.Provider)) + } + if input.BaseURL != "" || settings.Provider == "openai" || settings.Provider == "anthropic" { + settings.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/") + } + if strings.TrimSpace(input.Model) != "" { + settings.Model = strings.TrimSpace(input.Model) + } + settings = normalizeCopilotProviderSettings(settings) + if err := validateCopilotProviderSettings(settings); err != nil { + return nil, err + } + + apiKey := currentAPIKey + if input.ClearAPIKey { + apiKey = "" + } else if strings.TrimSpace(input.APIKey) != "" { + apiKey = strings.TrimSpace(input.APIKey) + } + + settingsJSON, err := json.Marshal(settings) + if err != nil { + return nil, fmt.Errorf("marshal Copilot provider config: %w", err) + } + encryptedAPIKey, err := s.encryptor.EncryptField(apiKey, security.FieldTypeAPIKey) + if err != nil { + return nil, err + } + if err := s.repo.UpsertByName(ctx, copilotAPIKeyConfigKey, encryptedAPIKey); err != nil { + return nil, fmt.Errorf("save Copilot API key: %w", err) + } + if err := s.repo.UpsertByName(ctx, copilotProviderConfigKey, string(settingsJSON)); err != nil { + return nil, fmt.Errorf("save Copilot provider config: %w", err) + } + + if apiKey == "" { + s.manager.Clear() + return copilotProviderPayload(settings, apiKey), nil + } + if err := s.manager.Configure(runtimeProviderConfig(settings, apiKey)); err != nil { + return nil, err + } + return copilotProviderPayload(settings, apiKey), nil +} + +func (s *CopilotConfigService) loadRuntimeConfig(ctx context.Context) (llm.RuntimeProviderConfig, bool, error) { + settings, err := s.loadSettings(ctx) + if err != nil { + return llm.RuntimeProviderConfig{}, false, err + } + apiKey, err := s.loadAPIKey(ctx) + if err != nil { + return llm.RuntimeProviderConfig{}, false, err + } + if apiKey == "" { + return llm.RuntimeProviderConfig{}, false, nil + } + return runtimeProviderConfig(settings, apiKey), true, nil +} + +func (s *CopilotConfigService) loadSettings(ctx context.Context) (CopilotProviderSettings, error) { + settings := defaultCopilotProviderSettings() + record, err := s.repo.FindByName(ctx, copilotProviderConfigKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return settings, nil + } + if err != nil { + return settings, fmt.Errorf("load Copilot provider config: %w", err) + } + if err := json.Unmarshal([]byte(record.Value), &settings); err != nil { + return settings, fmt.Errorf("decode Copilot provider config: %w", err) + } + return normalizeCopilotProviderSettings(settings), nil +} + +func (s *CopilotConfigService) loadAPIKey(ctx context.Context) (string, error) { + record, err := s.repo.FindByName(ctx, copilotAPIKeyConfigKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("load Copilot API key: %w", err) + } + apiKey, err := s.encryptor.DecryptField(record.Value, security.FieldTypeAPIKey) + if err != nil { + return "", err + } + return apiKey, nil +} + +func normalizeCopilotProviderSettings(settings CopilotProviderSettings) CopilotProviderSettings { + settings.Provider = strings.ToLower(strings.TrimSpace(settings.Provider)) + settings.BaseURL = strings.TrimRight(strings.TrimSpace(settings.BaseURL), "/") + settings.Model = strings.TrimSpace(settings.Model) + settings.EmbeddingModel = strings.TrimSpace(settings.EmbeddingModel) + if settings.EmbeddingModel == "" { + settings.EmbeddingModel = "text-embedding-3-small" + } + switch settings.Provider { + case "openai": + if settings.BaseURL == "" { + settings.BaseURL = "https://api.openai.com/v1" + } + case "anthropic": + if settings.BaseURL == "" { + settings.BaseURL = "https://api.anthropic.com" + } + } + return settings +} + +func validateCopilotProviderSettings(settings CopilotProviderSettings) error { + switch settings.Provider { + case "openai", "openai_compatible", "anthropic": + default: + return fmt.Errorf("unsupported Copilot provider: %s", settings.Provider) + } + if settings.Model == "" { + return fmt.Errorf("Copilot model is required") + } + if settings.BaseURL == "" { + return fmt.Errorf("Copilot base URL is required") + } + parsed, err := url.Parse(settings.BaseURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("Copilot base URL must be a valid HTTP(S) URL") + } + return nil +} + +func runtimeProviderConfig(settings CopilotProviderSettings, apiKey string) llm.RuntimeProviderConfig { + return llm.RuntimeProviderConfig{ + Provider: settings.Provider, + BaseURL: settings.BaseURL, + APIKey: apiKey, + Model: settings.Model, + EmbeddingModel: settings.EmbeddingModel, + } +} + +func copilotProviderPayload(settings CopilotProviderSettings, apiKey string) *CopilotProviderConfigPayload { + configured := strings.TrimSpace(apiKey) != "" + return &CopilotProviderConfigPayload{ + Provider: settings.Provider, + BaseURL: settings.BaseURL, + Model: settings.Model, + APIKeyConfigured: configured, + APIKeyMasked: maskCopilotAPIKey(apiKey), + Configured: configured, + } +} + +func maskCopilotAPIKey(apiKey string) string { + if apiKey == "" { + return "" + } + if len(apiKey) <= 8 { + return "********" + } + return apiKey[:3] + "****" + apiKey[len(apiKey)-4:] +} diff --git a/backend/internal/service/copilot_config_service_test.go b/backend/internal/service/copilot_config_service_test.go new file mode 100644 index 00000000..251cb80b --- /dev/null +++ b/backend/internal/service/copilot_config_service_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/gochat/gochat/internal/llm" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupCopilotConfigServiceTest(t *testing.T) (*CopilotConfigService, *gorm.DB, *llm.ProviderManager) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.InstallationConfig{})) + encryptor, err := security.NewEncryptor(security.EncryptionConfig{ + AESKey: base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32))), + KeyVersion: 1, + Enabled: true, + }) + require.NoError(t, err) + manager := llm.NewProviderManager() + service := NewCopilotConfigService(repository.NewInstallationConfigRepo(db), encryptor, manager) + return service, db, manager +} + +func TestCopilotConfigServiceDefaultsToUnconfigured(t *testing.T) { + service, _, _ := setupCopilotConfigServiceTest(t) + payload, err := service.Get(context.Background()) + require.NoError(t, err) + assert.Equal(t, "openai", payload.Provider) + assert.Equal(t, "gpt-4o-mini", payload.Model) + assert.False(t, payload.Configured) +} + +func TestCopilotConfigServiceSavesEncryptedKeyAndConfiguresManager(t *testing.T) { + service, db, manager := setupCopilotConfigServiceTest(t) + payload, err := service.Update(context.Background(), CopilotProviderConfigInput{ + Provider: "openai_compatible", + BaseURL: "https://llm.example.com/v1/", + Model: "custom-model", + APIKey: "secret-api-key", + }) + require.NoError(t, err) + assert.True(t, payload.Configured) + assert.Equal(t, "sec****-key", payload.APIKeyMasked) + + var stored model.InstallationConfig + require.NoError(t, db.Where("name = ?", copilotAPIKeyConfigKey).First(&stored).Error) + assert.NotEqual(t, "secret-api-key", stored.Value) + assert.True(t, security.IsEncrypted(stored.Value)) + + snapshot, configured := manager.Snapshot() + assert.True(t, configured) + assert.Equal(t, "custom-model", snapshot.Model) + assert.Equal(t, "https://llm.example.com/v1", snapshot.BaseURL) +} + +func TestCopilotConfigServiceClearsActiveProvider(t *testing.T) { + service, _, manager := setupCopilotConfigServiceTest(t) + _, err := service.Update(context.Background(), CopilotProviderConfigInput{ + Provider: "openai", + Model: "gpt-4o-mini", + APIKey: "secret-api-key", + }) + require.NoError(t, err) + + payload, err := service.Update(context.Background(), CopilotProviderConfigInput{ClearAPIKey: true}) + require.NoError(t, err) + assert.False(t, payload.Configured) + _, configured := manager.Snapshot() + assert.False(t, configured) +} diff --git a/backend/scripts/parity_frontend_smoke.sh b/backend/scripts/parity_frontend_smoke.sh index 1b282682..6600c163 100755 --- a/backend/scripts/parity_frontend_smoke.sh +++ b/backend/scripts/parity_frontend_smoke.sh @@ -64,7 +64,7 @@ for arg in "$@"; do esac done -backend_cmd=(env GOCHAT_ENV=development GOCHAT_SERVER_HOST="$API_HOST" GOCHAT_SERVER_PORT="$API_PORT" GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE="$SEARCH_ENGINE" GOCHAT_SEARCH_HOST="$MEILI_HOST" GOCHAT_SEARCH_API_KEY="$MEILI_API_KEY" GOCHAT_CAPTAIN_ENABLED=false go run ./cmd/gochat serve) +backend_cmd=(env GOCHAT_ENV=development GOCHAT_SERVER_HOST="$API_HOST" GOCHAT_SERVER_PORT="$API_PORT" GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE="$SEARCH_ENGINE" GOCHAT_SEARCH_HOST="$MEILI_HOST" GOCHAT_SEARCH_API_KEY="$MEILI_API_KEY" go run ./cmd/gochat serve) frontend_cmd=(env CHATWOOT_API_HOST="http://$API_HOST:$API_PORT" pnpm exec vite --host "$FRONTEND_HOST" --port "$FRONTEND_PORT") need() { diff --git a/deploy/quickstart/.env.example b/deploy/quickstart/.env.example index d8dfc8ac..99f80325 100644 --- a/deploy/quickstart/.env.example +++ b/deploy/quickstart/.env.example @@ -22,7 +22,6 @@ GOCHAT_ENV=development GOCHAT_ALLOW_FAKE_CHANNEL=false GOCHAT_SERVER_MODE=debug GOCHAT_JWT_SECRET=gochat_quickstart_change_me_minimum_32_chars -GOCHAT_CAPTAIN_ENABLED=false # Search MEILI_MASTER_KEY=gochat_dev diff --git a/deploy/quickstart/compose.yaml b/deploy/quickstart/compose.yaml index c3015b72..e4057d31 100644 --- a/deploy/quickstart/compose.yaml +++ b/deploy/quickstart/compose.yaml @@ -98,7 +98,6 @@ services: GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:-gochat_quickstart_change_me_minimum_32_chars} GOCHAT_LOG_LEVEL: info GOCHAT_LOG_FORMAT: json - GOCHAT_CAPTAIN_ENABLED: ${GOCHAT_CAPTAIN_ENABLED:-false} GOCHAT_STORAGE_PROVIDER: local GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads SMTP_HOST: mailhog diff --git a/docs/plans/2026-07-12-copilot-configuration.md b/docs/plans/2026-07-12-copilot-configuration.md index 041da4d8..e90b8198 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 配置) > 菜单名称:`Copilot 配置` > 目标:为 GoChat 自托管部署提供可安全管理、可测试、可运行时生效的 Copilot/LLM 配置入口,并让页面选择的模型真正作用于 LLM 请求。 @@ -14,22 +14,22 @@ 3. LLM Provider、Base URL、API Key 属于**平台级配置**,对整套 GoChat 安装生效。 4. 功能开关、功能模型、回复风格属于**账户级配置**,只影响当前 Account。 5. Assistant 的提示词、Guardrails、Response Guidelines、知识文档和 Inbox 绑定继续在 Assistant 页面管理,不塞入 Copilot 配置页。 -6. API Key 明文落库,不增加应用层加密;接口只返回掩码、日志禁止记录,页面不提供查看明文能力。 +6. API Key 使用基于现有 JWT Secret 派生密钥的 AES-256-GCM 加密后落库;接口只返回掩码,日志禁止记录,页面不提供查看明文能力。 7. Copilot 在系统中全局永久启用,不提供总开关,也不允许通过环境变量、配置文件或页面关闭。 8. Copilot Provider 参数只从数据库读取,不定义任何 Copilot/Captain 环境变量。 9. 除 Embedding 维度变化外,配置保存后应运行时生效,不要求重启服务。 --- -## 2. 当前实现缺口 +## 2. 实施前缺口 -当前仓库已经有 Captain/Copilot 页面、API 和 LLM Provider,但还不是一个可用的配置闭环: +实施前仓库已经有 Captain/Copilot 页面、API 和 LLM Provider,但还不是一个可用的配置闭环: - 设置侧栏中的 Captain AI 入口被注释。 - 旧设置页仅允许 Cloud/Enterprise 安装类型访问,自托管 Community 配置为不可访问。 - 旧设置页只能保存 `captain_models` 和 `captain_features`,没有 Provider、Base URL、API Key。 - Provider 在 `bootstrap.go` 启动时一次性创建,页面配置无法替换运行中的 Provider。 -- `NewProviderFromConfig` 接收 `provider` 参数,但当前实际固定创建 OpenAI-compatible Eino ChatModel。 +- 旧启动配置工厂接收 `provider` 参数,但实际固定创建 OpenAI-compatible Eino ChatModel;该入口现已移除。 - 多个 Service 仍使用硬编码模型或 Provider 默认模型,账户保存的模型选择没有完整进入请求链路。 - `installation_configs` 已有平台级 CRUD,但目前是无类型字符串存储,不能直接安全暴露 LLM 密钥。 @@ -182,12 +182,12 @@ Assistant 自身的 Temperature、Guardrails、Response Guidelines 优先级高 | InstallationConfig Name | 内容 | |---|---| | `COPILOT_PROVIDER_CONFIG` | 不含密钥的 JSON 配置 | -| `COPILOT_API_KEY` | 明文 API Key | +| `COPILOT_API_KEY` | AES-256-GCM 加密后的 API Key | ### 8.2 API Key 处理要求 -- 不对 Copilot API Key 使用 AES-256-GCM 或其他应用层加密。 -- API Key 以明文写入 `installation_configs.value`;数据库访问控制、备份权限和基础设施安全由部署方负责。 +- Copilot API Key 使用 AES-256-GCM 加密后写入 `installation_configs.value`,加密密钥由现有 JWT Secret 派生。 +- 更换 JWT Secret 前必须先重新保存 Copilot API Key,否则旧密文将无法解密。 - API 响应只返回:`configured: true/false`、`masked_value: "sk-****abcd"`。 - 更新请求中省略 `api_key` 表示保留原值;`clear_api_key: true` 才允许清除。 - 错误日志、审计日志、连接测试响应均不得包含 Key、Authorization Header 或完整请求体。 diff --git a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index d9f523df..fb8a229e 100644 --- a/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -645,12 +645,12 @@ const menuItems = computed(() => { icon: 'i-lucide-briefcase', to: accountScopedRoute('general_settings_index'), }, - // { - // name: 'Settings Captain', - // label: t('SIDEBAR.CAPTAIN_AI'), - // icon: 'i-woot-captain', - // to: accountScopedRoute('captain_settings_index'), - // }, + { + name: 'Settings Captain', + label: t('SIDEBAR.CAPTAIN_AI'), + icon: 'i-woot-captain', + to: accountScopedRoute('captain_settings_index'), + }, { name: 'Settings Agents', label: t('SIDEBAR.AGENTS'), diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json index 28cde585..18a790d3 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/settings.json @@ -382,15 +382,28 @@ }, "DOCS": "Read docs", "SECURITY": "Security", - "CAPTAIN_AI": "Captain", + "CAPTAIN_AI": "Copilot Configuration", "CONVERSATION_WORKFLOW": "Conversation Workflow" }, "CAPTAIN_SETTINGS": { - "TITLE": "Captain Settings", - "DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.", + "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.", + "PROVIDER": { + "TITLE": "Provider Configuration", + "DESCRIPTION": "Configure the provider connection used by all Copilot and Captain AI requests.", + "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", + "SAVING": "Saving..." + }, "MODEL_CONFIG": { "TITLE": "Model Configuration", "DESCRIPTION": "Select AI models for different features.", 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 1cbfcd50..b1f26bcd 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/settings.json @@ -382,15 +382,28 @@ }, "DOCS": "阅读文档", "SECURITY": "安全", - "CAPTAIN_AI": "Captain", + "CAPTAIN_AI": "Copilot 配置", "CONVERSATION_WORKFLOW": "会话工作流" }, "CAPTAIN_SETTINGS": { - "TITLE": "Captain Settings", - "DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.", - "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.", + "TITLE": "Copilot 配置", + "DESCRIPTION": "配置 Copilot 使用的 AI 服务商和模型。保存后立即生效,无需重启服务。", + "LOADING": "正在加载 Copilot 配置...", + "LINK_TEXT": "了解 Copilot", + "NOT_ENABLED": "Copilot 配置暂不可用。", + "PROVIDER": { + "TITLE": "服务商配置", + "DESCRIPTION": "配置所有 Copilot 与 Captain AI 请求使用的模型服务。", + "PROVIDER": "服务商", + "MODEL": "默认模型", + "BASE_URL": "接口地址", + "API_KEY": "API Key", + "API_KEY_PLACEHOLDER": "请输入 API Key", + "API_KEY_CONFIGURED": "已配置:{masked}。留空表示保持不变。", + "API_KEY_HELP": "API Key 加密保存,接口不会返回明文。", + "SAVE": "保存服务商配置", + "SAVING": "保存中..." + }, "MODEL_CONFIG": { "TITLE": "Model Configuration", "DESCRIPTION": "Select AI models for different features.", 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 fbf7342c..d74f5d50 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue @@ -1,9 +1,8 @@ + + diff --git a/frontend/app/javascript/dashboard/store/captain/preferences.js b/frontend/app/javascript/dashboard/store/captain/preferences.js index 12899195..2c60458f 100644 --- a/frontend/app/javascript/dashboard/store/captain/preferences.js +++ b/frontend/app/javascript/dashboard/store/captain/preferences.js @@ -6,6 +6,7 @@ export const useCaptainConfigStore = defineStore('captainConfig', { providers: {}, models: {}, features: {}, + providerConfig: {}, uiFlags: { isFetching: false, }, @@ -54,6 +55,7 @@ export const useCaptainConfigStore = defineStore('captainConfig', { this.providers = response.data.providers || {}; this.models = response.data.models || {}; this.features = response.data.features || {}; + this.providerConfig = response.data.provider_config || {}; } catch (error) { // Ignore error } finally { @@ -66,6 +68,7 @@ export const useCaptainConfigStore = defineStore('captainConfig', { this.providers = response.data.providers || {}; this.models = response.data.models || {}; this.features = response.data.features || {}; + this.providerConfig = response.data.provider_config || {}; }, }, });