Merge pull request #1956 from QuantumNous/alpha

alpha -> main
This commit is contained in:
Calcium-Ion
2025-10-02 15:26:59 +08:00
committed by GitHub
19 changed files with 1141 additions and 110 deletions

View File

@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
GotifyUrl string `json:"gotify_url,omitempty"`
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是Gotify类型验证Gotify URL和Token
if req.QuotaWarningType == dto.NotifyTypeGotify {
if req.GotifyUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址不能为空",
})
return
}
if req.GotifyToken == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify令牌不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Gotify服务器地址",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址必须以http://或https://开头",
})
return
}
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
settings.BarkUrl = req.BarkUrl
}
// 如果是Gotify类型添加Gotify配置到设置中
if req.QuotaWarningType == dto.NotifyTypeGotify {
settings.GotifyUrl = req.GotifyUrl
settings.GotifyToken = req.GotifyToken
// Gotify优先级范围0-10超出范围则使用默认值5
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
settings.GotifyPriority = 5
} else {
settings.GotifyPriority = req.GotifyPriority
}
}
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {

View File

@@ -20,6 +20,9 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {

View File

@@ -195,12 +195,15 @@ type ClaudeRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
//ClaudeMetadata `json:"metadata,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私
SafetyIdentifier string `json:"safety_identifier,omitempty"`
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
Store json.RawMessage `json:"store,omitempty"`
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Prediction json.RawMessage `json:"prediction,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
@@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct {
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -7,6 +7,9 @@ type UserSetting struct {
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
)

View File

@@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, errors.New("video无效的请求, " + err.Error())
}
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID

View File

@@ -91,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
}
a.AccountCredentials = *adc
if a.RequestMode == RequestModeLlama {
if a.RequestMode == RequestModeGemini {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeClaude {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
@@ -99,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
region,
), nil
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else {
var keyPrefix string
if strings.HasSuffix(suffix, "?alt=sse") {
keyPrefix = "&"
} else {
keyPrefix = "?"
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
}
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -188,7 +215,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("Authorization", "Bearer "+accessToken)
}
if a.AccountCredentials.ProjectID != "" {
if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
return nil

View File

@@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for Claude API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -509,3 +509,43 @@ type TaskInfo struct {
CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
}
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段可能导致额外计费OpenAI、Claude、Responses API 支持)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) {
var data map[string]interface{}
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
return jsonData, nil
}
// 默认移除 service_tier除非明确允许避免额外计费风险
if !channelOtherSettings.AllowServiceTier {
if _, exists := data["service_tier"]; exists {
delete(data, "service_tier")
}
}
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
if channelOtherSettings.DisableStore {
if _, exists := data["store"]; exists {
delete(data, "store")
}
}
// 默认移除 safety_identifier除非明确允许保护用户隐私避免向 OpenAI 报告用户信息)
if !channelOtherSettings.AllowSafetyIdentifier {
if _, exists := data["safety_identifier"]; exists {
delete(data, "safety_identifier")
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}

View File

@@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI Responses API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
// 默认内容格式适用于Email和Webhook
// 默认内容格式适用于Email和Webhook支持HTML
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}

View File

@@ -1,6 +1,8 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
switch notifyType {
case dto.NotifyTypeEmail:
// check setting email
userEmail = userSetting.NotificationEmail
if userEmail == "" {
// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
emailToUse := userSetting.NotificationEmail
if emailToUse == "" {
emailToUse = userEmail
}
if emailToUse == "" {
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
return nil
}
return sendEmailNotify(userEmail, data)
return sendEmailNotify(emailToUse, data)
case dto.NotifyTypeWebhook:
webhookURLStr := userSetting.WebhookUrl
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
return nil
}
return sendBarkNotify(barkURL, data)
case dto.NotifyTypeGotify:
gotifyUrl := userSetting.GotifyUrl
gotifyToken := userSetting.GotifyToken
if gotifyUrl == "" || gotifyToken == "" {
common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
return nil
}
return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
}
return nil
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return nil
}
func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
// 处理占位符
content := data.Content
for _, value := range data.Values {
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
}
// 构建完整的 Gotify API URL
// 确保 URL 以 /message 结尾
finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
// Gotify优先级范围0-10如果超出范围则使用默认值5
if priority < 0 || priority > 10 {
priority = 5
}
// 构建 JSON payload
type GotifyMessage struct {
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
}
payload := GotifyMessage{
Title: data.Title,
Message: content,
Priority: priority,
}
// 序列化为 JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal gotify payload: %v", err)
}
var req *http.Request
var resp *http.Response
if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "OneAPI-Gotify-Notify/1.0",
},
Body: payloadBytes,
}
resp, err = DoWorkerRequest(workerReq)
if err != nil {
return fmt.Errorf("failed to send gotify request through worker: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
} else {
// SSRF防护验证Gotify URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
// 直接发送请求
req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create gotify request: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
// 发送请求
client := GetHttpClient()
resp, err = client.Do(req)
if err != nil {
return fmt.Errorf("failed to send gotify request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
}
return nil
}

View File

@@ -81,6 +81,9 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
gotifyUrl: settings.gotify_url || '',
gotifyToken: settings.gotify_token || '',
gotifyPriority:
settings.gotify_priority !== undefined
? settings.gotify_priority
: 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
gotify_url: notificationSettings.gotifyUrl,
gotify_token: notificationSettings.gotifyToken,
gotify_priority: (() => {
const parsed = parseInt(notificationSettings.gotifyPriority);
return isNaN(parsed) ? 5 : parsed;
})(),
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,

View File

@@ -400,6 +400,7 @@ const NotificationSettings = ({
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
<Radio value='bark'>{t('Bark通知')}</Radio>
<Radio value='gotify'>{t('Gotify通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Bark 官方文档
Bark {t('官方文档')}
</a>
</div>
</div>
</div>
</>
)}
{/* Gotify推送设置 */}
{notificationSettings.warningType === 'gotify' && (
<>
<Form.Input
field='gotifyUrl'
label={t('Gotify服务器地址')}
placeholder={t(
'请输入Gotify服务器地址例如: https://gotify.example.com',
)}
onChange={(val) => handleFormChange('gotifyUrl', val)}
prefix={<IconLink />}
extraText={t(
'支持HTTP和HTTPS填写Gotify服务器的完整URL地址',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify服务器地址'),
},
{
pattern: /^https?:\/\/.+/,
message: t('Gotify服务器地址必须以http://或https://开头'),
},
]}
/>
<Form.Input
field='gotifyToken'
label={t('Gotify应用令牌')}
placeholder={t('请输入Gotify应用令牌')}
onChange={(val) => handleFormChange('gotifyToken', val)}
prefix={<IconKey />}
extraText={t(
'在Gotify服务器创建应用后获得的令牌用于发送通知',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify应用令牌'),
},
]}
/>
<Form.AutoComplete
field='gotifyPriority'
label={t('消息优先级')}
placeholder={t('请选择消息优先级')}
data={[
{ value: 0, label: t('0 - 最低') },
{ value: 2, label: t('2 - 低') },
{ value: 5, label: t('5 - 正常(默认)') },
{ value: 8, label: t('8 - 高') },
{ value: 10, label: t('10 - 最高') },
]}
onChange={(val) =>
handleFormChange('gotifyPriority', val)
}
prefix={<IconBell />}
extraText={t('消息优先级范围0-10默认为5')}
style={{ width: '100%', maxWidth: '300px' }}
/>
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
<div className='text-sm text-gray-700 mb-3'>
<strong>{t('配置说明')}</strong>
</div>
<div className='text-xs text-gray-500 space-y-2'>
<div>
1. {t('在Gotify服务器的应用管理中创建新应用')}
</div>
<div>
2.{' '}
{t(
'复制应用的令牌Token并填写到上方的应用令牌字段',
)}
</div>
<div>
3. {t('填写Gotify服务器的完整URL地址')}
</div>
<div className='mt-3 pt-3 border-t border-gray-200'>
<span className='text-gray-400'>
{t('更多信息请参考')}
</span>{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Gotify {t('官方文档')}
</a>
</div>
</div>

View File

@@ -68,6 +68,8 @@ import {
IconCode,
IconGlobe,
IconBolt,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -169,6 +171,10 @@ const EditChannelModal = (props) => {
vertex_key_type: 'json',
// 企业账户设置
is_enterprise_account: false,
// 字段透传控制默认值
allow_service_tier: false,
disable_store: false, // false = 允许透传(默认开启)
allow_safety_identifier: false,
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -202,6 +208,27 @@ const EditChannelModal = (props) => {
keyData: '',
});
// 专门的2FA验证状态用于TwoFactorAuthModal
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
// 表单块导航相关状态
const formSectionRefs = useRef({
basicInfo: null,
apiConfig: null,
modelConfig: null,
advancedSettings: null,
channelExtraSettings: null,
});
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings'];
const formContainerRef = useRef(null);
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState((prev) => ({ ...prev, ...updates }));
};
// 使用通用安全验证 Hook
const {
isModalVisible,
@@ -241,6 +268,44 @@ const EditChannelModal = (props) => {
});
};
// 重置2FA验证状态
const reset2FAVerifyState = () => {
setShow2FAVerifyModal(false);
setVerifyCode('');
setVerifyLoading(false);
};
// 表单导航功能
const scrollToSection = (sectionKey) => {
const sectionElement = formSectionRefs.current[sectionKey];
if (sectionElement) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
};
const navigateToSection = (direction) => {
const availableSections = formSections.filter(section => {
if (section === 'apiConfig') {
return showApiConfigCard;
}
return true;
});
let newIndex;
if (direction === 'up') {
newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1;
} else {
newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
}
setCurrentSectionIndex(newIndex);
scrollToSection(availableSections[newIndex]);
};
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -453,17 +518,27 @@ const EditChannelModal = (props) => {
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
// 读取企业账户设置
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
// 读取字段透传控制设置
data.allow_service_tier = parsedSettings.allow_service_tier || false;
data.disable_store = parsedSettings.disable_store || false;
data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false;
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
} else {
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
if (
@@ -715,6 +790,8 @@ const EditChannelModal = (props) => {
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
// 重置导航状态
setCurrentSectionIndex(0);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -900,21 +977,33 @@ const EditChannelModal = (props) => {
};
localInputs.setting = JSON.stringify(channelExtraSettings);
// 处理type === 20的企业账户设置
if (localInputs.type === 20) {
let settings = {};
if (localInputs.settings) {
try {
settings = JSON.parse(localInputs.settings);
} catch (error) {
console.error('解析settings失败:', error);
}
// 处理 settings 字段(包括企业账户设置和字段透传控制)
let settings = {};
if (localInputs.settings) {
try {
settings = JSON.parse(localInputs.settings);
} catch (error) {
console.error('解析settings失败:', error);
}
// 设置企业账户标识无论是true还是false都要传到后端
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
localInputs.settings = JSON.stringify(settings);
}
// type === 20: 设置企业账户标识无论是true还是false都要传到后端
if (localInputs.type === 20) {
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
}
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
if (localInputs.type === 1 || localInputs.type === 14) {
settings.allow_service_tier = localInputs.allow_service_tier === true;
// 仅 OpenAI 渠道需要 store 和 safety_identifier
if (localInputs.type === 1) {
settings.disable_store = localInputs.disable_store === true;
settings.allow_safety_identifier = localInputs.allow_safety_identifier === true;
}
}
localInputs.settings = JSON.stringify(settings);
// 清理不需要发送到后端的字段
delete localInputs.force_format;
delete localInputs.thinking_to_content;
@@ -925,6 +1014,10 @@ const EditChannelModal = (props) => {
delete localInputs.is_enterprise_account;
// 顶层的 vertex_key_type 不应发送给后端
delete localInputs.vertex_key_type;
// 清理字段透传控制的临时字段
delete localInputs.allow_service_tier;
delete localInputs.disable_store;
delete localInputs.allow_safety_identifier;
let res;
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -1240,7 +1333,41 @@ const EditChannelModal = (props) => {
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<div className='flex justify-between items-center bg-white'>
<div className='flex gap-2'>
<Button
size='small'
type='tertiary'
icon={<IconChevronUp />}
onClick={() => navigateToSection('up')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('上一个表单块')}
/>
<Button
size='small'
type='tertiary'
icon={<IconChevronDown />}
onClick={() => navigateToSection('down')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('下一个表单块')}
/>
</div>
<Space>
<Button
theme='solid'
@@ -1271,10 +1398,14 @@ const EditChannelModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<div
className='p-2'
ref={formContainerRef}
>
<div ref={el => formSectionRefs.current.basicInfo = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
@@ -1748,13 +1879,15 @@ const EditChannelModal = (props) => {
}
/>
)}
</Card>
</Card>
</div>
{/* API Configuration Card */}
{showApiConfigCard && (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.apiConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
@@ -1965,13 +2098,15 @@ const EditChannelModal = (props) => {
/>
</div>
)}
</Card>
</Card>
</div>
)}
{/* Model Configuration Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.modelConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
@@ -2166,12 +2301,14 @@ const EditChannelModal = (props) => {
formApi={formApiRef.current}
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
/>
</Card>
</Card>
</div>
{/* Advanced Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.advancedSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
@@ -2384,12 +2521,84 @@ const EditChannelModal = (props) => {
'键为原状态码,值为要复写的状态码,仅影响本地判断',
)}
/>
</Card>
{/* 字段透传控制 - OpenAI 渠道 */}
{inputs.type === 1 && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
<Form.Switch
field='disable_store'
label={t('禁用 store 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('disable_store', value)
}
extraText={t(
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
)}
/>
<Form.Switch
field='allow_safety_identifier'
label={t('允许 safety_identifier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_safety_identifier', value)
}
extraText={t(
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
)}
/>
</>
)}
{/* 字段透传控制 - Claude 渠道 */}
{(inputs.type === 14) && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
</>
)}
</Card>
</div>
{/* Channel Extra Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='violet'
@@ -2487,7 +2696,8 @@ const EditChannelModal = (props) => {
'如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面',
)}
/>
</Card>
</Card>
</div>
</div>
</Spin>
)}

View File

@@ -1313,6 +1313,8 @@
"请输入Webhook地址例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook",
"邮件通知": "Email notification",
"Webhook通知": "Webhook notification",
"Bark通知": "Bark notification",
"Gotify通知": "Gotify notification",
"接口凭证(可选)": "Interface credentials (optional)",
"密钥将以 Bearer 方式添加到请求头中用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request",
"Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key",
@@ -1323,6 +1325,36 @@
"通知邮箱": "Notification email",
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
"留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
"Bark推送URL": "Bark Push URL",
"请输入Bark推送URL例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}",
"支持HTTP和HTTPS模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)",
"请输入Bark推送URL": "Please enter Bark push URL",
"Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://",
"模板示例": "Template example",
"更多参数请参考": "For more parameters, please refer to",
"Gotify服务器地址": "Gotify server address",
"请输入Gotify服务器地址例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com",
"支持HTTP和HTTPS填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server",
"请输入Gotify服务器地址": "Please enter Gotify server address",
"Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://",
"Gotify应用令牌": "Gotify application token",
"请输入Gotify应用令牌": "Please enter Gotify application token",
"在Gotify服务器创建应用后获得的令牌用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications",
"消息优先级": "Message priority",
"请选择消息优先级": "Please select message priority",
"0 - 最低": "0 - Lowest",
"2 - 低": "2 - Low",
"5 - 正常(默认)": "5 - Normal (default)",
"8 - 高": "8 - High",
"10 - 最高": "10 - Highest",
"消息优先级范围0-10默认为5": "Message priority, range 0-10, default is 5",
"配置说明": "Configuration instructions",
"在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management",
"复制应用的令牌Token并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above",
"填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server",
"更多信息请参考": "For more information, please refer to",
"通知内容": "Notification content",
"官方文档": "Official documentation",
"API地址": "Base URL",
"对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
"渠道额外设置": "Channel extra settings",
@@ -2191,6 +2223,13 @@
"输入 Origin 后回车https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
"保存 Passkey 设置": "Save Passkey Settings",
"黑名单": "Blacklist",
"字段透传控制": "Field Pass-through Control",
"允许 service_tier 透传": "Allow service_tier Pass-through",
"禁用 store 透传": "Disable store Pass-through",
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges",
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction",
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy",
"common": {
"changeLanguage": "Change Language"
}

View File

@@ -1308,6 +1308,8 @@
"请输入Webhook地址例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook",
"邮件通知": "Notification par e-mail",
"Webhook通知": "Notification par Webhook",
"Bark通知": "Notification Bark",
"Gotify通知": "Notification Gotify",
"接口凭证(可选)": "Informations d'identification de l'interface (facultatif)",
"密钥将以 Bearer 方式添加到请求头中用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook",
"Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète",
@@ -1318,6 +1320,36 @@
"通知邮箱": "E-mail de notification",
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée",
"留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée",
"Bark推送URL": "URL de notification Bark",
"请输入Bark推送URL例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}",
"支持HTTP和HTTPS模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)",
"请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark",
"Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://",
"模板示例": "Exemple de modèle",
"更多参数请参考": "Pour plus de paramètres, veuillez vous référer à",
"Gotify服务器地址": "Adresse du serveur Gotify",
"请输入Gotify服务器地址例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com",
"支持HTTP和HTTPS填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify",
"请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify",
"Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://",
"Gotify应用令牌": "Jeton d'application Gotify",
"请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify",
"在Gotify服务器创建应用后获得的令牌用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications",
"消息优先级": "Priorité du message",
"请选择消息优先级": "Veuillez sélectionner la priorité du message",
"0 - 最低": "0 - La plus basse",
"2 - 低": "2 - Basse",
"5 - 正常(默认)": "5 - Normale (par défaut)",
"8 - 高": "8 - Haute",
"10 - 最高": "10 - La plus haute",
"消息优先级范围0-10默认为5": "Priorité du message, plage 0-10, par défaut 5",
"配置说明": "Instructions de configuration",
"在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify",
"复制应用的令牌Token并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus",
"填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify",
"更多信息请参考": "Pour plus d'informations, veuillez vous référer à",
"通知内容": "Contenu de la notification",
"官方文档": "Documentation officielle",
"API地址": "URL de base",
"对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir",
"渠道额外设置": "Paramètres supplémentaires du canal",
@@ -2135,6 +2167,13 @@
"关闭侧边栏": "Fermer la barre latérale",
"定价": "Tarification",
"语言": "Langue",
"字段透传控制": "Contrôle du passage des champs",
"允许 service_tier 透传": "Autoriser le passage de service_tier",
"禁用 store 透传": "Désactiver le passage de store",
"允许 safety_identifier 透传": "Autoriser le passage de safety_identifier",
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires",
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs",
"common": {
"changeLanguage": "Changer de langue"
}

View File

@@ -18,7 +18,26 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Form,
Space,
Spin,
RadioGroup,
Radio,
Table,
Modal,
Input,
Divider,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconEdit,
IconDelete,
IconSearch,
IconSaveStroked,
} from '@douyinfe/semi-icons';
import {
compareObjects,
API,
@@ -37,6 +56,52 @@ export default function SettingsChats(props) {
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [editMode, setEditMode] = useState('visual');
const [chatConfigs, setChatConfigs] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingConfig, setEditingConfig] = useState(null);
const [isEdit, setIsEdit] = useState(false);
const [searchText, setSearchText] = useState('');
const modalFormRef = useRef();
const jsonToConfigs = (jsonString) => {
try {
const configs = JSON.parse(jsonString);
return Array.isArray(configs)
? configs.map((config, index) => ({
id: index,
name: Object.keys(config)[0] || '',
url: Object.values(config)[0] || '',
}))
: [];
} catch (error) {
console.error('JSON parse error:', error);
return [];
}
};
const configsToJson = (configs) => {
const jsonArray = configs.map((config) => ({
[config.name]: config.url,
}));
return JSON.stringify(jsonArray, null, 2);
};
const syncJsonToConfigs = () => {
const configs = jsonToConfigs(inputs.Chats);
setChatConfigs(configs);
};
const syncConfigsToJson = (configs) => {
const jsonString = configsToJson(configs);
setInputs((prev) => ({
...prev,
Chats: jsonString,
}));
if (refForm.current && editMode === 'json') {
refForm.current.setValues({ Chats: jsonString });
}
};
async function onSubmit() {
try {
@@ -103,16 +168,184 @@ export default function SettingsChats(props) {
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
if (refForm.current) {
refForm.current.setValues(currentInputs);
}
// 同步到可视化配置
const configs = jsonToConfigs(currentInputs.Chats || '[]');
setChatConfigs(configs);
}, [props.options]);
useEffect(() => {
if (editMode === 'visual') {
syncJsonToConfigs();
}
}, [inputs.Chats, editMode]);
useEffect(() => {
if (refForm.current && editMode === 'json') {
refForm.current.setValues(inputs);
}
}, [editMode, inputs]);
const handleAddConfig = () => {
setEditingConfig({ name: '', url: '' });
setIsEdit(false);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues({ name: '', url: '' });
}
}, 100);
};
const handleEditConfig = (config) => {
setEditingConfig({ ...config });
setIsEdit(true);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues(config);
}
}, 100);
};
const handleDeleteConfig = (id) => {
const newConfigs = chatConfigs.filter((config) => config.id !== id);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
showSuccess(t('删除成功'));
};
const handleModalOk = () => {
if (modalFormRef.current) {
modalFormRef.current
.validate()
.then((values) => {
// 检查名称是否重复
const isDuplicate = chatConfigs.some(
(config) =>
config.name === values.name &&
(!isEdit || config.id !== editingConfig.id)
);
if (isDuplicate) {
showError(t('聊天应用名称已存在,请使用其他名称'));
return;
}
if (isEdit) {
const newConfigs = chatConfigs.map((config) =>
config.id === editingConfig.id
? { ...editingConfig, name: values.name, url: values.url }
: config,
);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
} else {
const maxId =
chatConfigs.length > 0
? Math.max(...chatConfigs.map((c) => c.id))
: -1;
const newConfig = {
id: maxId + 1,
name: values.name,
url: values.url,
};
const newConfigs = [...chatConfigs, newConfig];
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
}
setModalVisible(false);
setEditingConfig(null);
showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
})
.catch((error) => {
console.error('Modal form validation error:', error);
});
}
};
const handleModalCancel = () => {
setModalVisible(false);
setEditingConfig(null);
};
const filteredConfigs = chatConfigs.filter(
(config) =>
!searchText ||
config.name.toLowerCase().includes(searchText.toLowerCase()),
);
const highlightKeywords = (text) => {
if (!text) return text;
const parts = text.split(/(\{address\}|\{key\})/g);
return parts.map((part, index) => {
if (part === '{address}') {
return (
<span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
{part}
</span>
);
} else if (part === '{key}') {
return (
<span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
{part}
</span>
);
}
return part;
});
};
const columns = [
{
title: t('聊天应用名称'),
dataIndex: 'name',
key: 'name',
render: (text) => text || t('未命名'),
},
{
title: t('URL链接'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
{highlightKeywords(text)}
</div>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
<Button
type='primary'
icon={<IconEdit />}
size='small'
onClick={() => handleEditConfig(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
icon={<IconDelete />}
size='small'
onClick={() => handleDeleteConfig(record.id)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Space vertical style={{ width: '100%' }}>
<Form.Section text={t('聊天设置')}>
<Banner
type='info'
@@ -120,34 +353,155 @@ export default function SettingsChats(props) {
'链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
)}
/>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
<Divider />
<div style={{ marginBottom: 16 }}>
<span style={{ marginRight: 16, fontWeight: 600 }}>
{t('编辑模式')}:
</span>
<RadioGroup
type='button'
value={editMode}
onChange={(e) => {
const newMode = e.target.value;
setEditMode(newMode);
// 确保模式切换时数据正确同步
setTimeout(() => {
if (newMode === 'json' && refForm.current) {
refForm.current.setValues(inputs);
}
}, 100);
}}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='json'>{t('JSON编辑')}</Radio>
</RadioGroup>
</div>
{editMode === 'visual' ? (
<div>
<Space style={{ marginBottom: 16 }}>
<Button
type='primary'
icon={<IconPlus />}
onClick={handleAddConfig}
>
{t('添加聊天配置')}
</Button>
<Button
type='primary'
theme='solid'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索聊天应用名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: 250 }}
showClear
/>
</Space>
<Table
columns={columns}
dataSource={filteredConfigs}
rowKey='id'
pagination={{
pageSize: 10,
showSizeChanger: false,
showQuickJumper: true,
showTotal: (total, range) =>
t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
total,
start: range[0],
end: range[1],
}),
}}
/>
</div>
) : (
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
</Form>
)}
</Form.Section>
</Form>
<Space>
<Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
{editMode === 'json' && (
<Space>
<Button
type='primary'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
</Space>
)}
</Space>
<Modal
title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
visible={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form getFormApi={(api) => (modalFormRef.current = api)}>
<Form.Input
field='name'
label={t('聊天应用名称')}
placeholder={t('请输入聊天应用名称')}
rules={[
{ required: true, message: t('请输入聊天应用名称') },
{ min: 1, message: t('名称不能为空') },
]}
/>
<Form.Input
field='url'
label={t('URL链接')}
placeholder={t('请输入完整的URL链接')}
rules={[{ required: true, message: t('请输入URL链接') }]}
/>
<Banner
type='info'
description={t(
'提示:链接中的{key}将被替换为API密钥{address}将被替换为服务器地址',
)}
style={{ marginTop: 16 }}
/>
</Form>
</Modal>
</Spin>
);
}