Просмотр исходного кода

Merge branch 'main' into french-translation-final

comeback01 4 месяцев назад
Родитель
Сommit
6ee01d75a6

+ 2 - 1
constant/channel.go

@@ -51,9 +51,9 @@ const (
 	ChannelTypeJimeng         = 51
 	ChannelTypeVidu           = 52
 	ChannelTypeSubmodel       = 53
+	ChannelTypeDoubaoVideo    = 54
 	ChannelTypeDummy          // this one is only for count, do not add any channel after this
 
-
 )
 
 var ChannelBaseURLs = []string{
@@ -111,4 +111,5 @@ var ChannelBaseURLs = []string{
 	"https://visual.volcengineapi.com",          //51
 	"https://api.vidu.cn",                       //52
 	"https://llm.submodel.ai",                   //53
+	"https://ark.cn-beijing.volces.com",         //54
 }

+ 6 - 0
controller/channel-test.go

@@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
 			newAPIError: nil,
 		}
 	}
+	if channel.Type == constant.ChannelTypeDoubaoVideo {
+		return testResult{
+			localErr:    errors.New("doubao video channel test is not supported"),
+			newAPIError: nil,
+		}
+	}
 	if channel.Type == constant.ChannelTypeVidu {
 		return testResult{
 			localErr:    errors.New("vidu channel test is not supported"),

+ 86 - 0
controller/task_video.go

@@ -13,6 +13,7 @@ import (
 	"one-api/relay"
 	"one-api/relay/channel"
 	relaycommon "one-api/relay/common"
+	"one-api/setting/ratio_setting"
 	"time"
 )
 
@@ -120,6 +121,91 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 		if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
 			task.FailReason = taskResult.Url
 		}
+
+		// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
+		if taskResult.TotalTokens > 0 {
+			// 获取模型名称
+			var taskData map[string]interface{}
+			if err := json.Unmarshal(task.Data, &taskData); err == nil {
+				if modelName, ok := taskData["model"].(string); ok && modelName != "" {
+					// 获取模型价格和倍率
+					modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
+
+					// 只有配置了倍率(非固定价格)时才按 token 重新计费
+					if hasRatioSetting && modelRatio > 0 {
+						// 获取用户和组的倍率信息
+						user, err := model.GetUserById(task.UserId, false)
+						if err == nil {
+							groupRatio := ratio_setting.GetGroupRatio(user.Group)
+							userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
+
+							var finalGroupRatio float64
+							if hasUserGroupRatio {
+								finalGroupRatio = userGroupRatio
+							} else {
+								finalGroupRatio = groupRatio
+							}
+
+							// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
+							actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
+
+							// 计算差额
+							preConsumedQuota := task.Quota
+							quotaDelta := actualQuota - preConsumedQuota
+
+							if quotaDelta > 0 {
+								// 需要补扣费
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+									task.TaskID,
+									logger.LogQuota(quotaDelta),
+									logger.LogQuota(actualQuota),
+									logger.LogQuota(preConsumedQuota),
+									taskResult.TotalTokens,
+								))
+								if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
+									logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
+								} else {
+									model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
+									model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
+									task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+									// 记录消费日志
+									logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
+										modelRatio, finalGroupRatio, taskResult.TotalTokens,
+										logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
+									model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+								}
+							} else if quotaDelta < 0 {
+								// 需要退还多扣的费用
+								refundQuota := -quotaDelta
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+									task.TaskID,
+									logger.LogQuota(refundQuota),
+									logger.LogQuota(actualQuota),
+									logger.LogQuota(preConsumedQuota),
+									taskResult.TotalTokens,
+								))
+								if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
+									logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
+								} else {
+									task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+									// 记录退款日志
+									logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
+										modelRatio, finalGroupRatio, taskResult.TotalTokens,
+										logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
+									model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+								}
+							} else {
+								// quotaDelta == 0, 预扣费刚好准确
+								logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
+									task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
+							}
+						}
+					}
+				}
+			}
+		}
 	case model.TaskStatusFailure:
 		task.Status = model.TaskStatusFailure
 		task.Progress = "100%"

+ 50 - 1
controller/user.go

@@ -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 {

+ 46 - 17
docker-compose.yml

@@ -1,3 +1,17 @@
+# New-API Docker Compose Configuration
+# 
+# Quick Start:
+#   1. docker-compose up -d
+#   2. Access at http://localhost:3000
+#
+# Using MySQL instead of PostgreSQL:
+#   1. Comment out the postgres service and SQL_DSN line 15
+#   2. Uncomment the mysql service and SQL_DSN line 16
+#   3. Uncomment mysql in depends_on (line 28)
+#   4. Uncomment mysql_data in volumes section (line 64)
+#
+# ⚠️  IMPORTANT: Change all default passwords before deploying to production!
+
 version: '3.4'
 
 services:
@@ -12,21 +26,22 @@ services:
       - ./data:/data
       - ./logs:/app/logs
     environment:
-      - SQL_DSN=root:123456@tcp(mysql:3306)/new-api  # Point to the mysql service
+      - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
+#      - SQL_DSN=root:123456@tcp(mysql:3306)/new-api  # Point to the mysql service, uncomment if using MySQL
       - REDIS_CONN_STRING=redis://redis
       - TZ=Asia/Shanghai
       - ERROR_LOG_ENABLED=true # 是否启用错误日志记录
-    #      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
-    #      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!!!!!!!
-    #      - NODE_TYPE=slave  # Uncomment for slave node in multi-node deployment
-    #      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed
-    #      - FRONTEND_BASE_URL=https://openai.justsong.cn  # Uncomment for multi-node deployment with front-end URL
+      - BATCH_UPDATE_ENABLED=true  # 是否启用批量更新 batch update enabled
+#      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
+#      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
+#      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed
 
     depends_on:
       - redis
-      - mysql
+      - postgres
+#      - mysql  # Uncomment if using MySQL
     healthcheck:
-      test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
+      test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
       interval: 30s
       timeout: 10s
       retries: 3
@@ -36,17 +51,31 @@ services:
     container_name: redis
     restart: always
 
-  mysql:
-    image: mysql:8.2
-    container_name: mysql
+  postgres:
+    image: postgres:15
+    container_name: postgres
     restart: always
     environment:
-      MYSQL_ROOT_PASSWORD: 123456  # Ensure this matches the password in SQL_DSN
-      MYSQL_DATABASE: new-api
+      POSTGRES_USER: root
+      POSTGRES_PASSWORD: 123456  # ⚠️ IMPORTANT: Change this password in production!
+      POSTGRES_DB: new-api
     volumes:
-      - mysql_data:/var/lib/mysql
-    # ports:
-    #   - "3306:3306"  # If you want to access MySQL from outside Docker, uncomment
+      - pg_data:/var/lib/postgresql/data
+#    ports:
+#      - "5432:5432"  # Uncomment if you need to access PostgreSQL from outside Docker
+
+#  mysql:
+#    image: mysql:8.2
+#    container_name: mysql
+#    restart: always
+#    environment:
+#      MYSQL_ROOT_PASSWORD: 123456  # ⚠️ IMPORTANT: Change this password in production!
+#      MYSQL_DATABASE: new-api
+#    volumes:
+#      - mysql_data:/var/lib/mysql
+#    ports:
+#      - "3306:3306"  # Uncomment if you need to access MySQL from outside Docker
 
 volumes:
-  mysql_data:
+  pg_data:
+#  mysql_data:

+ 3 - 0
dto/channel_settings.go

@@ -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 {

+ 4 - 1
dto/claude.go

@@ -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 {

+ 26 - 13
dto/openai_request.go

@@ -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 {

+ 4 - 0
dto/user_settings.go

@@ -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 推送
 )

+ 3 - 0
middleware/distributor.go

@@ -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

+ 1 - 6
relay/channel/aws/adaptor.go

@@ -7,7 +7,6 @@ import (
 	"one-api/dto"
 	"one-api/relay/channel/claude"
 	relaycommon "one-api/relay/common"
-	"one-api/setting/model_setting"
 	"one-api/types"
 
 	"github.com/gin-gonic/gin"
@@ -52,11 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 }
 
 func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
-	anthropicBeta := c.Request.Header.Get("anthropic-beta")
-	if anthropicBeta != "" {
-		req.Set("anthropic-beta", anthropicBeta)
-	}
-	model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+	claude.CommonClaudeHeadersOperation(c, req, info)
 	return nil
 }
 

+ 10 - 5
relay/channel/claude/adaptor.go

@@ -64,6 +64,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 	return baseURL, nil
 }
 
+func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
+	// common headers operation
+	anthropicBeta := c.Request.Header.Get("anthropic-beta")
+	if anthropicBeta != "" {
+		req.Set("anthropic-beta", anthropicBeta)
+	}
+	model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+}
+
 func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
 	channel.SetupApiRequestHeader(info, c, req)
 	req.Set("x-api-key", info.ApiKey)
@@ -72,11 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
 		anthropicVersion = "2023-06-01"
 	}
 	req.Set("anthropic-version", anthropicVersion)
-	anthropicBeta := c.Request.Header.Get("anthropic-beta")
-	if anthropicBeta != "" {
-		req.Set("anthropic-beta", anthropicBeta)
-	}
-	model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+	CommonClaudeHeadersOperation(c, req, info)
 	return nil
 }
 

+ 1 - 0
relay/channel/jina/adaptor.go

@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
 }
 
 func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
+	request.EncodingFormat = ""
 	return request, nil
 }
 

+ 5 - 1
relay/channel/openai/relay_responses.go

@@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
 				if streamResponse.Item != nil {
 					switch streamResponse.Item.Type {
 					case dto.BuildInCallWebSearchCall:
-						info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
+						if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
+							if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
+								webSearchTool.CallCount++
+							}
+						}
 					}
 				}
 			}

+ 248 - 0
relay/channel/task/doubao/adaptor.go

@@ -0,0 +1,248 @@
+package doubao
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"one-api/constant"
+	"one-api/dto"
+	"one-api/model"
+	"one-api/relay/channel"
+	relaycommon "one-api/relay/common"
+	"one-api/service"
+
+	"github.com/gin-gonic/gin"
+	"github.com/pkg/errors"
+)
+
+// ============================
+// Request / Response structures
+// ============================
+
+type ContentItem struct {
+	Type     string    `json:"type"`                // "text" or "image_url"
+	Text     string    `json:"text,omitempty"`      // for text type
+	ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
+}
+
+type ImageURL struct {
+	URL string `json:"url"`
+}
+
+type requestPayload struct {
+	Model   string        `json:"model"`
+	Content []ContentItem `json:"content"`
+}
+
+type responsePayload struct {
+	ID string `json:"id"` // task_id
+}
+
+type responseTask struct {
+	ID      string `json:"id"`
+	Model   string `json:"model"`
+	Status  string `json:"status"`
+	Content struct {
+		VideoURL string `json:"video_url"`
+	} `json:"content"`
+	Seed            int    `json:"seed"`
+	Resolution      string `json:"resolution"`
+	Duration        int    `json:"duration"`
+	Ratio           string `json:"ratio"`
+	FramesPerSecond int    `json:"framespersecond"`
+	Usage           struct {
+		CompletionTokens int `json:"completion_tokens"`
+		TotalTokens      int `json:"total_tokens"`
+	} `json:"usage"`
+	CreatedAt int64 `json:"created_at"`
+	UpdatedAt int64 `json:"updated_at"`
+}
+
+// ============================
+// Adaptor implementation
+// ============================
+
+type TaskAdaptor struct {
+	ChannelType int
+	apiKey      string
+	baseURL     string
+}
+
+func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
+	a.ChannelType = info.ChannelType
+	a.baseURL = info.ChannelBaseUrl
+	a.apiKey = info.ApiKey
+}
+
+// ValidateRequestAndSetAction parses body, validates fields and sets default action.
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
+	// Accept only POST /v1/video/generations as "generate" action.
+	return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
+}
+
+// BuildRequestURL constructs the upstream URL.
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+	return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
+}
+
+// BuildRequestHeader sets required headers.
+func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Authorization", "Bearer "+a.apiKey)
+	return nil
+}
+
+// BuildRequestBody converts request into Doubao specific format.
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
+	v, exists := c.Get("task_request")
+	if !exists {
+		return nil, fmt.Errorf("request not found in context")
+	}
+	req := v.(relaycommon.TaskSubmitReq)
+
+	body, err := a.convertToRequestPayload(&req)
+	if err != nil {
+		return nil, errors.Wrap(err, "convert request payload failed")
+	}
+	data, err := json.Marshal(body)
+	if err != nil {
+		return nil, err
+	}
+	return bytes.NewReader(data), nil
+}
+
+// DoRequest delegates to common helper.
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
+	return channel.DoTaskApiRequest(a, c, info, requestBody)
+}
+
+// DoResponse handles upstream response, returns taskID etc.
+func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
+	responseBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
+		return
+	}
+	_ = resp.Body.Close()
+
+	// Parse Doubao response
+	var dResp responsePayload
+	if err := json.Unmarshal(responseBody, &dResp); err != nil {
+		taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
+		return
+	}
+
+	if dResp.ID == "" {
+		taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
+	return dResp.ID, responseBody, nil
+}
+
+// FetchTask fetch task status
+func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
+	taskID, ok := body["task_id"].(string)
+	if !ok {
+		return nil, fmt.Errorf("invalid task_id")
+	}
+
+	uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID)
+
+	req, err := http.NewRequest(http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "Bearer "+key)
+
+	return service.GetHttpClient().Do(req)
+}
+
+func (a *TaskAdaptor) GetModelList() []string {
+	return ModelList
+}
+
+func (a *TaskAdaptor) GetChannelName() string {
+	return ChannelName
+}
+
+func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
+	r := requestPayload{
+		Model:   req.Model,
+		Content: []ContentItem{},
+	}
+
+	// Add text prompt
+	if req.Prompt != "" {
+		r.Content = append(r.Content, ContentItem{
+			Type: "text",
+			Text: req.Prompt,
+		})
+	}
+
+	// Add images if present
+	if req.HasImage() {
+		for _, imgURL := range req.Images {
+			r.Content = append(r.Content, ContentItem{
+				Type: "image_url",
+				ImageURL: &ImageURL{
+					URL: imgURL,
+				},
+			})
+		}
+	}
+
+	// TODO: Add support for additional parameters from metadata
+	// such as ratio, duration, seed, etc.
+	// metadata := req.Metadata
+	// if metadata != nil {
+	//     // Parse and apply metadata parameters
+	// }
+
+	return &r, nil
+}
+
+func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
+	resTask := responseTask{}
+	if err := json.Unmarshal(respBody, &resTask); err != nil {
+		return nil, errors.Wrap(err, "unmarshal task result failed")
+	}
+
+	taskResult := relaycommon.TaskInfo{
+		Code: 0,
+	}
+
+	// Map Doubao status to internal status
+	switch resTask.Status {
+	case "pending", "queued":
+		taskResult.Status = model.TaskStatusQueued
+		taskResult.Progress = "10%"
+	case "processing":
+		taskResult.Status = model.TaskStatusInProgress
+		taskResult.Progress = "50%"
+	case "succeeded":
+		taskResult.Status = model.TaskStatusSuccess
+		taskResult.Progress = "100%"
+		taskResult.Url = resTask.Content.VideoURL
+		// 解析 usage 信息用于按倍率计费
+		taskResult.CompletionTokens = resTask.Usage.CompletionTokens
+		taskResult.TotalTokens = resTask.Usage.TotalTokens
+	case "failed":
+		taskResult.Status = model.TaskStatusFailure
+		taskResult.Progress = "100%"
+		taskResult.Reason = "task failed"
+	default:
+		// Unknown status, treat as processing
+		taskResult.Status = model.TaskStatusInProgress
+		taskResult.Progress = "30%"
+	}
+
+	return &taskResult, nil
+}

+ 9 - 0
relay/channel/task/doubao/constants.go

@@ -0,0 +1,9 @@
+package doubao
+
+var ModelList = []string{
+	"doubao-seedance-1-0-pro-250528",
+	"doubao-seedance-1-0-lite-t2v",
+	"doubao-seedance-1-0-lite-i2v",
+}
+
+var ChannelName = "doubao-video"

+ 48 - 21
relay/channel/vertex/adaptor.go

@@ -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 {
+		var keyPrefix string
+		if strings.HasSuffix(suffix, "?alt=sse") {
+			keyPrefix = "&"
 		} 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
+			keyPrefix = "?"
 		}
-	} else {
 		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

+ 6 - 0
relay/claude_handler.go

@@ -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)

+ 48 - 6
relay/common/relay_info.go

@@ -500,10 +500,52 @@ func (t TaskSubmitReq) HasImage() bool {
 }
 
 type TaskInfo struct {
-	Code     int    `json:"code"`
-	TaskID   string `json:"task_id"`
-	Status   string `json:"status"`
-	Reason   string `json:"reason,omitempty"`
-	Url      string `json:"url,omitempty"`
-	Progress string `json:"progress,omitempty"`
+	Code             int    `json:"code"`
+	TaskID           string `json:"task_id"`
+	Status           string `json:"status"`
+	Reason           string `json:"reason,omitempty"`
+	Url              string `json:"url,omitempty"`
+	Progress         string `json:"progress,omitempty"`
+	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
 }

+ 6 - 0
relay/compatible_handler.go

@@ -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)

+ 5 - 2
relay/relay_adaptor.go

@@ -1,6 +1,7 @@
 package relay
 
 import (
+	"github.com/gin-gonic/gin"
 	"one-api/constant"
 	"one-api/relay/channel"
 	"one-api/relay/channel/ali"
@@ -24,6 +25,8 @@ import (
 	"one-api/relay/channel/palm"
 	"one-api/relay/channel/perplexity"
 	"one-api/relay/channel/siliconflow"
+	"one-api/relay/channel/submodel"
+	taskdoubao "one-api/relay/channel/task/doubao"
 	taskjimeng "one-api/relay/channel/task/jimeng"
 	"one-api/relay/channel/task/kling"
 	"one-api/relay/channel/task/suno"
@@ -37,8 +40,6 @@ import (
 	"one-api/relay/channel/zhipu"
 	"one-api/relay/channel/zhipu_4v"
 	"strconv"
-    "one-api/relay/channel/submodel"
-	"github.com/gin-gonic/gin"
 )
 
 func GetAdaptor(apiType int) channel.Adaptor {
@@ -134,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
 			return &taskvertex.TaskAdaptor{}
 		case constant.ChannelTypeVidu:
 			return &taskVidu.TaskAdaptor{}
+		case constant.ChannelTypeDoubaoVideo:
+			return &taskdoubao.TaskAdaptor{}
 		}
 	}
 	return nil

+ 7 - 0
relay/responses_handler.go

@@ -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)

+ 4 - 1
service/quota.go

@@ -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}
 			}

+ 112 - 4
service/user_notify.go

@@ -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
+}

+ 3 - 0
setting/operation_setting/tools.go

@@ -29,6 +29,7 @@ const (
 	Gemini25FlashLitePreviewInputAudioPrice = 0.50
 	Gemini25FlashNativeAudioInputAudioPrice = 3.00
 	Gemini20FlashInputAudioPrice            = 0.70
+	GeminiRoboticsER15InputAudioPrice       = 1.00
 )
 
 const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
 		return Gemini25FlashProductionInputAudioPrice
 	} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
 		return Gemini20FlashInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
+		return GeminiRoboticsER15InputAudioPrice
 	}
 	return 0
 }

+ 14 - 11
setting/ratio_setting/model_ratio.go

@@ -179,6 +179,7 @@ var defaultModelRatio = map[string]float64{
 	"gemini-2.5-flash-lite-preview-thinking-*":  0.05,
 	"gemini-2.5-flash-lite-preview-06-17":       0.05,
 	"gemini-2.5-flash":                          0.15,
+	"gemini-robotics-er-1.5-preview":            0.15,
 	"gemini-embedding-001":                      0.075,
 	"text-embedding-004":                        0.001,
 	"chatglm_turbo":                             0.3572,     // ¥0.005 / 1k tokens
@@ -252,17 +253,17 @@ var defaultModelRatio = map[string]float64{
 	"grok-vision-beta":      2.5,
 	"grok-3-fast-beta":      2.5,
 	"grok-3-mini-fast-beta": 0.3,
-    // submodel
-	"NousResearch/Hermes-4-405B-FP8":               0.8,
-	"Qwen/Qwen3-235B-A22B-Thinking-2507":           0.6,
-	"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":      0.8,
-	"Qwen/Qwen3-235B-A22B-Instruct-2507":           0.3,
-	"zai-org/GLM-4.5-FP8":                          0.8,
-	"openai/gpt-oss-120b":                          0.5,
-	"deepseek-ai/DeepSeek-R1-0528":                 0.8,
-	"deepseek-ai/DeepSeek-R1":                      0.8,
-	"deepseek-ai/DeepSeek-V3-0324":                 0.8,
-	"deepseek-ai/DeepSeek-V3.1":                    0.8,
+	// submodel
+	"NousResearch/Hermes-4-405B-FP8":          0.8,
+	"Qwen/Qwen3-235B-A22B-Thinking-2507":      0.6,
+	"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
+	"Qwen/Qwen3-235B-A22B-Instruct-2507":      0.3,
+	"zai-org/GLM-4.5-FP8":                     0.8,
+	"openai/gpt-oss-120b":                     0.5,
+	"deepseek-ai/DeepSeek-R1-0528":            0.8,
+	"deepseek-ai/DeepSeek-R1":                 0.8,
+	"deepseek-ai/DeepSeek-V3-0324":            0.8,
+	"deepseek-ai/DeepSeek-V3.1":               0.8,
 }
 
 var defaultModelPrice = map[string]float64{
@@ -587,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
 				return 4, false
 			}
 			return 2.5 / 0.3, false
+		} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
+			return 2.5 / 0.3, false
 		}
 		return 4, false
 	}

+ 15 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -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,

+ 103 - 1
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -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>

+ 243 - 33
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -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都要传到后端
+    }
+
+    // type === 20: 设置企业账户标识,无论是true还是false都要传到后端
+    if (localInputs.type === 20) {
       settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
-      localInputs.settings = JSON.stringify(settings);
     }
 
+    // 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>
           )}

+ 5 - 0
web/src/constants/channel.constants.js

@@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [
     color: 'blue',
     label: 'SubModel',
   },
+  {
+    value: 54,
+    color: 'blue',
+    label: '豆包视频',
+  },
 ];
 
 export const MODEL_TABLE_PAGE_SIZE = 10;

+ 2 - 0
web/src/helpers/render.jsx

@@ -337,6 +337,8 @@ export function getChannelIcon(channelType) {
       return <Kling.Color size={iconSize} />;
     case 51: // 即梦 Jimeng
       return <Jimeng.Color size={iconSize} />;
+    case 54: // 豆包视频 Doubao Video
+      return <Doubao.Color size={iconSize} />;
     case 8: // 自定义渠道
     case 22: // 知识库:FastGPT
       return <FastGPT.Color size={iconSize} />;

+ 39 - 0
web/src/i18n/locales/en.json

@@ -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"
   }

+ 39 - 0
web/src/i18n/locales/fr.json

@@ -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",
@@ -2134,6 +2166,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"
   },

+ 386 - 32
web/src/pages/Setting/Chat/SettingsChats.jsx

@@ -18,7 +18,26 @@ For commercial licensing, please contact [email protected]
 */
 
 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
+
+          <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>
+
+        {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={[
-              {
-                validator: (rule, value) => {
-                  return verifyJSON(value);
-                },
-                message: t('不是合法的 JSON 字符串'),
-              },
+              { required: true, message: t('请输入聊天应用名称') },
+              { min: 1, message: t('名称不能为空') },
             ]}
-            onChange={(value) =>
-              setInputs({
-                ...inputs,
-                Chats: value,
-              })
-            }
           />
-        </Form.Section>
-      </Form>
-      <Space>
-        <Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
-      </Space>
+          <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>
   );
 }