diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0ea664d82c9..5b340e2173a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -197,6 +197,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + AIToolRewriteRules: dto.ParseAIToolRewriteRules(settings.AIToolRewriteRules), DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, RiskControlEnabled: settings.RiskControlEnabled, @@ -340,6 +341,67 @@ func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []serv return result } +func normalizeAIToolRewriteRules(input []dto.AIToolRewriteRule) ([]dto.AIToolRewriteRule, string) { + const ( + maxAIToolRewriteRules = 50 + maxAIToolRewriteRuleScopeLen = 32 + maxAIToolRewriteRulePatternLen = 20 * 1024 + ) + + if len(input) > maxAIToolRewriteRules { + return nil, "Too many AI tool rewrite rules (max 50)" + } + + validPlatforms := map[string]struct{}{ + "": {}, + "anthropic": {}, + "openai": {}, + "gemini": {}, + "antigravity": {}, + } + validClients := map[string]struct{}{ + "": {}, + "claude": {}, + "codex": {}, + "codex-ws": {}, + "gemini": {}, + "opencode": {}, + } + + rules := make([]dto.AIToolRewriteRule, 0, len(input)) + for _, rule := range input { + platform := strings.ToLower(strings.TrimSpace(rule.Platform)) + client := strings.ToLower(strings.TrimSpace(rule.Client)) + if len(platform) > maxAIToolRewriteRuleScopeLen { + return nil, "AI tool rewrite rule platform is too long (max 32 characters)" + } + if len(client) > maxAIToolRewriteRuleScopeLen { + return nil, "AI tool rewrite rule client is too long (max 32 characters)" + } + if _, ok := validPlatforms[platform]; !ok { + return nil, "AI tool rewrite rule platform is invalid" + } + if _, ok := validClients[client]; !ok { + return nil, "AI tool rewrite rule client is invalid" + } + if len(rule.Find) > maxAIToolRewriteRulePatternLen { + return nil, "AI tool rewrite rule find text is too long (max 20KB)" + } + if len(rule.Replace) > maxAIToolRewriteRulePatternLen { + return nil, "AI tool rewrite rule replace text is too long (max 20KB)" + } + rules = append(rules, dto.AIToolRewriteRule{ + Enabled: rule.Enabled, + Platform: platform, + Client: client, + Find: rule.Find, + Replace: rule.Replace, + }) + } + + return rules, "" +} + // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 @@ -430,20 +492,21 @@ type UpdateSettingsRequest struct { GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` // OEM设置 - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` + AIToolRewriteRules *[]dto.AIToolRewriteRule `json:"ai_tool_rewrite_rules"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -1220,6 +1283,21 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { customEndpointsJSON = string(endpointBytes) } + aiToolRewriteRulesJSON := previousSettings.AIToolRewriteRules + if req.AIToolRewriteRules != nil { + rules, errMessage := normalizeAIToolRewriteRules(*req.AIToolRewriteRules) + if errMessage != "" { + response.BadRequest(c, errMessage) + return + } + ruleBytes, err := json.Marshal(rules) + if err != nil { + response.BadRequest(c, "Failed to serialize AI tool rewrite rules") + return + } + aiToolRewriteRulesJSON = string(ruleBytes) + } + // Ops metrics collector interval validation (seconds). if req.OpsMetricsIntervalSeconds != nil { v := *req.OpsMetricsIntervalSeconds @@ -1360,6 +1438,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TablePageSizeOptions: req.TablePageSizeOptions, CustomMenuItems: customMenuJSON, CustomEndpoints: customEndpointsJSON, + AIToolRewriteRules: aiToolRewriteRulesJSON, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, AffiliateRebateRate: affiliateRebateRate, @@ -1744,6 +1823,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TablePageSizeOptions: updatedSettings.TablePageSizeOptions, CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), + AIToolRewriteRules: dto.ParseAIToolRewriteRules(updatedSettings.AIToolRewriteRules), DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, AffiliateRebateRate: updatedSettings.AffiliateRebateRate, @@ -2157,6 +2237,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.CustomEndpoints != after.CustomEndpoints { changed = append(changed, "custom_endpoints") } + if before.AIToolRewriteRules != after.AIToolRewriteRules { + changed = append(changed, "ai_tool_rewrite_rules") + } if before.EnableFingerprintUnification != after.EnableFingerprintUnification { changed = append(changed, "enable_fingerprint_unification") } diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go index 085fd2ca788..adfbbff8e63 100644 --- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go +++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go @@ -252,6 +252,129 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, true, data["openai_advanced_scheduler_enabled"]) } +func TestSettingHandler_UpdateSettings_PersistsAIToolRewriteRules(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{ + values: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + service.SettingKeyAIToolRewriteRules: "[]", + }, + } + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + + body := map[string]any{ + "promo_code_enabled": true, + "ai_tool_rewrite_rules": []map[string]any{ + { + "enabled": true, + "platform": " OpenAI ", + "client": " Codex ", + "find": `model = "gpt-5.4"`, + "replace": `model = "gpt-5.5"`, + }, + }, + } + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + require.JSONEq(t, `[{"enabled":true,"platform":"openai","client":"codex","find":"model = \"gpt-5.4\"","replace":"model = \"gpt-5.5\""}]`, repo.values[service.SettingKeyAIToolRewriteRules]) + + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + rules, ok := data["ai_tool_rewrite_rules"].([]any) + require.True(t, ok) + require.Len(t, rules, 1) + rule, ok := rules[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "openai", rule["platform"]) + require.Equal(t, "codex", rule["client"]) +} + +func TestSettingHandler_UpdateSettings_RejectsInvalidAIToolRewriteRules(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{ + values: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + service.SettingKeyAIToolRewriteRules: `[{"enabled":true,"platform":"openai","client":"codex","find":"old","replace":"new"}]`, + }, + } + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + + body := map[string]any{ + "promo_code_enabled": true, + "ai_tool_rewrite_rules": []map[string]any{ + { + "enabled": true, + "platform": "invalid", + "client": "codex", + "find": "old", + "replace": "new", + }, + }, + } + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.JSONEq(t, `[{"enabled":true,"platform":"openai","client":"codex","find":"old","replace":"new"}]`, repo.values[service.SettingKeyAIToolRewriteRules]) +} + +func TestSettingHandler_UpdateSettings_RejectsTooManyAIToolRewriteRules(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{ + values: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + service.SettingKeyAIToolRewriteRules: "[]", + }, + } + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + + rules := make([]map[string]any, 51) + for i := range rules { + rules[i] = map[string]any{ + "enabled": true, + "find": "old", + "replace": "new", + } + } + body := map[string]any{ + "promo_code_enabled": true, + "ai_tool_rewrite_rules": rules, + } + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, "[]", repo.values[service.SettingKeyAIToolRewriteRules]) +} + func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) { gin.SetMode(gin.TestMode) repo := &settingHandlerRepoStub{ diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 551cf0dc995..b53c2452aad 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -23,6 +23,15 @@ type CustomEndpoint struct { Description string `json:"description"` } +// AIToolRewriteRule 表示“使用密钥”弹窗配置内容的字符串替换规则。 +type AIToolRewriteRule struct { + Enabled bool `json:"enabled"` + Platform string `json:"platform"` + Client string `json:"client"` + Find string `json:"find"` + Replace string `json:"replace"` +} + // SystemSettings represents the admin settings API response payload. type SystemSettings struct { RegistrationEnabled bool `json:"registration_enabled"` @@ -107,20 +116,21 @@ type SystemSettings struct { GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + AIToolRewriteRules []AIToolRewriteRule `json:"ai_tool_rewrite_rules"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -260,6 +270,7 @@ type PublicSettings struct { TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + AIToolRewriteRules []AIToolRewriteRule `json:"ai_tool_rewrite_rules"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` @@ -395,3 +406,17 @@ func ParseCustomEndpoints(raw string) []CustomEndpoint { } return items } + +// ParseAIToolRewriteRules 将 JSON 字符串解析为 AI 工具配置替换规则。 +// 输入为空或格式无效时返回空数组。 +func ParseAIToolRewriteRules(raw string) []AIToolRewriteRule { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return []AIToolRewriteRule{} + } + var items []AIToolRewriteRule + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []AIToolRewriteRule{} + } + return items +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 6c389e3da1e..e0978eaf3bc 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -61,6 +61,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + AIToolRewriteRules: dto.ParseAIToolRewriteRules(settings.AIToolRewriteRules), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index 45d66f8e337..92dc1ea38bb 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -82,6 +82,45 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, resp.Data.ForceEmailOnThirdPartySignup) } +func TestSettingHandler_GetPublicSettings_ExposesAIToolRewriteRules(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &settingHandlerPublicRepoStub{ + values: map[string]string{ + service.SettingKeyAIToolRewriteRules: `[{"enabled":true,"platform":"openai","client":"codex","find":"from","replace":"to"}]`, + }, + } + h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) + + h.GetPublicSettings(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + AIToolRewriteRules []struct { + Enabled bool `json:"enabled"` + Platform string `json:"platform"` + Client string `json:"client"` + Find string `json:"find"` + Replace string `json:"replace"` + } `json:"ai_tool_rewrite_rules"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Len(t, resp.Data.AIToolRewriteRules, 1) + require.Equal(t, "openai", resp.Data.AIToolRewriteRules[0].Platform) + require.Equal(t, "codex", resp.Data.AIToolRewriteRules[0].Client) + require.Equal(t, "from", resp.Data.AIToolRewriteRules[0].Find) + require.Equal(t, "to", resp.Data.AIToolRewriteRules[0].Replace) +} + func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{ diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 39869d4dffc..dcc92af3c26 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -795,6 +795,7 @@ func TestAPIContracts(t *testing.T) { }, "custom_menu_items": [], "custom_endpoints": [], + "ai_tool_rewrite_rules": [], "payment_enabled": false, "payment_min_amount": 0, "payment_max_amount": 0, @@ -963,6 +964,7 @@ func TestAPIContracts(t *testing.T) { "table_page_size_options": [10, 20, 50], "custom_menu_items": [], "custom_endpoints": [], + "ai_tool_rewrite_rules": [], "default_concurrency": 0, "default_balance": 0, "affiliate_rebate_rate": 20, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 17c40ba1dc3..a424dd29fb4 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -206,6 +206,7 @@ const ( SettingKeyTablePageSizeOptions = "table_page_size_options" // 表格可选每页条数(JSON 数组) SettingKeyCustomMenuItems = "custom_menu_items" // 自定义菜单项(JSON 数组) SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表(JSON 数组) + SettingKeyAIToolRewriteRules = "ai_tool_rewrite_rules" // AI 工具配置替换规则(JSON 数组) // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 86978eecc4a..3cabaed73d5 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -605,6 +605,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyTablePageSizeOptions, SettingKeyCustomMenuItems, SettingKeyCustomEndpoints, + SettingKeyAIToolRewriteRules, SettingKeyLinuxDoConnectEnabled, SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, @@ -722,6 +723,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings TablePageSizeOptions: tablePageSizeOptions, CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomEndpoints: settings[SettingKeyCustomEndpoints], + AIToolRewriteRules: settings[SettingKeyAIToolRewriteRules], LinuxDoOAuthEnabled: linuxDoEnabled, WeChatOAuthEnabled: weChatEnabled, WeChatOAuthOpenEnabled: weChatOpenEnabled, @@ -925,6 +927,7 @@ type PublicSettingsInjectionPayload struct { TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems json.RawMessage `json:"custom_menu_items"` CustomEndpoints json.RawMessage `json:"custom_endpoints"` + AIToolRewriteRules json.RawMessage `json:"ai_tool_rewrite_rules"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` @@ -989,6 +992,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + AIToolRewriteRules: safeRawJSONArray(settings.AIToolRewriteRules), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, @@ -1567,6 +1571,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON) updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints + updates[SettingKeyAIToolRewriteRules] = settings.AIToolRewriteRules // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -2332,6 +2337,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyTablePageSizeOptions: "[10,20,50,100]", SettingKeyCustomMenuItems: "[]", SettingKeyCustomEndpoints: "[]", + SettingKeyAIToolRewriteRules: "[]", SettingKeyWeChatConnectEnabled: "false", SettingKeyWeChatConnectAppID: "", SettingKeyWeChatConnectAppSecret: "", @@ -2511,6 +2517,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomEndpoints: settings[SettingKeyCustomEndpoints], + AIToolRewriteRules: settings[SettingKeyAIToolRewriteRules], BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", } result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 1ecd4e6f416..582e010df80 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -78,6 +78,19 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions) } +func TestSettingService_GetPublicSettings_ExposesAIToolRewriteRules(t *testing.T) { + repo := &settingPublicRepoStub{ + values: map[string]string{ + SettingKeyAIToolRewriteRules: `[{"enabled":true,"platform":"openai","client":"codex","find":"model = \"gpt-5.4\"","replace":"model = \"gpt-5.5\""}]`, + }, + } + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.JSONEq(t, `[{"enabled":true,"platform":"openai","client":"codex","find":"model = \"gpt-5.4\"","replace":"model = \"gpt-5.5\""}]`, settings.AIToolRewriteRules) +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index bfe859951a2..ec1e3402162 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -121,6 +121,7 @@ type SystemSettings struct { TablePageSizeOptions []int CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints + AIToolRewriteRules string // AI 工具配置替换规则 JSON 数组 DefaultConcurrency int DefaultBalance float64 @@ -233,6 +234,7 @@ type PublicSettings struct { TablePageSizeOptions []int CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints + AIToolRewriteRules string // AI 工具配置替换规则 JSON 数组 LinuxDoOAuthEnabled bool WeChatOAuthEnabled bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 03e9e58fde5..9e92e7e47a5 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -5,6 +5,7 @@ import { apiClient } from "../client"; import type { + AIToolRewriteRule, CustomEndpoint, CustomMenuItem, LoginAgreementDocument, @@ -377,6 +378,7 @@ export interface SystemSettings { backend_mode_enabled: boolean; custom_menu_items: CustomMenuItem[]; custom_endpoints: CustomEndpoint[]; + ai_tool_rewrite_rules: AIToolRewriteRule[]; // SMTP settings smtp_host: string; smtp_port: number; @@ -595,6 +597,7 @@ export interface UpdateSettingsRequest { backend_mode_enabled?: boolean; custom_menu_items?: CustomMenuItem[]; custom_endpoints?: CustomEndpoint[]; + ai_tool_rewrite_rules?: AIToolRewriteRule[]; smtp_host?: string; smtp_port?: number; smtp_username?: string; diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 94010b62353..5a3ad001572 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -139,7 +139,7 @@ import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' -import type { GroupPlatform } from '@/types' +import type { AIToolRewriteRule, GroupPlatform } from '@/types' interface Props { show: boolean @@ -147,6 +147,7 @@ interface Props { baseUrl: string platform: GroupPlatform | null allowMessagesDispatch?: boolean + aiToolRewriteRules?: AIToolRewriteRule[] } interface Emits { @@ -394,45 +395,103 @@ const currentFiles = computed((): FileConfig[] => { return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta` })() + const platform = props.platform || 'anthropic' + let files: FileConfig[] + if (activeClientTab.value === 'opencode') { switch (props.platform) { case 'anthropic': - return [generateOpenCodeConfig('anthropic', apiBase, apiKey)] + files = [generateOpenCodeConfig('anthropic', apiBase, apiKey)] + break case 'openai': - return [generateOpenCodeConfig('openai', apiBase, apiKey)] + files = [generateOpenCodeConfig('openai', apiBase, apiKey)] + break case 'gemini': - return [generateOpenCodeConfig('gemini', geminiBase, apiKey)] + files = [generateOpenCodeConfig('gemini', geminiBase, apiKey)] + break case 'antigravity': - return [ + files = [ generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'), generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)') ] + break default: - return [generateOpenCodeConfig('openai', apiBase, apiKey)] + files = [generateOpenCodeConfig('openai', apiBase, apiKey)] } + + return applyAiToolRewriteRules(files, { + platform, + client: 'opencode', + }) } switch (props.platform) { case 'openai': if (activeClientTab.value === 'claude') { - return generateAnthropicFiles(baseUrl, apiKey) + files = generateAnthropicFiles(baseUrl, apiKey) + break } if (activeClientTab.value === 'codex-ws') { - return generateOpenAIWsFiles(baseUrl, apiKey) + files = generateOpenAIWsFiles(baseUrl, apiKey) + break } - return generateOpenAIFiles(baseUrl, apiKey) + files = generateOpenAIFiles(baseUrl, apiKey) + break case 'gemini': - return [generateGeminiCliContent(baseUrl, apiKey)] + files = [generateGeminiCliContent(baseUrl, apiKey)] + break case 'antigravity': if (activeClientTab.value === 'gemini') { - return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)] + files = [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)] + break } - return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey) + files = generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey) + break default: - return generateAnthropicFiles(baseUrl, apiKey) + files = generateAnthropicFiles(baseUrl, apiKey) } + + return applyAiToolRewriteRules(files, { + platform, + client: activeClientTab.value, + }) }) +function applyAiToolRewriteRules( + files: FileConfig[], + context: { platform: string; client: string }, +): FileConfig[] { + const rules = props.aiToolRewriteRules + if (!Array.isArray(rules) || rules.length === 0) { + return files + } + + const matchedRules = rules.filter((rule: AIToolRewriteRule) => { + if (!rule?.enabled || !rule.find) return false + const platform = rule.platform || '' + const client = rule.client || '' + return (!platform || platform === context.platform) && (!client || client === context.client) + }) + if (matchedRules.length === 0) { + return files + } + + return files.map((file) => { + let content = file.content + for (const rule of matchedRules) { + content = content.split(rule.find).join(rule.replace) + } + if (content === file.content) { + return file + } + return { + ...file, + content, + highlighted: undefined, + } + }) +} + function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] { let path: string let content: string diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index f7db586ac52..6b9d83726bf 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' @@ -17,6 +17,10 @@ vi.mock('@/composables/useClipboard', () => ({ import UseKeyModal from '../UseKeyModal.vue' describe('UseKeyModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('renders GPT-5.4 mini entry in OpenCode config', async () => { const wrapper = mount(UseKeyModal, { props: { @@ -50,4 +54,89 @@ describe('UseKeyModal', () => { expect(codeBlock.text()).toContain('"name": "GPT-5.4 Mini"') expect(codeBlock.text()).not.toContain('"name": "GPT-5.4 Nano"') }) + + it('applies matching rewrite rules to content only', async () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-test', + baseUrl: 'https://example.com/v1', + platform: 'openai', + aiToolRewriteRules: [ + { + enabled: true, + platform: 'openai', + client: 'codex', + find: 'model = "gpt-5.4"', + replace: 'model = "gpt-5.5"' + }, + { + enabled: true, + platform: '', + client: '', + find: '~/.codex/config.toml', + replace: '~/.other/config.toml' + } + ] + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const codeBlocks = wrapper.findAll('pre code') + expect(codeBlocks[0]?.text()).toContain('model = "gpt-5.5"') + expect(codeBlocks[0]?.text()).not.toContain('model = "gpt-5.4"') + expect(wrapper.text()).toContain('~/.codex/config.toml') + expect(wrapper.text()).not.toContain('~/.other/config.toml') + }) + + it('skips disabled and non-matching rewrite rules', () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-test', + baseUrl: 'https://example.com/v1', + platform: 'openai', + aiToolRewriteRules: [ + { + enabled: false, + platform: 'openai', + client: 'codex', + find: 'gpt-5.4', + replace: 'disabled-model' + }, + { + enabled: true, + platform: 'gemini', + client: 'gemini', + find: 'gpt-5.4', + replace: 'wrong-platform' + } + ] + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const codeBlock = wrapper.find('pre code') + expect(codeBlock.text()).toContain('gpt-5.4') + expect(codeBlock.text()).not.toContain('disabled-model') + expect(codeBlock.text()).not.toContain('wrong-platform') + }) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 02d044ef704..dcbae5bb962 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5412,6 +5412,21 @@ export default { descriptionPlaceholder: 'e.g., Supports OpenAI format requests', add: 'Add Endpoint', }, + aiToolRewriteRules: { + title: 'AI Tool Config Rewrite Rules', + description: 'Run plain string replacements on generated "Use Key" tool config content, in rule order', + publicWarning: 'Rules are delivered through public settings. Do not enter API keys, tokens, or other secrets.', + itemLabel: 'Rule #{n}', + platform: 'Platform', + client: 'Tool', + anyPlatform: 'All platforms', + anyClient: 'All tools', + find: 'Find', + findPlaceholder: 'e.g., model = "gpt-5.4"', + replace: 'Replace with', + replacePlaceholder: 'e.g., model = "gpt-5.5"', + add: 'Add Rule', + }, contactInfo: 'Contact Info', contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 687c2df644b..d6f5108027a 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5570,6 +5570,21 @@ export default { descriptionPlaceholder: '如:支持 OpenAI 格式请求', add: '添加端点', }, + aiToolRewriteRules: { + title: 'AI 工具配置替换规则', + description: '对「使用密钥」弹窗生成的工具配置内容做普通字符串替换,按规则顺序执行', + publicWarning: '规则会通过公开设置下发给用户侧,请不要填写 API Key、Token 或其他敏感信息。', + itemLabel: '规则 #{n}', + platform: '平台', + client: '工具', + anyPlatform: '全部平台', + anyClient: '全部工具', + find: '查找内容', + findPlaceholder: '例如:model = "gpt-5.4"', + replace: '替换为', + replacePlaceholder: '例如:model = "gpt-5.5"', + add: '添加规则', + }, contactInfo: '客服联系方式', contactInfoPlaceholder: '例如:QQ: 123456789', contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 4d701b2efb9..3f80dc99f08 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -340,6 +340,7 @@ export const useAppStore = defineStore('app', () => { table_page_size_options: [10, 20, 50, 100], custom_menu_items: [], custom_endpoints: [], + ai_tool_rewrite_rules: [], linuxdo_oauth_enabled: false, wechat_oauth_enabled: false, wechat_oauth_open_enabled: false, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ec7d0636e70..2acf7e17cbe 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -179,6 +179,14 @@ export interface CustomEndpoint { description: string } +export interface AIToolRewriteRule { + enabled: boolean + platform: '' | GroupPlatform + client: '' | 'claude' | 'codex' | 'codex-ws' | 'gemini' | 'opencode' + find: string + replace: string +} + export interface LoginAgreementDocument { id: string title: string @@ -214,6 +222,7 @@ export interface PublicSettings { table_page_size_options: number[] custom_menu_items: CustomMenuItem[] custom_endpoints: CustomEndpoint[] + ai_tool_rewrite_rules?: AIToolRewriteRule[] linuxdo_oauth_enabled: boolean wechat_oauth_enabled: boolean wechat_oauth_open_enabled?: boolean diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7c3735b6200..f6fcf93a352 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4185,6 +4185,133 @@ + +
+ +

+ {{ t("admin.settings.site.aiToolRewriteRules.description") }} +

+

+ {{ t("admin.settings.site.aiToolRewriteRules.publicWarning") }} +

+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+