diff --git a/.env.example b/.env.example index bece06dbc827..d317e1f3c550 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,7 @@ # 设置 Dify 渠道是否输出工作流和节点信息到客户端 # DIFY_DEBUG=true # 设置流式一次回复的超时时间 -# STREAMING_TIMEOUT=90 +# STREAMING_TIMEOUT=120 # 节点类型 diff --git a/README.en.md b/README.en.md index 10a3cdb058ef..b4ae921aeb90 100644 --- a/README.en.md +++ b/README.en.md @@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables): - `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false` -- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds +- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds - `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true` - `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true` - `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true` diff --git a/README.md b/README.md index 6ba3574cdf86..498d7c6c80d5 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables): - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false` -- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒 +- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒 - `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true` - `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true` - `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true` diff --git a/common/constants.go b/common/constants.go index ac8031487b04..67625439c295 100644 --- a/common/constants.go +++ b/common/constants.go @@ -242,6 +242,7 @@ const ( ChannelTypeXai = 48 ChannelTypeCoze = 49 ChannelTypeKling = 50 + ChannelTypeJimeng = 51 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -298,4 +299,5 @@ var ChannelBaseURLs = []string{ "https://api.x.ai", //48 "https://api.coze.cn", //49 "https://api.klingai.com", //50 + "https://visual.volcengineapi.com", //51 } diff --git a/common/http.go b/common/http.go new file mode 100644 index 000000000000..315b19af7b3f --- /dev/null +++ b/common/http.go @@ -0,0 +1,56 @@ +package common + +import ( + "bytes" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" +) + +func CloseResponseBodyGracefully(httpResponse *http.Response) { + if httpResponse == nil || httpResponse.Body == nil { + return + } + err := httpResponse.Body.Close() + if err != nil { + SysError("failed to close response body: " + err.Error()) + } +} + +func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) { + if src == nil || src.Body == nil { + return + } + + defer CloseResponseBodyGracefully(src) + + if c.Writer == nil { + return + } + + src.Body = io.NopCloser(bytes.NewBuffer(data)) + + // We shouldn't set the header before we parse the response body, because the parse part may fail. + // And then we will have to send an error response, but in this case, the header has already been set. + // So the httpClient will be confused by the response. + // For example, Postman will report error, and we cannot check the response at all. + for k, v := range src.Header { + // avoid setting Content-Length + if k == "Content-Length" { + continue + } + c.Writer.Header().Set(k, v[0]) + } + + // set Content-Length header manually + c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + + c.Writer.WriteHeader(src.StatusCode) + c.Writer.WriteHeaderNow() + + _, err := io.Copy(c.Writer, src.Body) + if err != nil { + LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error())) + } +} diff --git a/constant/context_key.go b/constant/context_key.go index 4b4d5cae0f04..895b0fcbb589 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -7,4 +7,5 @@ const ( ContextKeyUserStatus = "user_status" ContextKeyUserEmail = "user_email" ContextKeyUserGroup = "user_group" + ContextKeyUsingGroup = "group" ) diff --git a/constant/env.go b/constant/env.go index 612f3e8be0d6..f33c67ff7595 100644 --- a/constant/env.go +++ b/constant/env.go @@ -23,7 +23,7 @@ var ErrorLogEnabled bool //} func InitEnv() { - StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60) + StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120) DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true) MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) // ForceStreamOption 覆盖请求参数,强制返回usage信息 diff --git a/constant/task.go b/constant/task.go index d466fc8a123b..e7af39a6e1d9 100644 --- a/constant/task.go +++ b/constant/task.go @@ -6,11 +6,15 @@ const ( TaskPlatformSuno TaskPlatform = "suno" TaskPlatformMidjourney = "mj" TaskPlatformKling TaskPlatform = "kling" + TaskPlatformJimeng TaskPlatform = "jimeng" ) const ( SunoActionMusic = "MUSIC" SunoActionLyrics = "LYRICS" + + TaskActionGenerate = "generate" + TaskActionTextGenerate = "textGenerate" ) var SunoModel2Action = map[string]string{ diff --git a/controller/channel-test.go b/controller/channel-test.go index d54ccf0dd2fd..b3badf3504ec 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -43,6 +43,9 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr if channel.Type == common.ChannelTypeKling { return errors.New("kling channel test is not supported"), nil } + if channel.Type == common.ChannelTypeJimeng { + return errors.New("jimeng channel test is not supported"), nil + } w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -171,7 +174,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试", - quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other) + quota, "模型测试", 0, quota, int(consumedTime), false, info.UsingGroup, other) common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) return nil, nil } diff --git a/controller/channel.go b/controller/channel.go index 13ed72b3dea0..e46b38f50a63 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -40,6 +40,17 @@ type OpenAIModelsResponse struct { Success bool `json:"success"` } +func parseStatusFilter(statusParam string) int { + switch strings.ToLower(statusParam) { + case "enabled", "1": + return common.ChannelStatusEnabled + case "disabled", "0": + return 0 + default: + return -1 + } +} + func GetAllChannels(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) pageSize, _ := strconv.Atoi(c.Query("page_size")) @@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) { channelData := make([]*model.Channel, 0) idSort, _ := strconv.ParseBool(c.Query("id_sort")) enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + statusParam := c.Query("status") + // statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual) + statusFilter := parseStatusFilter(statusParam) // type filter typeStr := c.Query("type") typeFilter := -1 @@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) { var total int64 if enableTagMode { - // tag 分页:先分页 tag,再取各 tag 下 channels tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } for _, tag := range tags { - if tag != nil && *tag != "" { - tagChannel, err := model.GetChannelsByTag(*tag, idSort) - if err == nil { - channelData = append(channelData, tagChannel...) + if tag == nil || *tag == "" { + continue + } + tagChannels, err := model.GetChannelsByTag(*tag, idSort) + if err != nil { + continue + } + filtered := make([]*model.Channel, 0) + for _, ch := range tagChannels { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue } + if typeFilter >= 0 && ch.Type != typeFilter { + continue + } + filtered = append(filtered, ch) } + channelData = append(channelData, filtered...) } - // 计算 tag 总数用于分页 total, _ = model.CountAllTags() - } else if typeFilter >= 0 { - channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter) - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) - return - } - channelData = channels - total, _ = model.CountChannelsByType(typeFilter) } else { - channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort) + baseQuery := model.DB.Model(&model.Channel{}) + if typeFilter >= 0 { + baseQuery = baseQuery.Where("type = ?", typeFilter) + } + if statusFilter == common.ChannelStatusEnabled { + baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled) + } + + baseQuery.Count(&total) + + order := "priority desc" + if idSort { + order = "id desc" + } + + err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } - channelData = channels - total, _ = model.CountAllChannels() } - // calculate type counts - typeCounts, _ := model.CountChannelsGroupByType() + countQuery := model.DB.Model(&model.Channel{}) + if statusFilter == common.ChannelStatusEnabled { + countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled) + } + var results []struct { + Type int64 + Count int64 + } + _ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error + typeCounts := make(map[int64]int64) + for _, r := range results { + typeCounts[r.Type] = r.Count + } c.JSON(http.StatusOK, gin.H{ "success": true, @@ -134,13 +181,6 @@ func FetchUpstreamModels(c *gin.Context) { return } - //if channel.Type != common.ChannelTypeOpenAI { - // c.JSON(http.StatusOK, gin.H{ - // "success": false, - // "message": "仅支持 OpenAI 类型渠道", - // }) - // return - //} baseURL := common.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() @@ -206,6 +246,8 @@ func SearchChannels(c *gin.Context) { keyword := c.Query("keyword") group := c.Query("group") modelKeyword := c.Query("model") + statusParam := c.Query("status") + statusFilter := parseStatusFilter(statusParam) idSort, _ := strconv.ParseBool(c.Query("id_sort")) enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) channelData := make([]*model.Channel, 0) @@ -238,17 +280,71 @@ func SearchChannels(c *gin.Context) { channelData = channels } + if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue + } + filtered = append(filtered, ch) + } + channelData = filtered + } + // calculate type counts for search results typeCounts := make(map[int64]int64) for _, channel := range channelData { typeCounts[int64(channel.Type)]++ } + typeParam := c.Query("type") + typeFilter := -1 + if typeParam != "" { + if tp, err := strconv.Atoi(typeParam); err == nil { + typeFilter = tp + } + } + + if typeFilter >= 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if ch.Type == typeFilter { + filtered = append(filtered, ch) + } + } + channelData = filtered + } + + page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + total := len(channelData) + startIdx := (page - 1) * pageSize + if startIdx > total { + startIdx = total + } + endIdx := startIdx + pageSize + if endIdx > total { + endIdx = total + } + + pagedData := channelData[startIdx:endIdx] + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": gin.H{ - "items": channelData, + "items": pagedData, + "total": total, "type_counts": typeCounts, }, }) @@ -546,6 +642,7 @@ func UpdateChannel(c *gin.Context) { }) return } + channel.Key = "" c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index f749f3845e10..0453870d0070 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/json" + "fmt" "net/http" "strings" "sync" @@ -43,7 +44,17 @@ func FetchUpstreamRatios(c *gin.Context) { var upstreams []dto.UpstreamDTO - if len(req.ChannelIDs) > 0 { + if len(req.Upstreams) > 0 { + for _, u := range req.Upstreams { + if strings.HasPrefix(u.BaseURL, "http") { + if u.Endpoint == "" { + u.Endpoint = defaultEndpoint + } + u.BaseURL = strings.TrimRight(u.BaseURL, "/") + upstreams = append(upstreams, u) + } + } + } else if len(req.ChannelIDs) > 0 { intIds := make([]int, 0, len(req.ChannelIDs)) for _, id64 := range req.ChannelIDs { intIds = append(intIds, int(id64)) @@ -57,6 +68,7 @@ func FetchUpstreamRatios(c *gin.Context) { for _, ch := range dbChannels { if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { upstreams = append(upstreams, dto.UpstreamDTO{ + ID: ch.Id, Name: ch.Name, BaseURL: strings.TrimRight(base, "/"), Endpoint: "", @@ -93,43 +105,125 @@ func FetchUpstreamRatios(c *gin.Context) { } fullURL := chItem.BaseURL + endpoint + uniqueName := chItem.Name + if chItem.ID != 0 { + uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID) + } + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } resp, err := client.Do(httpReq) if err != nil { common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) - ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} + ch <- upstreamResult{Name: uniqueName, Err: resp.Status} return } + // 兼容两种上游接口格式: + // type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price + // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 var body struct { - Success bool `json:"success"` - Data map[string]any `json:"data"` - Message string `json:"message"` + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Message string `json:"message"` } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return } + if !body.Success { - ch <- upstreamResult{Name: chItem.Name, Err: body.Message} + ch <- upstreamResult{Name: uniqueName, Err: body.Message} return } - ch <- upstreamResult{Name: chItem.Name, Data: body.Data} + + // 尝试按 type1 解析 + var type1Data map[string]any + if err := json.Unmarshal(body.Data, &type1Data); err == nil { + // 如果包含至少一个 ratioTypes 字段,则认为是 type1 + isType1 := false + for _, rt := range ratioTypes { + if _, ok := type1Data[rt]; ok { + isType1 = true + break + } + } + if isType1 { + ch <- upstreamResult{Name: uniqueName, Data: type1Data} + return + } + } + + // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析 + var pricingItems []struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + CompletionRatio float64 `json:"completion_ratio"` + } + if err := json.Unmarshal(body.Data, &pricingItems); err != nil { + common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"} + return + } + + modelRatioMap := make(map[string]float64) + completionRatioMap := make(map[string]float64) + modelPriceMap := make(map[string]float64) + + for _, item := range pricingItems { + if item.QuotaType == 1 { + modelPriceMap[item.ModelName] = item.ModelPrice + } else { + modelRatioMap[item.ModelName] = item.ModelRatio + // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 + completionRatioMap[item.ModelName] = item.CompletionRatio + } + } + + converted := make(map[string]any) + + if len(modelRatioMap) > 0 { + ratioAny := make(map[string]any, len(modelRatioMap)) + for k, v := range modelRatioMap { + ratioAny[k] = v + } + converted["model_ratio"] = ratioAny + } + + if len(completionRatioMap) > 0 { + compAny := make(map[string]any, len(completionRatioMap)) + for k, v := range completionRatioMap { + compAny[k] = v + } + converted["completion_ratio"] = compAny + } + + if len(modelPriceMap) > 0 { + priceAny := make(map[string]any, len(modelPriceMap)) + for k, v := range modelPriceMap { + priceAny[k] = v + } + converted["model_price"] = priceAny + } + + ch <- upstreamResult{Name: uniqueName, Data: converted} }(chn) } @@ -202,6 +296,43 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } + confidenceMap := make(map[string]map[string]bool) + + // 预处理阶段:检查pricing接口的可信度 + for _, channel := range successfulChannels { + confidenceMap[channel.name] = make(map[string]bool) + + modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any) + completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any) + + if hasModelRatio && hasCompletionRatio { + // 遍历所有模型,检查是否满足不可信条件 + for modelName := range allModels { + // 默认为可信 + confidenceMap[channel.name][modelName] = true + + // 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1 + if modelRatioVal, ok := modelRatios[modelName]; ok { + if completionRatioVal, ok := completionRatios[modelName]; ok { + // 转换为float64进行比较 + if modelRatioFloat, ok := modelRatioVal.(float64); ok { + if completionRatioFloat, ok := completionRatioVal.(float64); ok { + if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 { + confidenceMap[channel.name][modelName] = false + } + } + } + } + } + } + } else { + // 如果不是从pricing接口获取的数据,则全部标记为可信 + for modelName := range allModels { + confidenceMap[channel.name][modelName] = true + } + } + } + for modelName := range allModels { for _, ratioType := range ratioTypes { var localValue interface{} = nil @@ -214,6 +345,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } upstreamValues := make(map[string]interface{}) + confidenceValues := make(map[string]bool) hasUpstreamValue := false hasDifference := false @@ -241,6 +373,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } upstreamValues[channel.name] = upstreamValue + + confidenceValues[channel.name] = confidenceMap[channel.name][modelName] } shouldInclude := false @@ -262,6 +396,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { differences[modelName][ratioType] = dto.DifferenceItem{ Current: localValue, Upstreams: upstreamValues, + Confidence: confidenceValues, } } } @@ -283,9 +418,26 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { for chName := range item.Upstreams { if !channelHasDiff[chName] { delete(item.Upstreams, chName) + delete(item.Confidence, chName) + } + } + + allSame := true + for _, v := range item.Upstreams { + if v != "same" { + allSame = false + break } } - differences[modelName][ratioType] = item + if len(item.Upstreams) == 0 || allSame { + delete(ratioMap, ratioType) + } else { + differences[modelName][ratioType] = item + } + } + + if len(ratioMap) == 0 { + delete(differences, modelName) } } diff --git a/controller/task.go b/controller/task.go index f7523e87f45d..5cfa728aa673 100644 --- a/controller/task.go +++ b/controller/task.go @@ -74,8 +74,8 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][ //_ = UpdateMidjourneyTaskAll(context.Background(), tasks) case constant.TaskPlatformSuno: _ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM) - case constant.TaskPlatformKling: - _ = UpdateVideoTaskAll(context.Background(), taskChannelM, taskM) + case constant.TaskPlatformKling, constant.TaskPlatformJimeng: + _ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM) default: common.SysLog("未知平台") } diff --git a/controller/task_video.go b/controller/task_video.go index a2c2431dff40..a17351b550ae 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -2,27 +2,26 @@ package controller import ( "context" - "encoding/json" "fmt" "io" - "net/http" "one-api/common" "one-api/constant" "one-api/model" "one-api/relay" "one-api/relay/channel" + "time" ) -func UpdateVideoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { +func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { for channelId, taskIds := range taskChannelM { - if err := updateVideoTaskAll(ctx, channelId, taskIds, taskM); err != nil { + if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil { common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) } } return nil } -func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error { +func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error { common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) if len(taskIds) == 0 { return nil @@ -39,7 +38,7 @@ func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, ta } return fmt.Errorf("CacheGetChannel failed: %w", err) } - adaptor := relay.GetTaskAdaptor(constant.TaskPlatformKling) + adaptor := relay.GetTaskAdaptor(platform) if adaptor == nil { return fmt.Errorf("video adaptor not found") } @@ -56,70 +55,64 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } + + task := taskM[taskId] + if task == nil { + common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) + return fmt.Errorf("task %s not found", taskId) + } resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ "task_id": taskId, + "action": task.Action, }) if err != nil { - return fmt.Errorf("FetchTask failed for task %s: %w", taskId, err) - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Get Video Task status code: %d", resp.StatusCode) + return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) } + //if resp.StatusCode != http.StatusOK { + //return fmt.Errorf("get Video Task status code: %d", resp.StatusCode) + //} defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("ReadAll failed for task %s: %w", taskId, err) + return fmt.Errorf("readAll failed for task %s: %w", taskId, err) } - var responseItem map[string]interface{} - err = json.Unmarshal(responseBody, &responseItem) + taskResult, err := adaptor.ParseTaskResult(responseBody) if err != nil { - common.LogError(ctx, fmt.Sprintf("Failed to parse video task response body: %v, body: %s", err, string(responseBody))) - return fmt.Errorf("Unmarshal failed for task %s: %w", taskId, err) - } - - code, _ := responseItem["code"].(float64) - if code != 0 { - return fmt.Errorf("video task fetch failed for task %s", taskId) - } - - data, ok := responseItem["data"].(map[string]interface{}) - if !ok { - common.LogError(ctx, fmt.Sprintf("Video task data format error: %s", string(responseBody))) - return fmt.Errorf("video task data format error for task %s", taskId) - } - - task := taskM[taskId] - if task == nil { - common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) - return fmt.Errorf("task %s not found", taskId) + return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) } + //if taskResult.Code != 0 { + // return fmt.Errorf("video task fetch failed for task %s", taskId) + //} - if status, ok := data["task_status"].(string); ok { - switch status { - case "submitted", "queued": - task.Status = model.TaskStatusSubmitted - case "processing": - task.Status = model.TaskStatusInProgress - case "succeed": - task.Status = model.TaskStatusSuccess - task.Progress = "100%" - if url, err := adaptor.ParseResultUrl(responseItem); err == nil { - task.FailReason = url - } else { - common.LogWarn(ctx, fmt.Sprintf("Failed to get url from body for task %s: %s", task.TaskID, err.Error())) - } - case "failed": - task.Status = model.TaskStatusFailure - task.Progress = "100%" - if reason, ok := data["fail_reason"].(string); ok { - task.FailReason = reason - } + now := time.Now().Unix() + if taskResult.Status == "" { + return fmt.Errorf("task %s status is empty", taskId) + } + task.Status = model.TaskStatus(taskResult.Status) + switch taskResult.Status { + case model.TaskStatusSubmitted: + task.Progress = "10%" + case model.TaskStatusQueued: + task.Progress = "20%" + case model.TaskStatusInProgress: + task.Progress = "30%" + if task.StartTime == 0 { + task.StartTime = now } - } - - // If task failed, refund quota - if task.Status == model.TaskStatusFailure { + case model.TaskStatusSuccess: + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskResult.Url + case model.TaskStatusFailure: + task.Status = model.TaskStatusFailure + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskResult.Reason common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) quota := task.Quota if quota != 0 { @@ -129,6 +122,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota)) model.RecordLog(task.UserId, model.LogTypeSystem, logContent) } + default: + return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId) + } + if taskResult.Progress != "" { + task.Progress = taskResult.Progress } task.Data = responseBody diff --git a/controller/token.go b/controller/token.go index c57552c0da39..173fc22e22b7 100644 --- a/controller/token.go +++ b/controller/token.go @@ -258,3 +258,32 @@ func UpdateToken(c *gin.Context) { }) return } + +type TokenBatch struct { + Ids []int `json:"ids"` +} + +func DeleteTokenBatch(c *gin.Context) { + tokenBatch := TokenBatch{} + if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + userId := c.GetInt("id") + count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) +} diff --git a/dto/openai_request.go b/dto/openai_request.go index 42c290ca758d..0104f347090d 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,7 @@ type GeneralOpenAIRequest struct { ExtraBody json.RawMessage `json:"extra_body,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // OpenRouter Params + Usage json.RawMessage `json:"usage,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"` // Ali Qwen Params VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` @@ -645,4 +646,6 @@ type ResponsesToolsCall struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` + Function json.RawMessage `json:"function,omitempty"` + Container json.RawMessage `json:"container,omitempty"` } diff --git a/dto/openai_response.go b/dto/openai_response.go index 790d4df81957..d95acd9eb194 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -26,7 +26,7 @@ type OpenAITextResponse struct { Id string `json:"id"` Model string `json:"model"` Object string `json:"object"` - Created int64 `json:"created"` + Created any `json:"created"` Choices []OpenAITextResponseChoice `json:"choices"` Error *OpenAIError `json:"error,omitempty"` Usage `json:"usage"` @@ -178,6 +178,8 @@ type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + // OpenRouter Params + Cost float64 `json:"cost,omitempty"` } type InputTokenDetails struct { diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go index 55a89025b085..6315f31ae6f5 100644 --- a/dto/ratio_sync.go +++ b/dto/ratio_sync.go @@ -1,18 +1,7 @@ package dto -// UpstreamDTO 提交到后端同步倍率的上游渠道信息 -// Endpoint 可以为空,后端会默认使用 /api/ratio_config -// BaseURL 必须以 http/https 开头,不要以 / 结尾 -// 例如: https://api.example.com -// Endpoint: /api/ratio_config -// 提交示例: -// { -// "name": "openai", -// "base_url": "https://api.openai.com", -// "endpoint": "/ratio_config" -// } - type UpstreamDTO struct { + ID int `json:"id,omitempty"` Name string `json:"name" binding:"required"` BaseURL string `json:"base_url" binding:"required"` Endpoint string `json:"endpoint"` @@ -20,6 +9,7 @@ type UpstreamDTO struct { type UpstreamRequest struct { ChannelIDs []int64 `json:"channel_ids"` + Upstreams []UpstreamDTO `json:"upstreams"` Timeout int `json:"timeout"` } @@ -37,10 +27,9 @@ type TestResult struct { type DifferenceItem struct { Current interface{} `json:"current"` Upstreams map[string]interface{} `json:"upstreams"` + Confidence map[string]bool `json:"confidence"` } -// SyncableChannel 可同步的渠道信息(base_url 不为空) - type SyncableChannel struct { ID int `json:"id"` Name string `json:"name"` diff --git a/middleware/auth.go b/middleware/auth.go index f387029fa5dc..ecf4844bc9b4 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -184,7 +184,7 @@ func TokenAuth() func(c *gin.Context) { } } // gemini api 从query中获取key - if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") { + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { skKey := c.Query("key") if skKey != "" { c.Request.Header.Set("Authorization", "Bearer "+skKey) diff --git a/middleware/distributor.go b/middleware/distributor.go index 9d074ce8d068..0a6a9af476b7 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -57,7 +57,7 @@ func Distribute() func(c *gin.Context) { } userGroup = tokenGroup } - c.Set("group", userGroup) + c.Set(constant.ContextKeyUsingGroup, userGroup) if ok { id, err := strconv.Atoi(channelId.(string)) if err != nil { @@ -171,15 +171,25 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { c.Set("platform", string(constant.TaskPlatformSuno)) c.Set("relay_mode", relayMode) } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { - relayMode := relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path) - if relayMode == relayconstant.RelayModeKlingFetchByID { - shouldSelectChannel = false + err = common.UnmarshalBodyReusable(c, &modelRequest) + var platform string + var relayMode int + if strings.HasPrefix(modelRequest.Model, "jimeng") { + platform = string(constant.TaskPlatformJimeng) + relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeJimengFetchByID { + shouldSelectChannel = false + } } else { - err = common.UnmarshalBodyReusable(c, &modelRequest) + platform = string(constant.TaskPlatformKling) + relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeKlingFetchByID { + shouldSelectChannel = false + } } - c.Set("platform", string(constant.TaskPlatformKling)) + c.Set("platform", platform) c.Set("relay_mode", relayMode) - } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") { + } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent relayMode := relayconstant.RelayModeGemini modelName := extractModelNameFromGeminiPath(c.Request.URL.Path) diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go new file mode 100644 index 000000000000..8e2a35519d30 --- /dev/null +++ b/middleware/kling_adapter.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "one-api/common" + "one-api/constant" + + "github.com/gin-gonic/gin" +) + +func KlingRequestConvert() func(c *gin.Context) { + return func(c *gin.Context) { + var originalReq map[string]interface{} + if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil { + c.Next() + return + } + + model, _ := originalReq["model"].(string) + prompt, _ := originalReq["prompt"].(string) + + unifiedReq := map[string]interface{}{ + "model": model, + "prompt": prompt, + "metadata": originalReq, + } + + jsonData, err := json.Marshal(unifiedReq) + if err != nil { + c.Next() + return + } + + // Rewrite request body and path + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + c.Request.URL.Path = "/v1/video/generations" + if image := originalReq["image"]; image == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + + // We have to reset the request body for the next handlers + c.Set(common.KeyRequestBody, jsonData) + c.Next() + } +} diff --git a/model/token.go b/model/token.go index 2ed2c09a912b..7e68f1857ef2 100644 --- a/model/token.go +++ b/model/token.go @@ -327,3 +327,37 @@ func CountUserTokens(userId int) (int64, error) { err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error return total, err } + +// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量 +func BatchDeleteTokens(ids []int, userId int) (int, error) { + if len(ids) == 0 { + return 0, errors.New("ids 不能为空!") + } + + tx := DB.Begin() + + var tokens []Token + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + if common.RedisEnabled { + gopool.Go(func() { + for _, t := range tokens { + _ = cacheDeleteToken(t.Key) + } + }) + } + + return len(tokens), nil +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 873997f6d3e1..2ff34e0176f1 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -45,5 +45,5 @@ type TaskAdaptor interface { // FetchTask FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) - ParseResultUrl(resp map[string]any) (string, error) + ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) } diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 4420358335f9..c84c7885758c 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -132,10 +132,7 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &aliTaskResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go index c9ae066a1941..ebfe26deb105 100644 --- a/relay/channel/ali/rerank.go +++ b/relay/channel/ali/rerank.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "one-api/common" "one-api/dto" relaycommon "one-api/relay/common" "one-api/service" @@ -35,10 +36,7 @@ func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var aliResponse AliRerankResponse err = json.Unmarshal(responseBody, &aliResponse) diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index 2f1387c5c21f..fda8a7f478b4 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -45,10 +45,7 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) if aliResponse.Code != "" { return &dto.OpenAIErrorWithStatusCode{ @@ -186,10 +183,7 @@ func aliStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWith return false } }) - err := resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) return nil, &usage } @@ -199,10 +193,7 @@ func aliHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatus if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &aliResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 55b6c1379fdf..011af26293b0 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -166,10 +166,7 @@ func baiduStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWi return false } }) - err := resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) return nil, &usage } @@ -179,10 +176,7 @@ func baiduHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStat if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &baiduResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil @@ -215,10 +209,7 @@ func baiduEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErro if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &baiduResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 406ebc8a4017..f164fd4ddb70 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -7,6 +7,7 @@ import ( "net/http" "one-api/common" "one-api/dto" + "one-api/relay/channel/openrouter" relaycommon "one-api/relay/common" "one-api/relay/helper" "one-api/service" @@ -122,6 +123,21 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") } + if textRequest.Reasoning != nil { + var reasoning openrouter.RequestReasoning + if err := common.DecodeJson(textRequest.Reasoning, &reasoning); err != nil { + return nil, err + } + + budgetTokens := reasoning.MaxTokens + if budgetTokens > 0 { + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: &budgetTokens, + } + } + } + if textRequest.Stop != nil { // stop maybe string/array string, convert to array string switch textRequest.Stop.(type) { diff --git a/relay/channel/cloudflare/relay_cloudflare.go b/relay/channel/cloudflare/relay_cloudflare.go index 50d4928af68f..1c3a26f7d699 100644 --- a/relay/channel/cloudflare/relay_cloudflare.go +++ b/relay/channel/cloudflare/relay_cloudflare.go @@ -81,10 +81,7 @@ func cfStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela } helper.Done(c) - err := resp.Body.Close() - if err != nil { - common.LogError(c, "close_response_body_failed: "+err.Error()) - } + common.CloseResponseBodyGracefully(resp) return nil, usage } @@ -94,10 +91,7 @@ func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapperLocal(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var response dto.TextResponse err = json.Unmarshal(responseBody, &response) if err != nil { @@ -127,10 +121,7 @@ func cfSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayIn if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &cfResp) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go index 29064242d2a7..4637740d13ca 100644 --- a/relay/channel/cohere/relay-cohere.go +++ b/relay/channel/cohere/relay-cohere.go @@ -173,10 +173,7 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var cohereResp CohereResponseResult err = json.Unmarshal(responseBody, &cohereResp) if err != nil { @@ -217,10 +214,7 @@ func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon. if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var cohereResp CohereRerankResponseResult err = json.Unmarshal(responseBody, &cohereResp) if err != nil { diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go index ac76476f2023..6c08261b9844 100644 --- a/relay/channel/coze/relay-coze.go +++ b/relay/channel/coze/relay-coze.go @@ -48,10 +48,7 @@ func cozeChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapperLocal(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) // convert coze response to openai response var response dto.TextResponse var cozeResponse CozeChatDetailResponse diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go index 115aed1b00f0..c330c79118c2 100644 --- a/relay/channel/dify/relay-dify.go +++ b/relay/channel/dify/relay-dify.go @@ -257,10 +257,7 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &difyResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 39757ceffcb1..822d30975cf8 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -20,10 +20,7 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela if err != nil { return nil, service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) } - err = resp.Body.Close() - if err != nil { - return nil, service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError) - } + common.CloseResponseBodyGracefully(resp) if common.DebugEnabled { println(string(responseBody)) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 18edfd04d229..b01d46e4e9c8 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -78,26 +78,7 @@ func clampThinkingBudget(modelName string, budget int) int { return budget } -// Setting safety to the lowest possible values since Gemini is already powerless enough -func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { - - geminiRequest := GeminiChatRequest{ - Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), - GenerationConfig: GeminiChatGenerationConfig{ - Temperature: textRequest.Temperature, - TopP: textRequest.TopP, - MaxOutputTokens: textRequest.MaxTokens, - Seed: int64(textRequest.Seed), - }, - } - - if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { - geminiRequest.GenerationConfig.ResponseModalities = []string{ - "TEXT", - "IMAGE", - } - } - +func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && @@ -150,6 +131,29 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } } } +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { + + geminiRequest := GeminiChatRequest{ + Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: GeminiChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.MaxTokens, + Seed: int64(textRequest.Seed), + }, + } + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + geminiRequest.GenerationConfig.ResponseModalities = []string{ + "TEXT", + "IMAGE", + } + } + + ThinkingAdaptor(&geminiRequest, info) safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) for _, category := range SafetySettingList { @@ -862,10 +866,7 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) if common.DebugEnabled { println(string(responseBody)) } diff --git a/relay/channel/mokaai/relay-mokaai.go b/relay/channel/mokaai/relay-mokaai.go index d7580d7a9178..645475ddc959 100644 --- a/relay/channel/mokaai/relay-mokaai.go +++ b/relay/channel/mokaai/relay-mokaai.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/service" ) @@ -26,7 +27,7 @@ func embeddingRequestOpenAI2Moka(request dto.GeneralOpenAIRequest) *dto.Embeddin } return &dto.EmbeddingRequest{ Input: input, - Model: request.Model, + Model: request.Model, } } @@ -53,10 +54,7 @@ func mokaEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIError if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &baiduResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil @@ -80,4 +78,3 @@ func mokaEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIError _, err = c.Writer.Write(jsonResponse) return nil, &fullTextResponse.Usage } - diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 89a046461fa1..aa1ec441ed16 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/service" "strings" @@ -88,10 +89,7 @@ func ollamaEmbeddingHandler(c *gin.Context, resp *http.Response, promptTokens in if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &ollamaEmbeddingResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil @@ -141,10 +139,7 @@ func ollamaEmbeddingHandler(c *gin.Context, resp *http.Response, promptTokens in if err != nil { return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) return nil, usage } diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 8358f3e2e54a..424fd3dfaaf9 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -159,6 +159,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if info.ChannelType != common.ChannelTypeOpenAI && info.ChannelType != common.ChannelTypeAzure { request.StreamOptions = nil } + if info.ChannelType == common.ChannelTypeOpenRouter { + if len(request.Usage) == 0 { + request.Usage = json.RawMessage(`{"include":true}`) + } + } if strings.HasPrefix(request.Model, "o") { if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { request.MaxCompletionTokens = request.MaxTokens diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 71590cd62451..c2def5d937ea 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -2,7 +2,6 @@ package openai import ( "bytes" - "encoding/json" "fmt" "io" "math" @@ -111,12 +110,13 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel return service.OpenAIErrorWrapper(fmt.Errorf("invalid response"), "invalid_response", http.StatusInternalServerError), nil } - containStreamUsage := false + defer common.CloseResponseBodyGracefully(resp) + + model := info.UpstreamModelName var responseId string var createAt int64 = 0 var systemFingerprint string - model := info.UpstreamModelName - + var containStreamUsage bool var responseTextBuilder strings.Builder var toolCount int var usage = &dto.Usage{} @@ -148,31 +148,15 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel return true }) + // 处理最后的响应 shouldSendLastResp := true - var lastStreamResponse dto.ChatCompletionsStreamResponse - err := common.DecodeJsonStr(lastStreamData, &lastStreamResponse) - if err == nil { - responseId = lastStreamResponse.Id - createAt = lastStreamResponse.Created - systemFingerprint = lastStreamResponse.GetSystemFingerprint() - model = lastStreamResponse.Model - if service.ValidUsage(lastStreamResponse.Usage) { - containStreamUsage = true - usage = lastStreamResponse.Usage - if !info.ShouldIncludeUsage { - shouldSendLastResp = false - } - } - for _, choice := range lastStreamResponse.Choices { - if choice.FinishReason != nil { - shouldSendLastResp = true - } - } + if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, + &containStreamUsage, info, &shouldSendLastResp); err != nil { + common.SysError("error handling last response: " + err.Error()) } - if shouldSendLastResp { - sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) - //err = handleStreamFormat(c, info, lastStreamData, forceFormat, thinkToContent) + if shouldSendLastResp && info.RelayFormat == relaycommon.RelayFormatOpenAI { + _ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) } // 处理token计算 @@ -202,10 +186,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = common.DecodeJson(responseBody, &simpleResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil @@ -238,7 +219,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI switch info.RelayFormat { case relaycommon.RelayFormatOpenAI: if forceFormat { - responseBody, err = json.Marshal(simpleResponse) + responseBody, err = common.EncodeJson(simpleResponse) if err != nil { return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil } @@ -247,29 +228,15 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI } case relaycommon.RelayFormatClaude: claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info) - claudeRespStr, err := json.Marshal(claudeResp) + claudeRespStr, err := common.EncodeJson(claudeResp) if err != nil { return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil } responseBody = claudeRespStr } - // Reset response body - resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) - // We shouldn't set the header before we parse the response body, because the parse part may fail. - // And then we will have to send an error response, but in this case, the header has already been set. - // So the httpClient will be confused by the response. - // For example, Postman will report error, and we cannot check the response at all. - for k, v := range resp.Header { - c.Writer.Header().Set(k, v[0]) - } - c.Writer.WriteHeader(resp.StatusCode) - _, err = io.Copy(c.Writer, resp.Body) - if err != nil { - //return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil - common.SysError("error copying response body: " + err.Error()) - } - resp.Body.Close() + common.IOCopyBytesGracefully(c, resp, responseBody) + return nil, &simpleResponse.Usage } @@ -280,7 +247,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel // if the upstream returns a specific status code, once the upstream has already written the header, // the subsequent failure of the response body should be regarded as a non-recoverable error, // and can be terminated directly. - defer resp.Body.Close() + defer common.CloseResponseBodyGracefully(resp) usage := &dto.Usage{} usage.PromptTokens = info.PromptTokens usage.TotalTokens = info.PromptTokens @@ -306,25 +273,10 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } - // Reset response body - resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) - // We shouldn't set the header before we parse the response body, because the parse part may fail. - // And then we will have to send an error response, but in this case, the header has already been set. - // So the httpClient will be confused by the response. - // For example, Postman will report error, and we cannot check the response at all. - for k, v := range resp.Header { - c.Writer.Header().Set(k, v[0]) - } - c.Writer.WriteHeader(resp.StatusCode) - _, err = io.Copy(c.Writer, resp.Body) - if err != nil { - return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil - } - resp.Body.Close() + common.CloseResponseBodyGracefully(resp) + + // 写入新的 response body + common.IOCopyBytesGracefully(c, resp, responseBody) usage := &dto.Usage{} usage.PromptTokens = audioTokens @@ -415,7 +367,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op } realtimeEvent := &dto.RealtimeEvent{} - err = json.Unmarshal(message, realtimeEvent) + err = common.DecodeJson(message, realtimeEvent) if err != nil { errChan <- fmt.Errorf("error unmarshalling message: %v", err) return @@ -475,7 +427,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op } info.SetFirstResponseTime() realtimeEvent := &dto.RealtimeEvent{} - err = json.Unmarshal(message, realtimeEvent) + err = common.DecodeJson(message, realtimeEvent) if err != nil { errChan <- fmt.Errorf("error unmarshalling message: %v", err) return @@ -522,9 +474,9 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*dto.Op localUsage = &dto.RealtimeUsage{} // print now usage } - //common.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage)) - //common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) - //common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + common.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage)) + common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) } else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated { realtimeSession := realtimeEvent.Session @@ -605,36 +557,22 @@ func OpenaiHandlerWithUsage(c *gin.Context, resp *http.Response, info *relaycomm if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } - // Reset response body - resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) - // We shouldn't set the header before we parse the response body, because the parse part may fail. - // And then we will have to send an error response, but in this case, the header has already been set. - // So the httpClient will be confused by the response. - // For example, Postman will report error, and we cannot check the response at all. - for k, v := range resp.Header { - c.Writer.Header().Set(k, v[0]) - } - // reset content length - c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(responseBody))) - c.Writer.WriteHeader(resp.StatusCode) - _, err = io.Copy(c.Writer, resp.Body) - if err != nil { - return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil - } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } var usageResp dto.SimpleResponse - err = json.Unmarshal(responseBody, &usageResp) + err = common.DecodeJson(responseBody, &usageResp) if err != nil { return service.OpenAIErrorWrapper(err, "parse_response_body_failed", http.StatusInternalServerError), nil } + + // 关闭旧的 response body(已被读取,再次读取会导致错误) + common.CloseResponseBodyGracefully(resp) + + // 写入新的 response body + common.IOCopyBytesGracefully(c, resp, responseBody) + + // Once we've written to the client, we should not return errors anymore + // because the upstream has already consumed resources and returned content + // We should still perform billing even if parsing fails // format if usageResp.InputTokens > 0 { usageResp.PromptTokens += usageResp.InputTokens diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index da9382c32d70..f7eae7d37547 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -22,10 +22,7 @@ func OaiResponsesHandler(c *gin.Context, resp *http.Response, info *relaycommon. if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = common.DecodeJson(responseBody, &responsesResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index 9d3dbd67aef8..44c60713c219 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -83,12 +83,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWit stopChan <- true return } - err = resp.Body.Close() - if err != nil { - common.SysError("error closing stream response: " + err.Error()) - stopChan <- true - return - } + common.CloseResponseBodyGracefully(resp) var palmResponse PaLMChatResponse err = json.Unmarshal(responseBody, &palmResponse) if err != nil { @@ -122,10 +117,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWit return false } }) - err := resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" - } + common.CloseResponseBodyGracefully(resp) return nil, responseText } @@ -134,10 +126,7 @@ func palmHandler(c *gin.Context, resp *http.Response, promptTokens int, model st if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var palmResponse PaLMChatResponse err = json.Unmarshal(responseBody, &palmResponse) if err != nil { diff --git a/relay/channel/siliconflow/relay-siliconflow.go b/relay/channel/siliconflow/relay-siliconflow.go index a01e745caf93..a52ebfdaef44 100644 --- a/relay/channel/siliconflow/relay-siliconflow.go +++ b/relay/channel/siliconflow/relay-siliconflow.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/service" ) @@ -14,10 +15,7 @@ func siliconflowRerankHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIE if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) var siliconflowResp SFRerankResponse err = json.Unmarshal(responseBody, &siliconflowResp) if err != nil { diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go new file mode 100644 index 000000000000..b3610c3f37df --- /dev/null +++ b/relay/channel/task/jimeng/adaptor.go @@ -0,0 +1,380 @@ +package jimeng + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "one-api/model" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + ReqKey string `json:"req_key"` + BinaryDataBase64 []string `json:"binary_data_base64,omitempty"` + ImageUrls []string `json:"image_urls,omitempty"` + Prompt string `json:"prompt,omitempty"` + Seed int64 `json:"seed"` + AspectRatio string `json:"aspect_ratio"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +type responseTask struct { + Code int `json:"code"` + Data struct { + BinaryDataBase64 []interface{} `json:"binary_data_base64"` + ImageUrls interface{} `json:"image_urls"` + RespData string `json:"resp_data"` + Status string `json:"status"` + VideoUrl string `json:"video_url"` + } `json:"data"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Status int `json:"status"` + TimeElapsed string `json:"time_elapsed"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + accessKey string + secretKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.BaseUrl + + // apiKey format: "access_key,secret_key" + keyParts := strings.Split(info.ApiKey, ",") + if len(keyParts) == 2 { + a.accessKey = strings.TrimSpace(keyParts[0]) + a.secretKey = strings.TrimSpace(keyParts[1]) + } +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + action := constant.TaskActionGenerate + info.Action = action + + req := relaycommon.TaskSubmitReq{} + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Prompt) == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) + return + } + + // Store into context for later usage + c.Set("task_request", req) + return nil +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return a.signRequest(req, a.accessKey, a.secretKey) +} + +// BuildRequestBody converts request into Jimeng specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (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.TaskRelayInfo, 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.TaskRelayInfo) (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 Jimeng response + var jResp responsePayload + if err := json.Unmarshal(responseBody, &jResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if jResp.Code != 10000 { + taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": jResp.Data.TaskID}) + return jResp.Data.TaskID, 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/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl) + payload := map[string]string{ + "req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774 + "task_id": taskID, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "marshal fetch task payload failed") + } + + req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + keyParts := strings.Split(key, ",") + if len(keyParts) != 2 { + return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak,sk'") + } + accessKey := strings.TrimSpace(keyParts[0]) + secretKey := strings.TrimSpace(keyParts[1]) + + if err := a.signRequest(req, accessKey, secretKey); err != nil { + return nil, errors.Wrap(err, "sign request failed") + } + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"jimeng_vgfm_t2v_l20"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "jimeng" +} + +func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error { + var bodyBytes []byte + var err error + + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return errors.Wrap(err, "read request body failed") + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind + } else { + bodyBytes = []byte{} + } + + payloadHash := sha256.Sum256(bodyBytes) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + + t := time.Now().UTC() + xDate := t.Format("20060102T150405Z") + shortDate := t.Format("20060102") + + req.Header.Set("Host", req.URL.Host) + req.Header.Set("X-Date", xDate) + req.Header.Set("X-Content-Sha256", hexPayloadHash) + + // Sort and encode query parameters to create canonical query string + queryParams := req.URL.Query() + sortedKeys := make([]string, 0, len(queryParams)) + for k := range queryParams { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var queryParts []string + for _, k := range sortedKeys { + values := queryParams[k] + sort.Strings(values) + for _, v := range values { + queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v))) + } + } + canonicalQueryString := strings.Join(queryParts, "&") + + headersToSign := map[string]string{ + "host": req.URL.Host, + "x-date": xDate, + "x-content-sha256": hexPayloadHash, + } + if req.Header.Get("Content-Type") != "" { + headersToSign["content-type"] = req.Header.Get("Content-Type") + } + + var signedHeaderKeys []string + for k := range headersToSign { + signedHeaderKeys = append(signedHeaderKeys, k) + } + sort.Strings(signedHeaderKeys) + + var canonicalHeaders strings.Builder + for _, k := range signedHeaderKeys { + canonicalHeaders.WriteString(k) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k])) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderKeys, ";") + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + req.Method, + req.URL.Path, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + hexPayloadHash, + ) + + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:]) + + region := "cn-north-1" + serviceName := "cv" + credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName) + stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", + xDate, + credentialScope, + hexHashedCanonicalRequest, + ) + + kDate := hmacSHA256([]byte(secretKey), []byte(shortDate)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("request")) + signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign))) + + authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, + credentialScope, + signedHeaders, + signature, + ) + req.Header.Set("Authorization", authorization) + return nil +} + +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + ReqKey: "jimeng_vgfm_i2v_l20", + Prompt: req.Prompt, + AspectRatio: "16:9", // Default aspect ratio + Seed: -1, // Default to random + } + + // Handle one-of image_urls or binary_data_base64 + if req.Image != "" { + if strings.HasPrefix(req.Image, "http") { + r.ImageUrls = []string{req.Image} + } else { + r.BinaryDataBase64 = []string{req.Image} + } + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + 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{} + if resTask.Code == 10000 { + taskResult.Code = 0 + } else { + taskResult.Code = resTask.Code // todo uni code + taskResult.Reason = resTask.Message + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + } + switch resTask.Data.Status { + case "in_queue": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "done": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + } + taskResult.Url = resTask.Data.VideoUrl + return &taskResult, nil +} diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index 9ea58728234d..afa392016a68 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -2,11 +2,12 @@ package kling import ( "bytes" - "context" "encoding/json" "fmt" + "github.com/samber/lo" "io" "net/http" + "one-api/model" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/pkg/errors" "one-api/common" + "one-api/constant" "one-api/dto" "one-api/relay/channel" relaycommon "one-api/relay/common" @@ -41,16 +43,27 @@ type requestPayload struct { Mode string `json:"mode,omitempty"` Duration string `json:"duration,omitempty"` AspectRatio string `json:"aspect_ratio,omitempty"` - Model string `json:"model,omitempty"` ModelName string `json:"model_name,omitempty"` CfgScale float64 `json:"cfg_scale,omitempty"` } type responsePayload struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - TaskID string `json:"task_id"` + Code int `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Data struct { + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskResult struct { + Videos []struct { + Id string `json:"id"` + Url string `json:"url"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } `json:"data"` } @@ -69,8 +82,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.BaseUrl - // apiKey format: "access_key,secret_key" - keyParts := strings.Split(info.ApiKey, ",") + // apiKey format: "access_key|secret_key" + keyParts := strings.Split(info.ApiKey, "|") if len(keyParts) == 2 { a.accessKey = strings.TrimSpace(keyParts[0]) a.secretKey = strings.TrimSpace(keyParts[1]) @@ -80,7 +93,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { // Accept only POST /v1/video/generations as "generate" action. - action := "generate" + action := constant.TaskActionGenerate info.Action = action var req SubmitReq @@ -94,13 +107,14 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom } // Store into context for later usage - c.Set("kling_request", req) + c.Set("task_request", req) return nil } // BuildRequestURL constructs the upstream URL. func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { - return fmt.Sprintf("%s/v1/videos/image2video", a.baseURL), nil + path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + return fmt.Sprintf("%s%s", a.baseURL, path), nil } // BuildRequestHeader sets required headers. @@ -119,13 +133,16 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info // BuildRequestBody converts request into Kling specific format. func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { - v, exists := c.Get("kling_request") + v, exists := c.Get("task_request") if !exists { return nil, fmt.Errorf("request not found in context") } req := v.(SubmitReq) - body := a.convertToRequestPayload(&req) + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, err + } data, err := json.Marshal(body) if err != nil { return nil, err @@ -135,6 +152,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel // DoRequest delegates to common helper. func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + if action := c.GetString("action"); action != "" { + info.Action = action + } return channel.DoTaskApiRequest(a, c, info, requestBody) } @@ -149,8 +169,8 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela // Attempt Kling response parse first. var kResp responsePayload if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 { - c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskID}) - return kResp.Data.TaskID, responseBody, nil + c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskId}) + return kResp.Data.TaskId, responseBody, nil } // Fallback generic task response. @@ -175,7 +195,12 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http if !ok { return nil, fmt.Errorf("invalid task_id") } - url := fmt.Sprintf("%s/v1/videos/image2video/%s", baseUrl, taskID) + action, ok := body["action"].(string) + if !ok { + return nil, fmt.Errorf("invalid action") + } + path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -187,10 +212,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http token = key } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - req = req.WithContext(ctx) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "kling-sdk/1.0") @@ -210,22 +231,29 @@ func (a *TaskAdaptor) GetChannelName() string { // helpers // ============================ -func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) *requestPayload { - r := &requestPayload{ +func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { + r := requestPayload{ Prompt: req.Prompt, Image: req.Image, Mode: defaultString(req.Mode, "std"), Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)), AspectRatio: a.getAspectRatio(req.Size), - Model: req.Model, ModelName: req.Model, CfgScale: 0.5, } - if r.Model == "" { - r.Model = "kling-v1" + if r.ModelName == "" { r.ModelName = "kling-v1" } - return r + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil } func (a *TaskAdaptor) getAspectRatio(size string) string { @@ -264,7 +292,7 @@ func (a *TaskAdaptor) createJWTToken() (string, error) { } func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { - parts := strings.Split(apiKey, ",") + parts := strings.Split(apiKey, "|") if len(parts) != 2 { return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'") } @@ -286,27 +314,33 @@ func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (strin return token.SignedString([]byte(secretKey)) } -// ParseResultUrl 提取视频任务结果的 url -func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) { - data, ok := resp["data"].(map[string]any) - if !ok { - return "", fmt.Errorf("data field not found or invalid") - } - taskResult, ok := data["task_result"].(map[string]any) - if !ok { - return "", fmt.Errorf("task_result field not found or invalid") - } - videos, ok := taskResult["videos"].([]interface{}) - if !ok || len(videos) == 0 { - return "", fmt.Errorf("videos field not found or empty") +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resPayload := responsePayload{} + err := json.Unmarshal(respBody, &resPayload) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") } - video, ok := videos[0].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("video item invalid") + taskInfo := &relaycommon.TaskInfo{} + taskInfo.Code = resPayload.Code + taskInfo.TaskID = resPayload.Data.TaskId + taskInfo.Reason = resPayload.Message + //任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败) + status := resPayload.Data.TaskStatus + switch status { + case "submitted": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "succeed": + taskInfo.Status = model.TaskStatusSuccess + case "failed": + taskInfo.Status = model.TaskStatusFailure + default: + return nil, fmt.Errorf("unknown task status: %s", status) } - url, ok := video["url"].(string) - if !ok || url == "" { - return "", fmt.Errorf("url field not found or invalid") + if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 { + video := videos[0] + taskInfo.Url = video.Url } - return url, nil + return taskInfo, nil } diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go index f7042348ee96..9c04c7ad4d9a 100644 --- a/relay/channel/task/suno/adaptor.go +++ b/relay/channel/task/suno/adaptor.go @@ -22,8 +22,8 @@ type TaskAdaptor struct { ChannelType int } -func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) { - return "", nil // todo implement this method if needed +func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return nil, fmt.Errorf("not implement") // todo implement this method if needed } func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index 1446e06e2202..a7106a88c8db 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -124,10 +124,7 @@ func tencentStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIError helper.Done(c) - err := resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" - } + common.CloseResponseBodyGracefully(resp) return nil, responseText } @@ -138,10 +135,7 @@ func tencentHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithSt if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &tencentSb) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go index 408160fb11d7..9a300356735c 100644 --- a/relay/channel/xai/text.go +++ b/relay/channel/xai/text.go @@ -73,11 +73,7 @@ func xAIStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel } helper.Done(c) - err := resp.Body.Close() - if err != nil { - //return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - common.SysError("close_response_body_failed: " + err.Error()) - } + common.CloseResponseBodyGracefully(resp) return nil, usage } @@ -110,10 +106,7 @@ func xAIHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo if err != nil { return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) return nil, &response.Usage } diff --git a/relay/channel/xinference/dto.go b/relay/channel/xinference/dto.go index 2f12ad1047ee..35f339fe6e4b 100644 --- a/relay/channel/xinference/dto.go +++ b/relay/channel/xinference/dto.go @@ -1,7 +1,7 @@ package xinference type XinRerankResponseDocument struct { - Document string `json:"document,omitempty"` + Document any `json:"document,omitempty"` Index int `json:"index"` RelevanceScore float64 `json:"relevance_score"` } diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index 744538e3f5ab..91cd384b8b8d 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -210,10 +210,7 @@ func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWi return false } }) - err := resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) return nil, usage } @@ -223,10 +220,7 @@ func zhipuHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStat if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &zhipuResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 3759c3633221..5fd94788af0d 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -65,8 +65,8 @@ type RelayInfo struct { TokenId int TokenKey string UserId int - Group string - UserGroup string + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 TokenUnlimited bool StartTime time.Time FirstResponseTime time.Time @@ -219,7 +219,6 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { tokenId := c.GetInt("token_id") tokenKey := c.GetString("token_key") userId := c.GetInt("id") - group := c.GetString("group") tokenUnlimited := c.GetBool("token_unlimited_quota") startTime := c.GetTime(constant.ContextKeyRequestStartTime) // firstResponseTime = time.Now() - 1 second @@ -239,7 +238,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { TokenId: tokenId, TokenKey: tokenKey, UserId: userId, - Group: group, + UsingGroup: c.GetString(constant.ContextKeyUsingGroup), UserGroup: c.GetString(constant.ContextKeyUserGroup), TokenUnlimited: tokenUnlimited, StartTime: startTime, @@ -314,3 +313,22 @@ func GenTaskRelayInfo(c *gin.Context) *TaskRelayInfo { } return info } + +type TaskSubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +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"` +} diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go index 496278b5c06e..63ab4769f77f 100644 --- a/relay/common_handler/rerank.go +++ b/relay/common_handler/rerank.go @@ -16,10 +16,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } + common.CloseResponseBodyGracefully(resp) if common.DebugEnabled { println("reranker response body: ", string(responseBody)) } @@ -38,10 +35,16 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo } if info.ReturnDocuments { var document any - if result.Document == "" { - document = info.Documents[result.Index] - } else { - document = result.Document + if result.Document != nil { + if doc, ok := result.Document.(string); ok { + if doc == "" { + document = info.Documents[result.Index] + } else { + document = doc + } + } else { + document = result.Document + } } respResult.Document = document } diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 02a286e2f1c5..cc8a14945180 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -41,6 +41,9 @@ const ( RelayModeKlingFetchByID RelayModeKlingSubmit + RelayModeJimengFetchByID + RelayModeJimengSubmit + RelayModeRerank RelayModeResponses @@ -80,7 +83,7 @@ func Path2RelayMode(path string) int { relayMode = RelayModeRerank } else if strings.HasPrefix(path, "/v1/realtime") { relayMode = RelayModeRealtime - } else if strings.HasPrefix(path, "/v1beta/models") { + } else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") { relayMode = RelayModeGemini } return relayMode @@ -146,3 +149,13 @@ func Path2RelayKling(method, path string) int { } return relayMode } + +func Path2RelayJimeng(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") { + relayMode = RelayModeJimengSubmit + } else if method == http.MethodGet && strings.Contains(path, "/video/generations/") { + relayMode = RelayModeJimengFetchByID + } + return relayMode +} diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 14d58cc581e9..9185ce624e81 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -13,6 +13,7 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting" + "one-api/setting/model_setting" "strings" "github.com/gin-gonic/gin" @@ -76,6 +77,33 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay return inputTokens } +func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { + if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { + return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0 + } + return false +} + +func trimModelThinking(modelName string) string { + // 去除模型名称中的 -nothinking 后缀 + if strings.HasSuffix(modelName, "-nothinking") { + return strings.TrimSuffix(modelName, "-nothinking") + } + // 去除模型名称中的 -thinking 后缀 + if strings.HasSuffix(modelName, "-thinking") { + return strings.TrimSuffix(modelName, "-thinking") + } + + // 去除模型名称中的 -thinking-number + if strings.Contains(modelName, "-thinking-") { + parts := strings.Split(modelName, "-thinking-") + if len(parts) > 1 { + return parts[0] + "-thinking" + } + } + return modelName +} + func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { req, err := getAndValidateGeminiRequest(c) if err != nil { @@ -107,12 +135,27 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { relayInfo.SetPromptTokens(promptTokens) } else { promptTokens := getGeminiInputTokens(req, relayInfo) - if err != nil { - return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest) - } c.Set("prompt_tokens", promptTokens) } + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + if isNoThinkingRequest(req) { + // check is thinking + if !strings.Contains(relayInfo.OriginModelName, "-nothinking") { + // try to get no thinking model price + noThinkingModelName := relayInfo.OriginModelName + "-nothinking" + containPrice := helper.ContainPriceOrRatio(noThinkingModelName) + if containPrice { + relayInfo.OriginModelName = noThinkingModelName + relayInfo.UpstreamModelName = noThinkingModelName + } + } + } + if req.GenerationConfig.ThinkingConfig == nil { + gemini.ThinkingAdaptor(req, relayInfo) + } + } + priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens)) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError) diff --git a/relay/helper/price.go b/relay/helper/price.go index 1ee2767e1653..ab614cbd5767 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -13,6 +13,7 @@ import ( type GroupRatioInfo struct { GroupRatio float64 GroupSpecialRatio float64 + HasSpecialRatio bool } type PriceData struct { @@ -31,7 +32,7 @@ func (p PriceData) ToSetting() string { return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio) } -// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.Group if present +// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo { groupRatioInfo := GroupRatioInfo{ GroupRatio: 1.0, // default ratio @@ -44,18 +45,19 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupR if common.DebugEnabled { println(fmt.Sprintf("final group: %s", autoGroup)) } - relayInfo.Group = autoGroup.(string) + relayInfo.UsingGroup = autoGroup.(string) } // check user group special ratio - userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) if ok { // user group special ratio groupRatioInfo.GroupSpecialRatio = userGroupRatio groupRatioInfo.GroupRatio = userGroupRatio + groupRatioInfo.HasSpecialRatio = true } else { // normal group ratio - groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.Group) + groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) } return groupRatioInfo @@ -120,6 +122,35 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens return priceData, nil } +type PerCallPriceData struct { + ModelPrice float64 + Quota int + GroupRatioInfo GroupRatioInfo +} + +// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) +func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) PerCallPriceData { + groupRatioInfo := HandleGroupRatio(c, info) + + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) + // 如果没有配置价格,则使用默认价格 + if !success { + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[info.OriginModelName] + if !ok { + modelPrice = 0.1 + } else { + modelPrice = defaultPrice + } + } + quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + priceData := PerCallPriceData{ + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + } + return priceData +} + func ContainPriceOrRatio(modelName string) bool { _, ok := ratio_setting.GetModelPrice(modelName, false) if ok { diff --git a/relay/relay-mj.go b/relay/relay-mj.go index ce4346b63876..b44890c1713c 100644 --- a/relay/relay-mj.go +++ b/relay/relay-mj.go @@ -13,9 +13,9 @@ import ( "one-api/model" relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" + "one-api/relay/helper" "one-api/service" "one-api/setting" - "one-api/setting/ratio_setting" "strconv" "strings" "time" @@ -174,18 +174,9 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required") } modelName := service.CoverActionToModelName(constant.MjActionSwapFace) - modelPrice, success := ratio_setting.GetModelPrice(modelName, true) - // 如果没有配置价格,则使用默认价格 - if !success { - defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] - if !ok { - modelPrice = 0.1 - } else { - modelPrice = defaultPrice - } - } - groupRatio := ratio_setting.GetGroupRatio(group) - ratio := modelPrice * groupRatio + + priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + userQuota, err := model.GetUserQuota(userId, false) if err != nil { return &dto.MidjourneyResponse{ @@ -193,9 +184,8 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { Description: err.Error(), } } - quota := int(ratio * common.QuotaPerUnit) - if userQuota-quota < 0 { + if userQuota-priceData.Quota < 0 { return &dto.MidjourneyResponse{ Code: 4, Description: "quota_not_enough", @@ -210,26 +200,18 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { } defer func() { if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 { - err := service.PostConsumeQuota(relayInfo, quota, 0, true) + err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true) if err != nil { common.SysError("error consuming token remain quota: " + err.Error()) } - //err = model.CacheUpdateUserQuota(userId) - if err != nil { - common.SysError("error update user quota cache: " + err.Error()) - } - if quota != 0 { - tokenName := c.GetString("token_name") - logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, constant.MjActionSwapFace) - other := make(map[string]interface{}) - other["model_price"] = modelPrice - other["group_ratio"] = groupRatio - model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName, - quota, logContent, tokenId, userQuota, 0, false, group, other) - model.UpdateUserUsedQuotaAndRequestCount(userId, quota) - channelId := c.GetInt("channel_id") - model.UpdateChannelUsedQuota(channelId, quota) - } + + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace) + other := service.GenerateMjOtherInfo(priceData) + model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName, + priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other) + model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota) + model.UpdateChannelUsedQuota(channelId, priceData.Quota) } }() midjResponse := &mjResp.Response @@ -250,7 +232,7 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { Progress: "0%", FailReason: "", ChannelId: c.GetInt("channel_id"), - Quota: quota, + Quota: priceData.Quota, } err = midjourneyTask.Insert() if err != nil { @@ -480,18 +462,9 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) modelName := service.CoverActionToModelName(midjRequest.Action) - modelPrice, success := ratio_setting.GetModelPrice(modelName, true) - // 如果没有配置价格,则使用默认价格 - if !success { - defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] - if !ok { - modelPrice = 0.1 - } else { - modelPrice = defaultPrice - } - } - groupRatio := ratio_setting.GetGroupRatio(group) - ratio := modelPrice * groupRatio + + priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + userQuota, err := model.GetUserQuota(userId, false) if err != nil { return &dto.MidjourneyResponse{ @@ -499,9 +472,8 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons Description: err.Error(), } } - quota := int(ratio * common.QuotaPerUnit) - if consumeQuota && userQuota-quota < 0 { + if consumeQuota && userQuota-priceData.Quota < 0 { return &dto.MidjourneyResponse{ Code: 4, Description: "quota_not_enough", @@ -516,22 +488,17 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons defer func() { if consumeQuota && midjResponseWithStatus.StatusCode == 200 { - err := service.PostConsumeQuota(relayInfo, quota, 0, true) + err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true) if err != nil { common.SysError("error consuming token remain quota: " + err.Error()) } - if quota != 0 { - tokenName := c.GetString("token_name") - logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", modelPrice, groupRatio, midjRequest.Action, midjResponse.Result) - other := make(map[string]interface{}) - other["model_price"] = modelPrice - other["group_ratio"] = groupRatio - model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName, - quota, logContent, tokenId, userQuota, 0, false, group, other) - model.UpdateUserUsedQuotaAndRequestCount(userId, quota) - channelId := c.GetInt("channel_id") - model.UpdateChannelUsedQuota(channelId, quota) - } + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result) + other := service.GenerateMjOtherInfo(priceData) + model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName, + priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other) + model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota) + model.UpdateChannelUsedQuota(channelId, priceData.Quota) } }() @@ -559,7 +526,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons Progress: "0%", FailReason: "", ChannelId: c.GetInt("channel_id"), - Quota: quota, + Quota: priceData.Quota, } if midjResponse.Code == 3 { //无实例账号自动禁用渠道(No available account instance) diff --git a/relay/relay-text.go b/relay/relay-text.go index db8d0d3b927a..e0c8f047b297 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -541,5 +541,5 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other["audio_input_price"] = audioInputPrice } model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel, - tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) + tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other) } diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 626bb7e4dd8b..f648b4d572d9 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -22,6 +22,7 @@ import ( "one-api/relay/channel/palm" "one-api/relay/channel/perplexity" "one-api/relay/channel/siliconflow" + "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" "one-api/relay/channel/tencent" @@ -104,6 +105,8 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor { return &suno.TaskAdaptor{} case commonconstant.TaskPlatformKling: return &kling.TaskAdaptor{} + case commonconstant.TaskPlatformJimeng: + return &jimeng.TaskAdaptor{} } return nil } diff --git a/relay/relay_task.go b/relay/relay_task.go index 245fd68139fb..702cff4c66c7 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/gin-gonic/gin" "io" "net/http" "one-api/common" @@ -16,6 +15,8 @@ import ( relayconstant "one-api/relay/constant" "one-api/service" "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" ) /* @@ -51,8 +52,14 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } // 预扣 - groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group) - ratio := modelPrice * groupRatio + groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + var ratio float64 + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if hasUserGroupRatio { + ratio = modelPrice * userGroupRatio + } else { + ratio = modelPrice * groupRatio + } userQuota, err := model.GetUserQuota(relayInfo.UserId, false) if err != nil { taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) @@ -121,12 +128,19 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } if quota != 0 { tokenName := c.GetString("token_name") - logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, relayInfo.Action) + gRatio := groupRatio + if hasUserGroupRatio { + gRatio = userGroupRatio + } + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action) other := make(map[string]interface{}) other["model_price"] = modelPrice other["group_ratio"] = groupRatio + if hasUserGroupRatio { + other["user_group_ratio"] = userGroupRatio + } model.RecordConsumeLog(c, relayInfo.UserId, relayInfo.ChannelId, 0, 0, - modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.Group, other) + modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.UsingGroup, other) model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) } @@ -231,7 +245,7 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt } func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { - taskId := c.Param("id") + taskId := c.Param("task_id") userId := c.GetInt("id") originTask, exist, err := model.GetByTaskId(userId, taskId) diff --git a/router/api-router.go b/router/api-router.go index badfa7bf4e28..db4c3898524b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -125,6 +125,7 @@ func SetApiRouter(router *gin.Engine) { tokenRoute.POST("/", controller.AddToken) tokenRoute.PUT("/", controller.UpdateToken) tokenRoute.DELETE("/:id", controller.DeleteToken) + tokenRoute.POST("/batch", controller.DeleteTokenBatch) } redemptionRoute := apiRouter.Group("/redemption") redemptionRoute.Use(middleware.AdminAuth()) diff --git a/router/relay-router.go b/router/relay-router.go index aa7f27a88b3c..325ef1355dfb 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -63,6 +63,7 @@ func SetRelayRouter(router *gin.Engine) { httpRouter.DELETE("/models/:model", controller.RelayNotImplemented) httpRouter.POST("/moderations", controller.Relay) httpRouter.POST("/rerank", controller.Relay) + httpRouter.POST("/models/*path", controller.Relay) } relayMjRouter := router.Group("/mj") diff --git a/router/video-router.go b/router/video-router.go index 7201c34ab804..9e605d5411d6 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -14,4 +14,11 @@ func SetVideoRouter(router *gin.Engine) { videoV1Router.POST("/video/generations", controller.RelayTask) videoV1Router.GET("/video/generations/:task_id", controller.RelayTask) } + + klingV1Router := router.Group("/kling/v1") + klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + klingV1Router.POST("/videos/text2video", controller.RelayTask) + klingV1Router.POST("/videos/image2video", controller.RelayTask) + } } diff --git a/service/convert.go b/service/convert.go index 7a9e8403456e..df7acf0d5967 100644 --- a/service/convert.go +++ b/service/convert.go @@ -276,12 +276,15 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } if info.Done { claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - if info.ClaudeConvertInfo.Usage != nil { + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Type: "message_delta", Usage: &dto.ClaudeUsage{ - InputTokens: info.ClaudeConvertInfo.Usage.PromptTokens, - OutputTokens: info.ClaudeConvertInfo.Usage.CompletionTokens, + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, }, Delta: &dto.ClaudeMediaMessage{ StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), diff --git a/service/error.go b/service/error.go index f3d8a17decbf..21835f2a4fad 100644 --- a/service/error.go +++ b/service/error.go @@ -90,10 +90,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (errWithStatu if err != nil { return } - err = resp.Body.Close() - if err != nil { - return - } + common.CloseResponseBodyGracefully(resp) var errResponse dto.GeneralErrorResponse err = json.Unmarshal(responseBody, &errResponse) if err != nil { diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 1edc90736980..affae5fba85c 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -3,6 +3,7 @@ package service import ( "one-api/dto" relaycommon "one-api/relay/common" + "one-api/relay/helper" "github.com/gin-gonic/gin" ) @@ -63,3 +64,13 @@ func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, info["cache_creation_ratio"] = cacheCreationRatio return info } + +func GenerateMjOtherInfo(priceData helper.PerCallPriceData) map[string]interface{} { + other := make(map[string]interface{}) + other["model_price"] = priceData.ModelPrice + other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio + if priceData.GroupRatioInfo.HasSpecialRatio { + other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio + } + return other +} diff --git a/service/midjourney.go b/service/midjourney.go index 635c29ae7fbe..b4ef15a9b120 100644 --- a/service/midjourney.go +++ b/service/midjourney.go @@ -228,10 +228,7 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU if err != nil { return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err } - err = resp.Body.Close() - if err != nil { - return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_response_body_failed", statusCode), responseBody, err - } + common.CloseResponseBodyGracefully(resp) respStr := string(responseBody) log.Printf("respStr: %s", respStr) if respStr == "" { diff --git a/service/quota.go b/service/quota.go index 973deba7c1fd..c17616a74f61 100644 --- a/service/quota.go +++ b/service/quota.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "math" "one-api/common" constant2 "one-api/constant" "one-api/dto" @@ -94,18 +95,18 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag textOutTokens := usage.OutputTokenDetails.TextTokens audioInputTokens := usage.InputTokenDetails.AudioTokens audioOutTokens := usage.OutputTokenDetails.AudioTokens - groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group) + groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) modelRatio, _ := ratio_setting.GetModelRatio(modelName) autoGroup, exists := ctx.Get("auto_group") if exists { groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) log.Printf("final group ratio: %f", groupRatio) - relayInfo.Group = autoGroup.(string) + relayInfo.UsingGroup = autoGroup.(string) } actualGroupRatio := groupRatio - userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) if ok { actualGroupRatio = userGroupRatio } @@ -209,7 +210,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel, - tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) + tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other) } func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, @@ -231,6 +232,17 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, cacheCreationRatio := priceData.CacheCreationRatio cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + if relayInfo.ChannelType == common.ChannelTypeOpenRouter { + promptTokens -= cacheTokens + if cacheCreationTokens == 0 && priceData.CacheCreationRatio != 1 && usage.Cost != 0 { + maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, priceData) + if promptTokens >= maybeCacheCreationTokens { + cacheCreationTokens = maybeCacheCreationTokens + } + } + promptTokens -= cacheCreationTokens + } + calculateQuota := 0.0 if !priceData.UsePrice { calculateQuota = float64(promptTokens) @@ -275,7 +287,28 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName, - tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) + tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other) +} + +func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData) int { + if priceData.CacheCreationRatio == 1 { + return 0 + } + quotaPrice := priceData.ModelRatio / common.QuotaPerUnit + promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio + promptCacheReadPrice := quotaPrice * priceData.CacheRatio + completionPrice := quotaPrice * priceData.CompletionRatio + + cost := usage.Cost + totalPromptTokens := float64(usage.PromptTokens) + completionTokens := float64(usage.CompletionTokens) + promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens) + + return int(math.Round((cost - + totalPromptTokens*quotaPrice + + promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) - + completionTokens*completionPrice) / + (promptCacheCreatePrice - quotaPrice))) } func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, @@ -352,7 +385,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel, - tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) + tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other) } func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go index f600a7b59642..86f4a8d19c9e 100644 --- a/setting/ratio_setting/group_ratio.go +++ b/setting/ratio_setting/group_ratio.go @@ -73,15 +73,15 @@ func GetGroupRatio(name string) float64 { return ratio } -func GetGroupGroupRatio(group, name string) (float64, bool) { +func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) { groupGroupRatioMutex.RLock() defer groupGroupRatioMutex.RUnlock() - gp, ok := GroupGroupRatio[group] + gp, ok := GroupGroupRatio[userGroup] if !ok { return -1, false } - ratio, ok := gp[name] + ratio, ok := gp[usingGroup] if !ok { return -1, false } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 879423eb04a8..8da144448176 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -501,16 +501,19 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { } else if strings.HasPrefix(name, "gemini-2.0") { return 4, true } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 - return 8, true + return 8, false } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率 if strings.HasPrefix(name, "gemini-2.5-flash-preview") { if strings.HasSuffix(name, "-nothinking") { - return 4, true + return 4, false } - return 3.5 / 0.15, true + return 3.5 / 0.15, false } - if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { - return 4, true + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { + return 4, false + } + return 4, false } return 2.5 / 0.3, true } diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index c8847a33d720..85d22dfcb3cf 100644 --- a/web/src/components/auth/LoginForm.js +++ b/web/src/components/auth/LoginForm.js @@ -34,20 +34,20 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; import { useTranslation } from 'react-i18next'; const LoginForm = () => { + let navigate = useNavigate(); + const { t } = useTranslation(); const [inputs, setInputs] = useState({ username: '', password: '', wechat_verification_code: '', }); + const { username, password } = inputs; const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); - const { username, password } = inputs; const [userState, userDispatch] = useContext(UserContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); - let navigate = useNavigate(); - const [status, setStatus] = useState({}); const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); const [showEmailLogin, setShowEmailLogin] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); @@ -59,7 +59,6 @@ const LoginForm = () => { const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); - const { t } = useTranslation(); const logo = getLogo(); const systemName = getSystemName(); @@ -69,19 +68,22 @@ const LoginForm = () => { localStorage.setItem('aff', affCode); } + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + useEffect(() => { + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + }, [status]); + useEffect(() => { if (searchParams.get('expired')) { showError(t('未登录或登录已过期,请重新登录')); } - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } }, []); const onWeChatLoginClicked = () => { @@ -356,9 +358,19 @@ const LoginForm = () => { -
- {t('没有账户?')} {t('注册')} -
+ {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} @@ -451,9 +463,19 @@ const LoginForm = () => { )} -
- {t('没有账户?')} {t('注册')} -
+ {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} @@ -499,8 +521,11 @@ const LoginForm = () => { }; return ( -
-
+
+ {/* 背景模糊晕染球 */} +
+
+
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailLoginForm() : renderOAuthOptions()} diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js index e2d9a9ad28ed..e7809298b653 100644 --- a/web/src/components/auth/PasswordResetConfirm.js +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -78,8 +78,11 @@ const PasswordResetConfirm = () => { } return ( -
-
+
+ {/* 背景模糊晕染球 */} +
+
+
diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index 29c3d4774908..9a782117cca8 100644 --- a/web/src/components/auth/PasswordResetForm.js +++ b/web/src/components/auth/PasswordResetForm.js @@ -78,8 +78,11 @@ const PasswordResetForm = () => { } return ( -
-
+
+ {/* 背景模糊晕染球 */} +
+
+
diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 0d9c8982b2fe..a3a5e0b46fc7 100644 --- a/web/src/components/auth/RegisterForm.js +++ b/web/src/components/auth/RegisterForm.js @@ -35,6 +35,7 @@ import { UserContext } from '../../context/User/index.js'; import { useTranslation } from 'react-i18next'; const RegisterForm = () => { + let navigate = useNavigate(); const { t } = useTranslation(); const [inputs, setInputs] = useState({ username: '', @@ -45,15 +46,12 @@ const RegisterForm = () => { wechat_verification_code: '', }); const { username, password, password2 } = inputs; - const [showEmailVerification, setShowEmailVerification] = useState(false); const [userState, userDispatch] = useContext(UserContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); const [showEmailRegister, setShowEmailRegister] = useState(false); - const [status, setStatus] = useState({}); const [wechatLoading, setWechatLoading] = useState(false); const [githubLoading, setGithubLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); @@ -63,7 +61,6 @@ const RegisterForm = () => { const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); - let navigate = useNavigate(); const logo = getLogo(); const systemName = getSystemName(); @@ -73,18 +70,22 @@ const RegisterForm = () => { localStorage.setItem('aff', affCode); } + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + const [showEmailVerification, setShowEmailVerification] = useState(() => { + return status.email_verification ?? false; + }); + useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } + setShowEmailVerification(status.email_verification); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); } - }, []); + }, [status]); const onWeChatLoginClicked = () => { setWechatLoading(true); @@ -541,8 +542,11 @@ const RegisterForm = () => { }; return ( -
-
+
+ {/* 背景模糊晕染球 */} +
+
+
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailRegisterForm() : renderOAuthOptions()} diff --git a/web/src/components/layout/NoticeModal.js b/web/src/components/layout/NoticeModal.js index 456c012f4754..55126ad8101f 100644 --- a/web/src/components/layout/NoticeModal.js +++ b/web/src/components/layout/NoticeModal.js @@ -113,25 +113,31 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK return (
- {processedAnnouncements.map((item, idx) => ( - -
- {item.isUnread ? ( - - {item.content} - - ) : ( - item.content - )} - {item.extra &&
{item.extra}
} -
-
- ))} + {processedAnnouncements.map((item, idx) => { + const htmlContent = marked.parse(item.content || ''); + const htmlExtra = item.extra ? marked.parse(item.extra) : ''; + return ( + +
+
+ {item.extra && ( +
+ )} +
+ + ); + })}
); diff --git a/web/src/components/layout/PageLayout.js b/web/src/components/layout/PageLayout.js index e25901efb39d..17d16fc0c08f 100644 --- a/web/src/components/layout/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -11,7 +11,7 @@ import { API, getLogo, getSystemName, showError, setStatusData } from '../../hel import { UserContext } from '../../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js'; import { useLocation } from 'react-router-dom'; -const { Sider, Content, Header, Footer } = Layout; +const { Sider, Content, Header } = Layout; const PageLayout = () => { const [userState, userDispatch] = useContext(UserContext); @@ -94,8 +94,6 @@ const PageLayout = () => { { - return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default; -}; - -export default function ChannelSelectorModal({ - t, +const ChannelSelectorModal = forwardRef(({ visible, onCancel, onOk, - allChannels = [], - selectedChannelIds = [], + allChannels, + selectedChannelIds, setSelectedChannelIds, channelEndpoints, updateChannelEndpoint, -}) { + t, +}, ref) => { const [searchText, setSearchText] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); - const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => { - const channelId = item.key || item.value; - const currentEndpoint = channelEndpoints[channelId]; - const baseUrl = item._originalData?.base_url || ''; - const status = item._originalData?.status || 0; - const statusConfig = getChannelStatusConfig(status); + const [filteredData, setFilteredData] = useState([]); - return ( - <> - - {statusConfig.text} - -
-
- {isSelected ? ( - item.label - ) : ( - - )} -
-
- - {isSelected ? ( - baseUrl - ) : ( - - )} - - {showEndpoint && ( - updateChannelEndpoint(channelId, value)} - placeholder="/api/ratio_config" - className="flex-1 text-xs" - style={{ fontSize: '12px' }} - /> - )} - {isSelected && !showEndpoint && ( - - {currentEndpoint} - - )} -
-
- - ); + useImperativeHandle(ref, () => ({ + resetPagination: () => { + setCurrentPage(1); + setSearchText(''); + }, + })); + + useEffect(() => { + if (!allChannels) return; + + const searchLower = searchText.trim().toLowerCase(); + const matched = searchLower + ? allChannels.filter((item) => { + const name = (item.label || '').toLowerCase(); + const baseUrl = (item._originalData?.base_url || '').toLowerCase(); + return name.includes(searchLower) || baseUrl.includes(searchLower); + }) + : allChannels; + + setFilteredData(matched); + }, [allChannels, searchText]); + + const total = filteredData.length; + + const paginatedData = filteredData.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + const updateEndpoint = (channelId, endpoint) => { + if (typeof updateChannelEndpoint === 'function') { + updateChannelEndpoint(channelId, endpoint); + } }; - const renderSourceItem = (item) => { + const renderEndpointCell = (text, record) => { + const channelId = record.key || record.value; + const currentEndpoint = channelEndpoints[channelId] || ''; + + const getEndpointType = (ep) => { + if (ep === '/api/ratio_config') return 'ratio_config'; + if (ep === '/api/pricing') return 'pricing'; + return 'custom'; + }; + + const currentType = getEndpointType(currentEndpoint); + + const handleTypeChange = (val) => { + if (val === 'ratio_config') { + updateEndpoint(channelId, '/api/ratio_config'); + } else if (val === 'pricing') { + updateEndpoint(channelId, '/api/pricing'); + } else { + if (currentType !== 'custom') { + updateEndpoint(channelId, ''); + } + } + }; + return ( -
- - - +
+ updateEndpoint(channelId, val)} + placeholder="/your/endpoint" + style={{ width: 160, fontSize: 12 }} + /> + )}
); }; - const renderSelectedItem = (item) => { - return ( -
- - -
- ); + const renderStatusCell = (status) => { + switch (status) { + case 1: + return ( + }> + {t('已启用')} + + ); + case 2: + return ( + }> + {t('已禁用')} + + ); + case 3: + return ( + }> + {t('自动禁用')} + + ); + default: + return ( + }> + {t('未知状态')} + + ); + } }; - const channelFilter = (input, item) => { - const searchLower = input.toLowerCase(); - return item.label.toLowerCase().includes(searchLower) || - (item._originalData?.base_url || '').toLowerCase().includes(searchLower); + const renderNameCell = (text) => ( + + ); + + const renderBaseUrlCell = (text) => ( + + ); + + const columns = [ + { + title: t('名称'), + dataIndex: 'label', + render: renderNameCell, + }, + { + title: t('源地址'), + dataIndex: '_originalData.base_url', + render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''), + }, + { + title: t('状态'), + dataIndex: '_originalData.status', + render: (_, record) => renderStatusCell(record._originalData?.status || 0), + }, + { + title: t('同步接口'), + dataIndex: 'endpoint', + fixed: 'right', + render: renderEndpointCell, + }, + ]; + + const rowSelection = { + selectedRowKeys: selectedChannelIds, + onChange: (keys) => setSelectedChannelIds(keys), }; return ( @@ -118,26 +186,51 @@ export default function ChannelSelectorModal({ onCancel={onCancel} onOk={onOk} title={{t('选择同步渠道')}} - width={1000} + size={isMobile() ? 'full-width' : 'large'} + keepDOM + lazyRender={false} > - } + placeholder={t('搜索渠道名称或地址')} + value={searchText} + onChange={setSearchText} + showClear + className="!rounded-full" + /> + + t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: total, + }), + onChange: (page, size) => { + setCurrentPage(page); + setPageSize(size); + }, + onShowSizeChange: (curr, size) => { + setCurrentPage(1); + setPageSize(size); + }, }} + size="small" /> ); -} \ No newline at end of file +}); + +export default ChannelSelectorModal; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.js b/web/src/components/settings/PersonalSetting.js index 36eb4e4d58e6..7e2b85fd48d8 100644 --- a/web/src/components/settings/PersonalSetting.js +++ b/web/src/components/settings/PersonalSetting.js @@ -379,257 +379,268 @@ const PersonalSetting = () => { }; return ( -
- - - -
-
- {/* 主卡片容器 */} - - {/* 顶部用户信息区域 */} - - {/* 装饰性背景元素 */} -
-
-
-
+
+
+
+ {/* 主卡片容器 */} + + {/* 顶部用户信息区域 */} + + {/* 装饰性背景元素 */} +
+
+
+
+
+ +
+
+
+ + {getAvatarText()} + +
+
+ {getUsername()} +
+
+ {isRoot() ? ( + + {t('超级管理员')} + + ) : isAdmin() ? ( + + {t('管理员')} + + ) : ( + + {t('普通用户')} + + )} + + ID: {userState?.user?.id} + +
+
+
+ +
+
-
-
-
- - {getAvatarText()} - -
-
- {getUsername()} -
-
- {isRoot() ? ( - - {t('超级管理员')} - - ) : isAdmin() ? ( - - {t('管理员')} - - ) : ( - - {t('普通用户')} - - )} - - ID: {userState?.user?.id} - -
-
+
+
+ {t('当前余额')} +
+
+ {renderQuota(userState?.user?.quota)} +
+
+ +
+
+
+
+ {t('历史消耗')}
-
- +
+ {renderQuota(userState?.user?.used_quota)}
- -
-
- {t('当前余额')} +
+
+ {t('请求次数')} +
+
+ {userState.user?.request_count || 0}
-
- {renderQuota(userState?.user?.quota)} +
+
+
+ {t('用户分组')} +
+
+ {userState?.user?.group || t('默认')}
+
+
-
-
-
-
- {t('历史消耗')} -
-
- {renderQuota(userState?.user?.used_quota)} -
-
-
-
- {t('请求次数')} -
-
- {userState.user?.request_count || 0} -
+
+
+ + + {/* 主内容区域 - 使用Tabs组织不同功能模块 */} +
+ + {/* 可用模型Tab */} + + + {t('可用模型')} +
+ } + itemKey='models' + > +
+ {/* 可用模型部分 */} +
+
+
+
-
-
- {t('用户分组')} -
-
- {userState?.user?.group || t('默认')} -
+
+ {t('模型列表')} +
{t('点击模型名称可复制')}
-
-
-
- - - {/* 主内容区域 - 使用Tabs组织不同功能模块 */} -
- - {/* 可用模型Tab */} - - - {t('可用模型')} -
- } - itemKey='models' - > -
- {/* 可用模型部分 */} -
-
-
- -
-
- {t('模型列表')} -
{t('点击模型名称可复制')}
+ {modelsLoading ? ( + // 骨架屏加载状态 - 模拟实际加载后的布局 +
+ {/* 模拟分类标签 */} +
+
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))}
- {modelsLoading ? ( - // 骨架屏加载状态 - 模拟实际加载后的布局 -
- {/* 模拟分类标签 */} -
-
- {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
-
- - {/* 模拟模型标签列表 */} -
- {Array.from({ length: 20 }).map((_, index) => ( - - ))} -
-
- ) : models.length === 0 ? ( -
- } - darkModeImage={} - description={t('没有可用模型')} - style={{ padding: '24px 0' }} + {/* 模拟模型标签列表 */} +
+ {Array.from({ length: 20 }).map((_, index) => ( + -
- ) : ( - <> - {/* 模型分类标签页 */} -
- setActiveModelCategory(key)} - className="mt-2" - > - {Object.entries(getModelCategories(t)).map(([key, category]) => { - // 计算该分类下的模型数量 - const modelCount = key === 'all' - ? models.length - : models.filter(model => category.filter({ model_name: model })).length; - - if (modelCount === 0 && key !== 'all') return null; - - return ( - - {category.icon && {category.icon}} - {category.label} - - {modelCount} - - - } - itemKey={key} - key={key} - /> - ); - })} - -
- -
- {(() => { - // 根据当前选中的分类过滤模型 - const categories = getModelCategories(t); - const filteredModels = activeModelCategory === 'all' - ? models - : models.filter(model => categories[activeModelCategory].filter({ model_name: model })); - - // 如果过滤后没有模型,显示空状态 - if (filteredModels.length === 0) { - return ( - } - darkModeImage={} - description={t('该分类下没有可用模型')} - style={{ padding: '16px 0' }} - /> - ); - } + ))} +
+
+ ) : models.length === 0 ? ( +
+ } + darkModeImage={} + description={t('没有可用模型')} + style={{ padding: '24px 0' }} + /> +
+ ) : ( + <> + {/* 模型分类标签页 */} +
+ setActiveModelCategory(key)} + className="mt-2" + > + {Object.entries(getModelCategories(t)).map(([key, category]) => { + // 计算该分类下的模型数量 + const modelCount = key === 'all' + ? models.length + : models.filter(model => category.filter({ model_name: model })).length; + + if (modelCount === 0 && key !== 'all') return null; + + return ( + + {category.icon && {category.icon}} + {category.label} + + {modelCount} + + + } + itemKey={key} + key={key} + /> + ); + })} + +
- if (filteredModels.length <= MODELS_DISPLAY_COUNT) { - return ( +
+ {(() => { + // 根据当前选中的分类过滤模型 + const categories = getModelCategories(t); + const filteredModels = activeModelCategory === 'all' + ? models + : models.filter(model => categories[activeModelCategory].filter({ model_name: model })); + + // 如果过滤后没有模型,显示空状态 + if (filteredModels.length === 0) { + return ( + } + darkModeImage={} + description={t('该分类下没有可用模型')} + style={{ padding: '16px 0' }} + /> + ); + } + + if (filteredModels.length <= MODELS_DISPLAY_COUNT) { + return ( + + {filteredModels.map((model) => ( + renderModelTag(model, { + size: 'large', + shape: 'circle', + onClick: () => copyText(model), + }) + ))} + + ); + } else { + return ( + <> + {filteredModels.map((model) => ( renderModelTag(model, { @@ -638,527 +649,513 @@ const PersonalSetting = () => { onClick: () => copyText(model), }) ))} + setIsModelsExpanded(false)} + icon={} + > + {t('收起')} + - ); - } else { - return ( - <> - - - {filteredModels.map((model) => ( - renderModelTag(model, { - size: 'large', - shape: 'circle', - onClick: () => copyText(model), - }) - ))} - setIsModelsExpanded(false)} - icon={} - > - {t('收起')} - - - - {!isModelsExpanded && ( - - {filteredModels - .slice(0, MODELS_DISPLAY_COUNT) - .map((model) => ( - renderModelTag(model, { - size: 'large', - shape: 'circle', - onClick: () => copyText(model), - }) - ))} - setIsModelsExpanded(true)} - icon={} - > - {t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')} - - - )} - - ); - } - })()} + + {!isModelsExpanded && ( + + {filteredModels + .slice(0, MODELS_DISPLAY_COUNT) + .map((model) => ( + renderModelTag(model, { + size: 'large', + shape: 'circle', + onClick: () => copyText(model), + }) + ))} + setIsModelsExpanded(true)} + icon={} + > + {t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')} + + + )} + + ); + } + })()} +
+ + )} +
+
+ + + {/* 账户绑定Tab */} + + + {t('账户绑定')} +
+ } + itemKey='account' + > +
+
+ {/* 邮箱绑定 */} + +
+
+
+ +
+
+
{t('邮箱')}
+
+ {userState.user && userState.user.email !== '' + ? userState.user.email + : t('未绑定')}
- - )} -
-
- - - {/* 账户绑定Tab */} - - - {t('账户绑定')} +
+
+
- } - itemKey='account' - > -
-
- {/* 邮箱绑定 */} - + + {/* 微信绑定 */} + +
+
+
+ +
+
+
{t('微信')}
+
+ {userState.user && userState.user.wechat_id !== '' + ? t('已绑定') + : t('未绑定')} +
+
+
+ +
+
+ + {/* GitHub绑定 */} + +
+
+
+ +
+
+
{t('GitHub')}
+
+ {userState.user && userState.user.github_id !== '' + ? userState.user.github_id + : t('未绑定')}
-
- - - {/* 微信绑定 */} - + +
+ + + {/* OIDC绑定 */} + +
+
+
+ +
+
+
{t('OIDC')}
+
+ {userState.user && userState.user.oidc_id !== '' + ? userState.user.oidc_id + : t('未绑定')}
-
- - - {/* GitHub绑定 */} - + +
+ + + {/* Telegram绑定 */} + +
+
+
+ +
+
+
{t('Telegram')}
+
+ {userState.user && userState.user.telegram_id !== '' + ? userState.user.telegram_id + : t('未绑定')}
-
+
+
+ {status.telegram_oauth ? ( + userState.user.telegram_id !== '' ? ( + + ) : ( +
+ +
+ ) + ) : ( + + )} +
+
+
+ + {/* LinuxDO绑定 */} + +
+
+
+
- - - {/* OIDC绑定 */} - +
{t('LinuxDO')}
+
+ {userState.user && userState.user.linux_do_id !== '' + ? userState.user.linux_do_id + : t('未绑定')} +
+
+
+ +
+
+
+
+ + + {/* 安全设置Tab */} + + + {t('安全设置')} +
+ } + itemKey='security' + > +
+
+ + {/* 系统访问令牌 */} + +
+
+
+
- +
-
+ +
+ - {/* Telegram绑定 */} - -
-
-
- -
-
-
{t('Telegram')}
-
- {userState.user && userState.user.telegram_id !== '' - ? userState.user.telegram_id - : t('未绑定')} -
-
+ {/* 密码管理 */} + +
+
+
+
-
- {status.telegram_oauth ? ( - userState.user.telegram_id !== '' ? ( - - ) : ( -
- -
- ) - ) : ( - - )} +
+ + {t('密码管理')} + + + {t('定期更改密码可以提高账户安全性')} +
- + +
+ - {/* LinuxDO绑定 */} - -
-
-
- -
-
-
{t('LinuxDO')}
-
- {userState.user && userState.user.linux_do_id !== '' - ? userState.user.linux_do_id - : t('未绑定')} -
-
+ {/* 危险区域 */} + +
+
+
+ +
+
+ + {t('删除账户')} + + + {t('此操作不可逆,所有数据将被永久删除')} +
-
- -
-
- - - {/* 安全设置Tab */} - - - {t('安全设置')} -
- } - itemKey='security' - > -
+ +
+
+ +
+
+ + + {/* 通知设置Tab */} + + + {t('其他设置')} +
+ } + itemKey='notification' + > +
+ +
- - {/* 系统访问令牌 */} - + {t('通知方式')} + + handleNotificationSettingChange('warningType', value) + } + type="pureCard" > -
-
-
- -
-
- - {t('系统访问令牌')} - - - {t('用于API调用的身份验证令牌,请妥善保管')} - - {systemToken && ( -
- } - /> -
- )} + +
+ +
+
{t('邮件通知')}
+
{t('通过邮件接收通知')}
- -
- - - {/* 密码管理 */} - -
-
-
- -
+ + +
+
- - {t('密码管理')} - - - {t('定期更改密码可以提高账户安全性')} - +
{t('Webhook通知')}
+
{t('通过HTTP请求接收通知')}
- -
- + + +
- {/* 危险区域 */} - -
-
-
- -
-
- - {t('删除账户')} - - - {t('此操作不可逆,所有数据将被永久删除')} - -
+ {/* Webhook设置 */} + {notificationSettings.warningType === 'webhook' && ( +
+
+ {t('Webhook地址')} + + handleNotificationSettingChange('webhookUrl', val) + } + placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')} + size="large" + className="!rounded-lg" + prefix={} + /> +
+ {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
-
- - -
-
- - - {/* 通知设置Tab */} - - - {t('其他设置')} -
- } - itemKey='notification' - > -
- - -
- {/* 通知方式选择 */} -
- {t('通知方式')} - - handleNotificationSettingChange('warningType', value) + +
+ {t('接口凭证(可选)')} + + handleNotificationSettingChange('webhookSecret', val) } - type="pureCard" - > - -
- -
-
{t('邮件通知')}
-
{t('通过邮件接收通知')}
-
-
-
- -
- -
-
{t('Webhook通知')}
-
{t('通过HTTP请求接收通知')}
-
-
-
- + placeholder={t('请输入密钥')} + size="large" + className="!rounded-lg" + prefix={} + /> +
+ {t('密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性')} +
- {/* Webhook设置 */} - {notificationSettings.warningType === 'webhook' && ( -
-
- {t('Webhook地址')} - - handleNotificationSettingChange('webhookUrl', val) - } - placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')} - size="large" - className="!rounded-lg" - prefix={} - /> -
- {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')} -
-
- -
- {t('接口凭证(可选)')} - - handleNotificationSettingChange('webhookSecret', val) - } - placeholder={t('请输入密钥')} - size="large" - className="!rounded-lg" - prefix={} - /> -
- {t('密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性')} -
+
+
setShowWebhookDocs(!showWebhookDocs)}> +
+ + + {t('Webhook请求结构')} +
- -
-
setShowWebhookDocs(!showWebhookDocs)}> -
- - - {t('Webhook请求结构')} - -
- {showWebhookDocs ? : } -
- -
-                                        {`{
+                                  {showWebhookDocs ?  : }
+                                
+ +
+                                    {`{
   "type": "quota_exceed",      // 通知类型
   "title": "标题",             // 通知标题
   "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
@@ -1174,158 +1171,156 @@ const PersonalSetting = () => {
   "values": ["$0.99"],
   "timestamp": 1739950503
 }`}
-                                      
-
-
-
- )} - - {/* 邮件设置 */} - {notificationSettings.warningType === 'email' && ( -
- {t('通知邮箱')} - - handleNotificationSettingChange('notificationEmail', val) - } - placeholder={t('留空则使用账号绑定的邮箱')} - size="large" - className="!rounded-lg" - prefix={} - /> -
- {t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')} -
-
- )} + + +
+
+ )} - {/* 预警阈值 */} -
- - {t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)} - - - handleNotificationSettingChange('warningThreshold', val) - } - size="large" - className="!rounded-lg w-full max-w-xs" - placeholder={t('请输入预警额度')} - data={[ - { value: 100000, label: '0.2$' }, - { value: 500000, label: '1$' }, - { value: 1000000, label: '5$' }, - { value: 5000000, label: '10$' }, - ]} - prefix={} - /> -
- {t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')} -
+ {/* 邮件设置 */} + {notificationSettings.warningType === 'email' && ( +
+ {t('通知邮箱')} + + handleNotificationSettingChange('notificationEmail', val) + } + placeholder={t('留空则使用账号绑定的邮箱')} + size="large" + className="!rounded-lg" + prefix={} + /> +
+ {t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
- + )} - -
-
- {/* 接受未设置价格模型 */} -
-
-
- -
-
-
-
- - {t('接受未设置价格模型')} - -
- {t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')} -
-
- - handleNotificationSettingChange( - 'acceptUnsetModelRatioModel', - e.target.checked, - ) - } - className="ml-4" - /> + {/* 预警阈值 */} +
+ + {t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)} + + + handleNotificationSettingChange('warningThreshold', val) + } + size="large" + className="!rounded-lg w-full max-w-xs" + placeholder={t('请输入预警额度')} + data={[ + { value: 100000, label: '0.2$' }, + { value: 500000, label: '1$' }, + { value: 1000000, label: '5$' }, + { value: 5000000, label: '10$' }, + ]} + prefix={} + /> +
+ {t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')} +
+
+
+ + + +
+
+ {/* 接受未设置价格模型 */} +
+
+
+ +
+
+
+
+ + {t('接受未设置价格模型')} + +
+ {t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
+ + handleNotificationSettingChange( + 'acceptUnsetModelRatioModel', + e.target.checked, + ) + } + className="ml-4" + />
- - - -
-
-
-
- -
-
-
-
- - {t('记录请求与错误日志 IP')} - -
- {t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')} -
-
- - handleNotificationSettingChange( - 'recordIpLog', - e.target.checked, - ) - } - className="ml-4" - /> +
+
+ + + +
+
+
+
+ +
+
+
+
+ + {t('记录请求与错误日志 IP')} + +
+ {t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')}
+ + handleNotificationSettingChange( + 'recordIpLog', + e.target.checked, + ) + } + className="ml-4" + />
- - - -
- +
-
-
- -
- + + + +
+ +
+
+ +
-
- - + +
+
{/* 邮箱绑定模态框 */} { - + + + + { - {/* 分组倍率设置 */} - - - ); }; diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 9092146093cc..184432432535 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -17,10 +17,10 @@ import { AlertCircle, HelpCircle, Coins, - Tags + Tags, } from 'lucide-react'; -import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js'; +import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js'; import { Button, Divider, @@ -40,7 +40,8 @@ import { Card, Form, Tabs, - TabPane + TabPane, + Select } from '@douyinfe/semi-ui'; import { IllustrationNoResult, @@ -50,20 +51,16 @@ import EditChannel from '../../pages/Channel/EditChannel.js'; import { IconTreeTriangleDown, IconPlus, - IconRefresh, - IconSetting, IconSearch, - IconEdit, IconDelete, - IconStop, - IconPlay, IconMore, IconCopy, IconSmallTriangleRight } from '@douyinfe/semi-icons'; -import { loadChannelModels } from '../../helpers/index.js'; +import { loadChannelModels, isMobile, copy } from '../../helpers'; import EditTagModal from '../../pages/Channel/EditTagModal.js'; import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; const ChannelsTable = () => { const { t } = useTranslation(); @@ -114,7 +111,7 @@ const ChannelsTable = () => { ); case 2: return ( - }> + }> {t('已禁用')} ); @@ -187,6 +184,11 @@ const ChannelsTable = () => { const [visibleColumns, setVisibleColumns] = useState({}); const [showColumnSelector, setShowColumnSelector] = useState(false); + // 状态筛选 all / enabled / disabled + const [statusFilter, setStatusFilter] = useState( + localStorage.getItem('channel-status-filter') || 'all' + ); + // Load saved column preferences from localStorage useEffect(() => { const savedColumns = localStorage.getItem('channels-table-columns'); @@ -549,7 +551,6 @@ const ChannelsTable = () => { type='warning' size="small" className="!rounded-full" - icon={} onClick={() => manageChannel(record.id, 'disable', record)} > {t('禁用')} @@ -560,7 +561,6 @@ const ChannelsTable = () => { type='secondary' size="small" className="!rounded-full" - icon={} onClick={() => manageChannel(record.id, 'enable', record)} > {t('启用')} @@ -572,7 +572,6 @@ const ChannelsTable = () => { type='tertiary' size="small" className="!rounded-full" - icon={} onClick={() => { setEditingChannel(record); setShowEdit(true); @@ -597,19 +596,7 @@ const ChannelsTable = () => { ); } else { - // 标签操作的下拉菜单项 - const tagMenuItems = [ - { - node: 'item', - name: t('编辑'), - icon: , - onClick: () => { - setShowEditTag(true); - setEditingTag(record.key); - }, - }, - ]; - + // 标签操作按钮 return ( - { + setShowEditTag(true); + setEditingTag(record.key); + }} > - ); } @@ -676,18 +660,22 @@ const ChannelsTable = () => { const [modelSearchKeyword, setModelSearchKeyword] = useState(''); const [modelTestResults, setModelTestResults] = useState({}); const [testingModels, setTestingModels] = useState(new Set()); + const [selectedModelKeys, setSelectedModelKeys] = useState([]); const [isBatchTesting, setIsBatchTesting] = useState(false); const [testQueue, setTestQueue] = useState([]); const [isProcessingQueue, setIsProcessingQueue] = useState(false); + const [modelTablePage, setModelTablePage] = useState(1); const [activeTypeKey, setActiveTypeKey] = useState('all'); const [typeCounts, setTypeCounts] = useState({}); const requestCounter = useRef(0); const [formApi, setFormApi] = useState(null); + const [compactMode, setCompactMode] = useTableCompactMode('channels'); const formInitValues = { searchKeyword: '', searchGroup: '', searchModel: '', }; + const allSelectingRef = useRef(false); // Filter columns based on visibility settings const getVisibleColumns = () => { @@ -864,12 +852,30 @@ const ChannelsTable = () => { setChannels(channelDates); }; - const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => { + const loadChannels = async ( + page, + pageSize, + idSort, + enableTagMode, + typeKey = activeTypeKey, + statusF, + ) => { + if (statusF === undefined) statusF = statusFilter; + + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { + setLoading(true); + await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); + setLoading(false); + return; + } + const reqId = ++requestCounter.current; // 记录当前请求序号 setLoading(true); - const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : ''; + const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const res = await API.get( - `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`, + `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, ); if (res === undefined || reqId !== requestCounter.current) { return; @@ -920,7 +926,7 @@ const ChannelsTable = () => { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { await loadChannels(activePage, pageSize, idSort, enableTagMode); } else { - await searchChannels(enableTagMode); + await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort); } }; @@ -1026,7 +1032,7 @@ const ChannelsTable = () => { } }; - // 获取表单值的辅助函数,确保所有值都是字符串 + // 获取表单值的辅助函数 const getFormValues = () => { const formValues = formApi ? formApi.getValues() : {}; return { @@ -1036,27 +1042,35 @@ const ChannelsTable = () => { }; }; - const searchChannels = async (enableTagMode) => { + const searchChannels = async ( + enableTagMode, + typeKey = activeTypeKey, + statusF = statusFilter, + page = 1, + pageSz = pageSize, + sortFlag = idSort, + ) => { const { searchKeyword, searchGroup, searchModel } = getFormValues(); - setSearching(true); try { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); + await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); return; } - const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : ''; + const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const res = await API.get( - `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`, + `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`, ); const { success, message, data } = res.data; if (success) { - const { items = [], type_counts = {} } = data; + const { items = [], total = 0, type_counts = {} } = data; const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); setTypeCounts({ ...type_counts, all: sumAll }); setChannelFormat(items, enableTagMode); - setActivePage(1); + setChannelCount(total); + setActivePage(page); } else { showError(message); } @@ -1096,7 +1110,22 @@ const ChannelsTable = () => { const processTestQueue = async () => { if (!isProcessingQueue || testQueue.length === 0) return; - const { channel, model } = testQueue[0]; + const { channel, model, indexInFiltered } = testQueue[0]; + + // 自动翻页到正在测试的模型所在页 + if (currentTestChannel && currentTestChannel.id === channel.id) { + let pageNo; + if (indexInFiltered !== undefined) { + pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1; + } else { + const filteredModelsList = currentTestChannel.models + .split(',') + .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())); + const modelIdx = filteredModelsList.indexOf(model); + pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1; + } + setModelTablePage(pageNo); + } try { setTestingModels(prev => new Set([...prev, model])); @@ -1159,16 +1188,22 @@ const ChannelsTable = () => { setIsBatchTesting(true); - const models = currentTestChannel.models + // 重置分页到第一页 + setModelTablePage(1); + + const filteredModels = currentTestChannel.models .split(',') .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), ); - setTestQueue(models.map(model => ({ - channel: currentTestChannel, - model - }))); + setTestQueue( + filteredModels.map((model, idx) => ({ + channel: currentTestChannel, + model, + indexInFiltered: idx, // 记录在过滤列表中的顺序 + })), + ); setIsProcessingQueue(true); }; @@ -1182,6 +1217,8 @@ const ChannelsTable = () => { } else { setShowModelTestModal(false); setModelSearchKeyword(''); + setSelectedModelKeys([]); + setModelTablePage(1); } }; @@ -1262,32 +1299,31 @@ const ChannelsTable = () => { }; let pageData = channels; - if (activeTypeKey !== 'all') { - const typeVal = parseInt(activeTypeKey); - if (!isNaN(typeVal)) { - pageData = pageData.filter((ch) => { - if (ch.children !== undefined) { - return ch.children.some((c) => c.type === typeVal); - } - return ch.type === typeVal; - }); - } - } const handlePageChange = (page) => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); setActivePage(page); - loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + } }; const handlePageSizeChange = async (size) => { localStorage.setItem('page-size', size + ''); setPageSize(size); setActivePage(1); - loadChannels(1, size, idSort, enableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(1, size, idSort, enableTagMode) + .then() + .catch((reason) => { + showError(reason); + }); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort); + } }; const fetchGroups = async () => { @@ -1468,6 +1504,7 @@ const ChannelsTable = () => {
+ +
@@ -1584,11 +1637,17 @@ const ChannelsTable = () => { {t('使用ID排序')} { localStorage.setItem('id-sort', v + ''); setIdSort(v); - loadChannels(activePage, pageSize, v, enableTagMode); + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + loadChannels(activePage, pageSize, v, enableTagMode); + } else { + searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v); + } }} />
@@ -1598,6 +1657,7 @@ const ChannelsTable = () => { {t('开启批量操作')} { localStorage.setItem('enable-batch-delete', v + ''); @@ -1611,6 +1671,7 @@ const ChannelsTable = () => { {t('标签聚合模式')} { localStorage.setItem('enable-tag-mode', v + ''); @@ -1620,6 +1681,27 @@ const ChannelsTable = () => { }} />
+ + {/* 状态筛选器 */} +
+ + {t('状态筛选')} + + +
@@ -1628,6 +1710,7 @@ const ChannelsTable = () => {
rest) : getVisibleColumns()} dataSource={pageData} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} pagination={{ currentPage: activePage, pageSize: pageSize, @@ -1806,7 +1894,7 @@ const ChannelsTable = () => { } className="rounded-xl overflow-hidden" size="middle" - loading={loading} + loading={loading || searching} /> @@ -1842,13 +1930,73 @@ const ChannelsTable = () => { - - {currentTestChannel.name} {t('渠道的模型测试')} - - - {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} - +
+
+ + {currentTestChannel.name} {t('渠道的模型测试')} + + + {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} + +
+ + {/* 搜索与操作按钮 */} +
+ { + setModelSearchKeyword(v); + setModelTablePage(1); + }} + className="!w-full !rounded-full" + prefix={} + showClear + /> + + + + +
) } @@ -1898,22 +2046,11 @@ const ChannelsTable = () => { } maskClosable={!isBatchTesting} className="!rounded-lg" - size="large" + size={isMobile() ? 'full-width' : 'large'} > -
+
{currentTestChannel && (
-
- setModelSearchKeyword(v)} - className="w-64 !rounded-full" - prefix={} - showClear - /> -
-
{ } } ]} - dataSource={currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) - ) - .map((model) => ({ + dataSource={(() => { + const filtered = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ); + const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE; + const end = start + MODEL_TABLE_PAGE_SIZE; + return filtered.slice(start, end).map((model) => ({ model, - key: model - }))} - pagination={false} + key: model, + })); + })()} + rowSelection={{ + selectedRowKeys: selectedModelKeys, + onChange: (keys) => { + if (allSelectingRef.current) { + allSelectingRef.current = false; + return; + } + setSelectedModelKeys(keys); + }, + onSelectAll: (checked) => { + const filtered = currentTestChannel.models + .split(',') + .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase())); + allSelectingRef.current = true; + setSelectedModelKeys(checked ? filtered : []); + }, + }} + pagination={{ + currentPage: modelTablePage, + pageSize: MODEL_TABLE_PAGE_SIZE, + total: currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ).length, + showSizeChanger: false, + onPageChange: (page) => setModelTablePage(page), + }} /> )} diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index 90e4a8095784..a61994412240 100644 --- a/web/src/components/table/LogsTable.js +++ b/web/src/components/table/LogsTable.js @@ -47,8 +47,9 @@ import { } from '@douyinfe/semi-illustrations'; import { ITEMS_PER_PAGE } from '../../constants'; import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; -import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons'; +import { IconSetting, IconSearch, IconHelpCircle, IconDescend } from '@douyinfe/semi-icons'; import { Route } from 'lucide-react'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; const { Text } = Typography; @@ -192,7 +193,7 @@ const LogsTable = () => { if (!modelMapped) { return renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, }); } else { @@ -209,7 +210,7 @@ const LogsTable = () => { {renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, })} @@ -220,7 +221,7 @@ const LogsTable = () => { {renderModelTag(other.upstream_model_name, { onClick: (event) => { copyText(event, other.upstream_model_name).then( - (r) => {}, + (r) => { }, ); }, })} @@ -231,7 +232,7 @@ const LogsTable = () => { > {renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, suffixIcon: ( { } let content = other?.claude ? renderClaudeModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - other.cache_creation_tokens || 0, - other.cache_creation_ratio || 1.0, - ) + other.model_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + other.cache_creation_tokens || 0, + other.cache_creation_ratio || 1.0, + ) : renderModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - ); + other.model_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + ); return ( { key: t('日志详情'), value: other?.claude ? renderClaudeLogContent( - other?.model_ratio, - other.completion_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_ratio || 1.0, - other.cache_creation_ratio || 1.0, - ) + other?.model_ratio, + other.completion_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + other.cache_ratio || 1.0, + other.cache_creation_ratio || 1.0, + ) : renderLogContent( - other?.model_ratio, - other.completion_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - false, - 1.0, - other.web_search || false, - other.web_search_call_count || 0, - other.file_search || false, - other.file_search_call_count || 0, - ), + other?.model_ratio, + other.completion_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + false, + 1.0, + other.web_search || false, + other.web_search_call_count || 0, + other.file_search || false, + other.file_search_call_count || 0, + ), }); } if (logs[i].type === 2) { @@ -1145,7 +1146,7 @@ const LogsTable = () => { const handlePageChange = (page) => { setActivePage(page); - loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值 + loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值 }; const handlePageSizeChange = async (size) => { @@ -1203,6 +1204,8 @@ const LogsTable = () => { ); }; + const [compactMode, setCompactMode] = useTableCompactMode('logs'); + return ( <> {renderColumnSelector()} @@ -1211,45 +1214,57 @@ const LogsTable = () => { title={
- - - {t('消耗额度')}: {renderQuota(stat.quota)} - - - RPM: {stat.rpm} - - + + + {t('消耗额度')}: {renderQuota(stat.quota)} + + + RPM: {stat.rpm} + + + TPM: {stat.tpm} + + + + +
@@ -1382,7 +1397,6 @@ const LogsTable = () => { if (formApi) { formApi.reset(); setLogType(0); - // 重置后立即查询,使用setTimeout确保表单重置完成 setTimeout(() => { refresh(); }, 100); @@ -1411,7 +1425,7 @@ const LogsTable = () => { bordered={false} >
rest) : getVisibleColumns()} {...(hasExpandableRows() && { expandedRowRender: expandRowRender, expandRowByClick: true, @@ -1421,7 +1435,7 @@ const LogsTable = () => { dataSource={logs} rowKey='key' loading={loading} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} className='rounded-xl overflow-hidden' size='middle' empty={ diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 869db485f9f2..008a77856582 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -24,7 +24,7 @@ import { XCircle, Loader, AlertCircle, - Hash + Hash, } from 'lucide-react'; import { API, @@ -59,8 +59,10 @@ import { ITEMS_PER_PAGE } from '../../constants'; import { IconEyeOpened, IconSearch, - IconSetting + IconSetting, + IconDescend } from '@douyinfe/semi-icons'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; const { Text } = Typography; @@ -107,6 +109,7 @@ const LogsTable = () => { const [visibleColumns, setVisibleColumns] = useState({}); const [showColumnSelector, setShowColumnSelector] = useState(false); const isAdminUser = isAdmin(); + const [compactMode, setCompactMode] = useTableCompactMode('mjLogs'); // 加载保存的列偏好设置 useEffect(() => { @@ -802,7 +805,7 @@ const LogsTable = () => { className="!rounded-2xl mb-4" title={
-
+
{loading ? ( @@ -821,6 +824,15 @@ const LogsTable = () => { )}
+
@@ -919,11 +931,11 @@ const LogsTable = () => { bordered={false} >
rest) : getVisibleColumns()} dataSource={logs} rowKey='key' loading={loading} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} className="rounded-xl overflow-hidden" size="middle" empty={ diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js index b81274c7d1e5..be389f80aa6c 100644 --- a/web/src/components/table/ModelPricing.js +++ b/web/src/components/table/ModelPricing.js @@ -16,7 +16,6 @@ import { Card, Tabs, TabPane, - Dropdown, Empty } from '@douyinfe/semi-ui'; import { @@ -257,7 +256,7 @@ const ModelPricing = () => { const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); - const [userState, userDispatch] = useContext(UserContext); + const [userState] = useContext(UserContext); const [groupRatio, setGroupRatio] = useState({}); const [usableGroup, setUsableGroup] = useState({}); @@ -334,57 +333,6 @@ const ModelPricing = () => { return counts; }, [models, modelCategories]); - const renderArrow = (items, pos, handleArrowClick) => { - const style = { - width: 32, - height: 32, - margin: '0 12px', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - borderRadius: '100%', - background: 'rgba(var(--semi-grey-1), 1)', - color: 'var(--semi-color-text)', - cursor: 'pointer', - }; - return ( - - {items.map(item => { - const key = item.itemKey; - const modelCount = categoryCounts[key] || 0; - - return ( - setActiveKey(item.itemKey)} - icon={modelCategories[item.itemKey]?.icon} - > -
- {modelCategories[item.itemKey]?.label || item.itemKey} - - {modelCount} - -
-
- ); - })} - - } - > -
- {pos === 'start' ? '←' : '→'} -
-
- ); - }; - - // 检查分类是否有对应的模型 const availableCategories = useMemo(() => { if (!models.length) return ['all']; @@ -394,11 +342,9 @@ const ModelPricing = () => { }).map(([key]) => key); }, [models]); - // 渲染标签页 const renderTabs = () => { return ( { ); }; - // 优化过滤逻辑 const filteredModels = useMemo(() => { let result = models; - // 先按分类过滤 if (activeKey !== 'all') { result = result.filter(model => modelCategories[activeKey].filter(model)); } - // 再按搜索词过滤 if (filteredValue.length > 0) { const searchTerm = filteredValue[0].toLowerCase(); result = result.filter(model => @@ -454,7 +397,6 @@ const ModelPricing = () => { return result; }, [activeKey, models, filteredValue]); - // 搜索和操作区组件 const SearchAndActions = useMemo(() => (
@@ -485,7 +427,6 @@ const ModelPricing = () => { ), [selectedRowKeys, t]); - // 表格组件 const ModelTable = useMemo(() => (
{
-
+
{/* 主卡片容器 */} - + {/* 顶部状态卡片 */} { id: undefined, }); const [showEdit, setShowEdit] = useState(false); + const [compactMode, setCompactMode] = useTableCompactMode('redemptions'); - // Form 初始值 const formInitValues = { searchKeyword: '', }; - // Form API 引用 const [formApi, setFormApi] = useState(null); - // 获取表单值的辅助函数 const getFormValues = () => { const formValues = formApi ? formApi.getValues() : {}; return { @@ -296,14 +296,15 @@ const RedemptionsTable = () => { setRedemptions(redeptions); }; - const loadRedemptions = async (startIdx, pageSize) => { + const loadRedemptions = async (page = 1, pageSize) => { + setLoading(true); const res = await API.get( - `/api/redemption/?p=${startIdx}&page_size=${pageSize}`, + `/api/redemption/?p=${page}&page_size=${pageSize}`, ); const { success, message, data } = res.data; if (success) { const newPageData = data.items; - setActivePage(data.page); + setActivePage(data.page <= 0 ? 1 : data.page); setTokenCount(data.total); setRedemptionFormat(newPageData); } else { @@ -336,17 +337,8 @@ const RedemptionsTable = () => { } }; - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(redemptions.length / pageSize) + 1) { - await loadRedemptions(activePage - 1, pageSize); - } - setActivePage(activePage); - })(); - }; - useEffect(() => { - loadRedemptions(0, pageSize) + loadRedemptions(1, pageSize) .then() .catch((reason) => { showError(reason); @@ -417,20 +409,6 @@ const RedemptionsTable = () => { setSearching(false); }; - const sortRedemption = (key) => { - if (redemptions.length === 0) return; - setLoading(true); - let sortedRedemptions = [...redemptions]; - sortedRedemptions.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - if (sortedRedemptions[0].id === redemptions[0].id) { - sortedRedemptions.reverse(); - } - setRedemptions(sortedRedemptions); - setLoading(false); - }; - const handlePageChange = (page) => { setActivePage(page); const { searchKeyword } = getFormValues(); @@ -465,9 +443,20 @@ const RedemptionsTable = () => { const renderHeader = () => (
-
- - {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} +
+
+ + {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} +
+
@@ -610,9 +599,9 @@ const RedemptionsTable = () => { bordered={false} >
rest) : columns} dataSource={pageData} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} pagination={{ currentPage: activePage, pageSize: pageSize, diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index 37bdde5750db..af1ed7f9bb66 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -47,8 +47,11 @@ import { ITEMS_PER_PAGE } from '../../constants'; import { IconEyeOpened, IconSearch, - IconSetting + IconSetting, + IconDescend } from '@douyinfe/semi-icons'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant'; const { Text } = Typography; @@ -207,10 +210,16 @@ const LogsTable = () => { {t('生成歌词')} ); - case 'generate': + case TASK_ACTION_GENERATE: return ( }> - {t('生成视频')} + {t('图生视频')} + + ); + case TASK_ACTION_TEXT_GENERATE: + return ( + }> + {t('文生视频')} ); default: @@ -222,8 +231,8 @@ const LogsTable = () => { } }; - const renderPlatform = (type) => { - switch (type) { + const renderPlatform = (platform) => { + switch (platform) { case 'suno': return ( }> @@ -232,10 +241,16 @@ const LogsTable = () => { ); case 'kling': return ( - }> + }> Kling ); + case 'jimeng': + return ( + }> + Jimeng + + ); default: return ( }> @@ -432,7 +447,7 @@ const LogsTable = () => { fixed: 'right', render: (text, record, index) => { // 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接 - const isVideoTask = record.action === 'generate'; + const isVideoTask = record.action === TASK_ACTION_GENERATE || record.action === TASK_ACTION_TEXT_GENERATE; const isSuccess = record.status === 'SUCCESS'; const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { @@ -471,6 +486,8 @@ const LogsTable = () => { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(false); + const [compactMode, setCompactMode] = useTableCompactMode('taskLogs'); + useEffect(() => { const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; setPageSize(localPageSize); @@ -650,7 +667,7 @@ const LogsTable = () => { className="!rounded-2xl mb-4" title={
-
+
{loading ? ( @@ -665,6 +682,15 @@ const LogsTable = () => { {t('任务记录')} )}
+
@@ -763,11 +789,11 @@ const LogsTable = () => { bordered={false} >
rest) : getVisibleColumns()} dataSource={logs} rowKey='key' loading={loading} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} className="rounded-xl overflow-hidden" size="middle" empty={ diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index bc6c7607414d..db34dc02beb8 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { API, copy, @@ -52,10 +52,12 @@ import { IconDelete, IconStop, IconPlay, - IconMore + IconMore, + IconDescend } from '@douyinfe/semi-icons'; import EditToken from '../../pages/Token/EditToken'; import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; const { Text } = Typography; @@ -385,6 +387,7 @@ const TokensTable = () => { const [editingToken, setEditingToken] = useState({ id: undefined, }); + const [compactMode, setCompactMode] = useTableCompactMode('tokens'); // Form 初始值 const formInitValues = { @@ -435,6 +438,7 @@ const TokensTable = () => { const refresh = async () => { await loadTokens(1); + setSelectedKeys([]); }; const copyText = async (text) => { @@ -583,24 +587,58 @@ const TokensTable = () => { } }; + const batchDeleteTokens = async () => { + if (selectedKeys.length === 0) { + showError(t('请先选择要删除的令牌!')); + return; + } + setLoading(true); + try { + const ids = selectedKeys.map((token) => token.id); + const res = await API.post('/api/token/batch', { ids }); + if (res?.data?.success) { + const count = res.data.data || 0; + showSuccess(t('已删除 {{count}} 个令牌!', { count })); + await refresh(); + } else { + showError(res?.data?.message || t('删除失败')); + } + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + const renderHeader = () => (
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+
+ + {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+
-
+
+ + + ), + }); + }} + > + {t('复制所选令牌')} + +
@@ -711,9 +805,15 @@ const TokensTable = () => { bordered={false} >
{ + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns} dataSource={tokens} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} pagination={{ currentPage: activePage, pageSize: pageSize, diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index d245c56f109a..94b829120fe3 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -13,7 +13,7 @@ import { Activity, Users, DollarSign, - UserPlus + UserPlus, } from 'lucide-react'; import { Button, @@ -43,17 +43,20 @@ import { IconMore, IconUserAdd, IconArrowUp, - IconArrowDown + IconArrowDown, + IconDescend } from '@douyinfe/semi-icons'; import { ITEMS_PER_PAGE } from '../../constants'; import AddUser from '../../pages/User/AddUser'; import EditUser from '../../pages/User/EditUser'; import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../../hooks/useTableCompactMode'; const { Text } = Typography; const UsersTable = () => { const { t } = useTranslation(); + const [compactMode, setCompactMode] = useTableCompactMode('users'); function renderRole(role) { switch (role) { @@ -527,9 +530,20 @@ const UsersTable = () => { const renderHeader = () => (
-
- - {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} +
+
+ + {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} +
+
@@ -645,9 +659,9 @@ const UsersTable = () => { bordered={false} >
rest) : columns} dataSource={users} - scroll={{ x: 'max-content' }} + scroll={compactMode ? undefined : { x: 'max-content' }} pagination={{ formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index c4220bd46717..b145ea11fbb4 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -130,4 +130,11 @@ export const CHANNEL_OPTIONS = [ color: 'green', label: '可灵', }, + { + value: 51, + color: 'blue', + label: '即梦', + }, ]; + +export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 9ce834323495..6556ffefb62b 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -1,3 +1,23 @@ export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend! -export const DEFAULT_ENDPOINT = '/api/ratio_config'; \ No newline at end of file +export const DEFAULT_ENDPOINT = '/api/ratio_config'; + +export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes'; + +export const API_ENDPOINTS = [ + '/v1/chat/completions', + '/v1/responses', + '/v1/messages', + '/v1beta/models', + '/v1/embeddings', + '/v1/rerank', + '/v1/images/generations', + '/v1/images/edits', + '/v1/images/variations', + '/v1/audio/speech', + '/v1/audio/transcriptions', + '/v1/audio/translations' +]; + +export const TASK_ACTION_GENERATE = 'generate'; +export const TASK_ACTION_TEXT_GENERATE = 'textGenerate'; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index c95082033b1e..6f00b9143a6e 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -883,7 +883,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) { ? i18next.t('专属倍率') : i18next.t('分组倍率'); const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; - + return { ratio: effectiveRatio, label: ratioLabel, @@ -1074,25 +1074,25 @@ export function renderModelPrice( const extraServices = [ webSearch && webSearchCallCount > 0 ? i18next.t( - ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', - { - count: webSearchCallCount, - price: webSearchPrice, - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + { + count: webSearchCallCount, + price: webSearchPrice, + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', fileSearch && fileSearchCallCount > 0 ? i18next.t( - ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', - { - count: fileSearchCallCount, - price: fileSearchPrice, - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + { + count: fileSearchCallCount, + price: fileSearchPrice, + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', ].join(''); @@ -1281,10 +1281,10 @@ export function renderAudioModelPrice( let audioPrice = (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio + (audioCompletionTokens / 1000000) * - inputRatioPrice * - audioRatio * - audioCompletionRatio * - groupRatio; + inputRatioPrice * + audioRatio * + audioCompletionRatio * + groupRatio; let price = textPrice + audioPrice; return ( <> @@ -1340,27 +1340,27 @@ export function renderAudioModelPrice(

{cacheTokens > 0 ? i18next.t( - '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', - { - nonCacheInput: inputTokens - cacheTokens, - cacheInput: cacheTokens, - cachePrice: inputRatioPrice * cacheRatio, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), - }, - ) + '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + { + nonCacheInput: inputTokens - cacheTokens, + cacheInput: cacheTokens, + cachePrice: inputRatioPrice * cacheRatio, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + total: textPrice.toFixed(6), + }, + ) : i18next.t( - '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', - { - input: inputTokens, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), - }, - )} + '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + { + input: inputTokens, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + total: textPrice.toFixed(6), + }, + )}

{i18next.t( @@ -1397,7 +1397,7 @@ export function renderQuotaWithPrompt(quota, digits) { displayInCurrency = displayInCurrency === 'true'; if (displayInCurrency) { return ( - ' | ' + i18next.t('等价金额') + ': ' + renderQuota(quota, digits) + '' + i18next.t('等价金额:') + renderQuota(quota, digits) ); } return ''; @@ -1499,35 +1499,35 @@ export function renderClaudeModelPrice(

{cacheTokens > 0 || cacheCreationTokens > 0 ? i18next.t( - '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', - { - nonCacheInput: nonCachedTokens, - cacheInput: cacheTokens, - cacheRatio: cacheRatio, - cacheCreationInput: cacheCreationTokens, - cacheCreationRatio: cacheCreationRatio, - cachePrice: cacheRatioPrice, - cacheCreationPrice: cacheCreationRatioPrice, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - ratio: groupRatio, - ratioType: ratioLabel, - total: price.toFixed(6), - }, - ) + '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + { + nonCacheInput: nonCachedTokens, + cacheInput: cacheTokens, + cacheRatio: cacheRatio, + cacheCreationInput: cacheCreationTokens, + cacheCreationRatio: cacheCreationRatio, + cachePrice: cacheRatioPrice, + cacheCreationPrice: cacheCreationRatioPrice, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + ratio: groupRatio, + ratioType: ratioLabel, + total: price.toFixed(6), + }, + ) : i18next.t( - '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', - { - input: inputTokens, - price: inputRatioPrice, - completion: completionTokens, - compPrice: completionRatioPrice, - ratio: groupRatio, - ratioType: ratioLabel, - total: price.toFixed(6), - }, - )} + '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + { + input: inputTokens, + price: inputRatioPrice, + completion: completionTokens, + compPrice: completionRatioPrice, + ratio: groupRatio, + ratioType: ratioLabel, + total: price.toFixed(6), + }, + )}

{i18next.t('仅供参考,以实际扣费为准')}

diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 56e1104d927e..68a058469ec6 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -3,6 +3,7 @@ import { toastConstants } from '../constants'; import React from 'react'; import { toast } from 'react-toastify'; import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants'; +import { TABLE_COMPACT_MODES_KEY } from '../constants'; const HTMLToastContent = ({ htmlContent }) => { return
; @@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => { const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}`; }; + +function readTableCompactModes() { + try { + const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY); + return json ? JSON.parse(json) : {}; + } catch { + return {}; + } +} + +function writeTableCompactModes(modes) { + try { + localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes)); + } catch { + // ignore + } +} + +export function getTableCompactMode(tableKey = 'global') { + const modes = readTableCompactModes(); + return !!modes[tableKey]; +} + +export function setTableCompactMode(compact, tableKey = 'global') { + const modes = readTableCompactModes(); + modes[tableKey] = compact; + writeTableCompactModes(modes); +} diff --git a/web/src/hooks/useTableCompactMode.js b/web/src/hooks/useTableCompactMode.js new file mode 100644 index 000000000000..f943bda79dc0 --- /dev/null +++ b/web/src/hooks/useTableCompactMode.js @@ -0,0 +1,34 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getTableCompactMode, setTableCompactMode } from '../helpers'; +import { TABLE_COMPACT_MODES_KEY } from '../constants'; + +/** + * 自定义 Hook:管理表格紧凑/自适应模式 + * 返回 [compactMode, setCompactMode]。 + * 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。 + */ +export function useTableCompactMode(tableKey = 'global') { + const [compactMode, setCompactModeState] = useState(() => getTableCompactMode(tableKey)); + + const setCompactMode = useCallback((value) => { + setCompactModeState(value); + setTableCompactMode(value, tableKey); + }, [tableKey]); + + useEffect(() => { + const handleStorage = (e) => { + if (e.key === TABLE_COMPACT_MODES_KEY) { + try { + const modes = JSON.parse(e.newValue || '{}'); + setCompactModeState(!!modes[tableKey]); + } catch { + // ignore parse error + } + } + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, [tableKey]); + + return [compactMode, setCompactMode]; +} \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cab7f8fbeb54..8a38ef0c9c8b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -139,7 +139,7 @@ "已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.", "通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!", "已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!", - "搜索渠道的 ID,名称,密钥和API地址 ...": "Search channel ID, name, key and Base URL...", + "渠道ID,名称,密钥,API地址": "Channel ID, name, key, Base URL", "名称": "Name", "分组": "Group", "类型": "Type", @@ -397,7 +397,7 @@ "删除用户": "Delete User", "添加新的用户": "Add New User", "自定义": "Custom", - "等价金额": "Equivalent Amount", + "等价金额:": "Equivalent Amount: ", "未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again", "请求次数过多,请稍后再试": "Too many requests, please try again later", "服务器内部错误,请联系管理员": "Server internal error, please contact the administrator", @@ -428,6 +428,7 @@ "填入基础模型": "Fill in the basic model", "填入所有模型": "Fill in all models", "清除所有模型": "Clear all models", + "复制所有模型": "Copy all models", "密钥": "Key", "请输入密钥": "Please enter the key", "批量创建": "Batch Create", @@ -456,7 +457,7 @@ "令牌分组,默认为用户的分组": "Token group, default is the your's group", "IP白名单": "IP whitelist", "注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.", - "设为无限额度": "Set to unlimited quota", + "无限额度": "Unlimited quota", "更新令牌信息": "Update Token Information", "请输入充值码!": "Please enter the recharge code!", "请输入名称": "Please enter a name", @@ -470,10 +471,11 @@ "请输入新的密码": "Please enter a new password", "显示名称": "Display Name", "请输入新的显示名称": "Please enter a new display name", - "已绑定的 GitHub 账户": "GitHub Account Bound", - "此项只读,要用户通过个人设置页面的相关绑��按钮进��绑���,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly", - "已绑定的微信账户": "WeChat Account Bound", - "已绑定的邮箱账户": "Email Account Bound", + "已绑定的 GITHUB 账户": "Bound GitHub Account", + "已绑定的 WECHAT 账户": "Bound WeChat Account", + "已绑定的 EMAIL 账户": "Bound Email Account", + "已绑定的 TELEGRAM 账户": "Bound Telegram Account", + "此项只读,要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly", "用户信息更新成功!": "User information updated successfully!", "使用明细(总消耗额度:{renderQuota(stat.quota)})": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})", "用户名称": "User Name", @@ -515,7 +517,6 @@ "注意,系统请求的时模型名称中的点会被剔除,例如:gpt-4.1会请求为gpt-41,所以在Azure部署的时候,部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41", "2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment", "模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!", - "取消无限额度": "Cancel unlimited quota", "取消": "Cancel", "重置": "Reset", "请输入新的剩余额度": "Please enter the new remaining quota", @@ -800,6 +801,7 @@ "获取无水印": "Get no watermark", "生成图片": "Generate pictures", "可灵": "Kling", + "即梦": "Jimeng", "正在提交": "Submitting", "执行中": "processing", "平台": "platform", @@ -813,7 +815,16 @@ "复制所选令牌": "Copy selected token", "请至少选择一个令牌!": "Please select at least one token!", "管理员未设置查询页链接": "The administrator has not set the query page link", - "复制所选令牌到剪贴板": "Copy selected token to clipboard", + "批量删除令牌": "Batch delete token", + "确定要删除所选的 {{count}} 个令牌吗?": "Are you sure you want to delete the selected {{count}} tokens?", + "删除所选令牌": "Delete selected token", + "请先选择要删除的令牌!": "Please select the token to be deleted!", + "已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!", + "删除失败": "Delete failed", + "复制令牌": "Copy token", + "请选择你的复制方式": "Please select your copy method", + "名称+密钥": "Name + key", + "仅密钥": "Only key", "查看API地址": "View API address", "打开查询页": "Open query page", "时间(仅显示近3天)": "Time (only displays the last 3 days)", @@ -1263,7 +1274,7 @@ " 吗?": "?", "修改子渠道优先级": "Modify sub-channel priority", "确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ", - "分组设置": "Group settings", + "分组倍率设置": "Group ratio settings", "用户可选分组": "User selectable groups", "保存分组倍率设置": "Save group ratio settings", "模型倍率设置": "Model ratio settings", @@ -1412,8 +1423,8 @@ "初始化系统": "Initialize system", "支持众多的大模型供应商": "Supporting various LLM providers", "统一的大模型接口网关": "The Unified LLMs API Gateway", - "更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required", - "开始使用": "Get Started", + "更好的价格,更好的稳定性,只需要将模型基址替换为:": "Better price, better stability, no subscription required, just replace the model BASE URL with: ", + "获取密钥": "Get Key", "关于我们": "About Us", "关于项目": "About Project", "联系我们": "Contact Us", @@ -1449,7 +1460,8 @@ "访问限制": "Access Restrictions", "设置令牌的访问限制": "Set token access restrictions", "请勿过度信任此功能,IP可能被伪造": "Do not over-trust this feature, IP can be spoofed", - "勾选启用模型限制后可选择": "Select after checking to enable model restrictions", + "模型限制列表": "Model restrictions list", + "请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models", "非必要,不建议启用模型限制": "Not necessary, model restrictions are not recommended", "分组信息": "Group Information", "设置令牌的分组": "Set token grouping", @@ -1615,6 +1627,7 @@ "编辑公告": "Edit Notice", "公告内容": "Notice Content", "请输入公告内容": "Please enter the notice content", + "请输入公告内容(支持 Markdown/HTML)": "Please enter the notice content (supports Markdown/HTML)", "发布日期": "Publish Date", "请选择发布日期": "Please select the publish date", "发布时间": "Publish Time", @@ -1630,6 +1643,7 @@ "请输入问题标题": "Please enter the question title", "回答内容": "Answer Content", "请输入回答内容": "Please enter the answer content", + "请输入回答内容(支持 Markdown/HTML)": "Please enter the answer content (supports Markdown/HTML)", "确定要删除此问答吗?": "Are you sure you want to delete this FAQ?", "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)", "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)", @@ -1701,5 +1715,40 @@ "充值分组倍率": "Recharge group ratio", "充值方式设置": "Recharge method settings", "更新支付设置": "Update payment settings", - "通知": "Notice" + "通知": "Notice", + "源地址": "Source address", + "同步接口": "Synchronization interface", + "置信度": "Confidence", + "谨慎": "Cautious", + "该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution", + "可信": "Reliable", + "所有上游数据均可信": "All upstream data is reliable", + "以下上游数据可能不可信:": "The following upstream data may not be reliable: ", + "按倍率类型筛选": "Filter by ratio type", + "内容": "Content", + "放大编辑": "Expand editor", + "编辑公告内容": "Edit announcement content", + "自适应列表": "Adaptive list", + "紧凑列表": "Compact list", + "仅显示矛盾倍率": "Only show conflicting ratios", + "矛盾": "Conflict", + "确认冲突项修改": "Confirm conflict item modification", + "该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection", + "当前计费": "Current billing", + "修改为": "Modify to", + "状态筛选": "Status filter", + "没有模型可以复制": "No models to copy", + "模型列表已复制到剪贴板": "Model list copied to clipboard", + "复制失败": "Copy failed", + "复制已选": "Copy selected", + "选择成功": "Selection successful", + "暂无成功模型": "No successful models", + "请先选择模型!": "Please select a model first!", + "已复制 ${count} 个模型": "Copied ${count} models", + "复制失败,请手动复制": "Copy failed, please copy manually", + "快捷设置": "Quick settings", + "批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name", + "额度必须大于0": "Quota must be greater than 0", + "生成数量必须大于0": "Generation quantity must be greater than 0", + "创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel" } \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index c95e6db402f7..b9a772acd85c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -375,6 +375,7 @@ code { } /* 隐藏卡片内容区域的滚动条 */ +.model-test-scroll, .card-content-scroll, .model-settings-scroll, .thinking-content-scroll, @@ -385,6 +386,7 @@ code { scrollbar-width: none; } +.model-test-scroll::-webkit-scrollbar, .card-content-scroll::-webkit-scrollbar, .model-settings-scroll::-webkit-scrollbar, .thinking-content-scroll::-webkit-scrollbar, @@ -528,4 +530,66 @@ code { -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; +} + +/* ==================== ScrollList 定制样式 ==================== */ +.semi-scrolllist, +.semi-scrolllist * { + -ms-overflow-style: none; + /* IE, Edge */ + scrollbar-width: none; + /* Firefox */ + background: transparent !important; +} + +.semi-scrolllist::-webkit-scrollbar, +.semi-scrolllist *::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; +} + +.semi-scrolllist-body { + padding: 1px !important; +} + +.semi-scrolllist-list-outer { + padding-right: 0 !important; +} + +/* ==================== Banner 背景模糊球 ==================== */ +.blur-ball { + position: absolute; + width: 360px; + height: 360px; + border-radius: 50%; + filter: blur(120px); + pointer-events: none; + z-index: -1; +} + +.blur-ball-indigo { + background: #6366f1; + /* indigo-500 */ + top: 40px; + left: 50%; + transform: translateX(-50%); + opacity: 0.5; +} + +.blur-ball-teal { + background: #14b8a6; + /* teal-400 */ + top: 200px; + left: 30%; + opacity: 0.4; +} + +/* 浅色主题下让模糊球更柔和 */ +html:not(.dark) .blur-ball-indigo { + opacity: 0.25; +} + +html:not(.dark) .blur-ball-teal { + opacity: 0.2; } \ No newline at end of file diff --git a/web/src/index.js b/web/src/index.js index ef8a3a07feab..ef299ea2c291 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -5,7 +5,6 @@ import '@douyinfe/semi-ui/dist/css/semi.css'; import { UserProvider } from './context/User'; import 'react-toastify/dist/ReactToastify.css'; import { StatusProvider } from './context/Status'; -import { Layout } from '@douyinfe/semi-ui'; import { ThemeProvider } from './context/Theme'; import { StyleProvider } from './context/Style/index.js'; import PageLayout from './components/layout/PageLayout.js'; @@ -15,7 +14,6 @@ import './index.css'; // initialization const root = ReactDOM.createRoot(document.getElementById('root')); -const { Sider, Content, Header, Footer } = Layout; root.render( diff --git a/web/src/pages/About/index.js b/web/src/pages/About/index.js index 3259449ee7e3..032562cabe30 100644 --- a/web/src/pages/About/index.js +++ b/web/src/pages/About/index.js @@ -105,7 +105,7 @@ const About = () => { ); return ( - <> +
{aboutLoaded && about === '' ? (
{ )} )} - +
); }; diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index ca38e6b969a9..413e8248b91f 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -25,8 +25,9 @@ import { ImagePreview, Card, Tag, + Avatar, } from '@douyinfe/semi-ui'; -import { getChannelModels } from '../../helpers'; +import { getChannelModels, copy } from '../../helpers'; import { IconSave, IconClose, @@ -64,6 +65,8 @@ function type2secretPrompt(type) { return '按照如下格式输入:AppId|SecretId|SecretKey'; case 33: return '按照如下格式输入:Ak|Sk|Region'; + case 50: + return '按照如下格式输入: AccessKey|SecretKey'; default: return '请输入渠道对应的鉴权密钥'; } @@ -109,6 +112,10 @@ const EditChannel = (props) => { const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const handleInputChange = (name, value) => { + if (name === 'models' && Array.isArray(value)) { + value = Array.from(new Set(value.map((m) => (m || '').trim()))); + } + if (name === 'base_url' && value.endsWith('/v1')) { Modal.confirm({ title: '警告', @@ -263,10 +270,14 @@ const EditChannel = (props) => { const fetchModels = async () => { try { let res = await API.get(`/api/channel/models`); - let localModelOptions = res.data.data.map((model) => ({ - label: model.id, - value: model.id, - })); + const localModelOptions = res.data.data.map((model) => { + const id = (model.id || '').trim(); + return { + key: id, + label: id, + value: id, + }; + }); setOriginModelOptions(localModelOptions); setFullModels(res.data.data.map((model) => model.id)); setBasicModels( @@ -298,27 +309,29 @@ const EditChannel = (props) => { } }; -useEffect(() => { - // 使用 Map 来避免重复,以 value 为键 - const modelMap = new Map(); - - // 先添加原始模型选项 - originModelOptions.forEach(option => { - modelMap.set(option.value, option); - }); - - // 再添加当前选中的模型(如果不存在) - inputs.models.forEach(model => { - if (!modelMap.has(model)) { - modelMap.set(model, { - label: model, - value: model, - }); - } - }); - - setModelOptions(Array.from(modelMap.values())); -}, [originModelOptions, inputs.models]); + useEffect(() => { + const modelMap = new Map(); + + originModelOptions.forEach(option => { + const v = (option.value || '').trim(); + if (!modelMap.has(v)) { + modelMap.set(v, option); + } + }); + + inputs.models.forEach(model => { + const v = (model || '').trim(); + if (!modelMap.has(v)) { + modelMap.set(v, { + key: v, + label: v, + value: v, + }); + } + }); + + setModelOptions(Array.from(modelMap.values())); + }, [originModelOptions, inputs.models]); useEffect(() => { fetchModels().then(); @@ -401,7 +414,7 @@ useEffect(() => { localModels.push(model); localModelOptions.push({ key: model, - text: model, + label: model, value: model, }); addedModels.push(model); @@ -440,10 +453,7 @@ useEffect(() => { borderBottom: '1px solid var(--semi-color-border)', padding: '24px' }} - bodyStyle={{ - backgroundColor: 'var(--semi-color-bg-0)', - padding: '0' - }} + bodyStyle={{ padding: '0' }} visible={props.visible} width={isMobile() ? '100%' : 600} footer={ @@ -451,7 +461,6 @@ useEffect(() => {
@@ -750,7 +738,6 @@ useEffect(() => { onChange={(value) => handleInputChange('base_url', value)} value={inputs.base_url} autoComplete='new-password' - size="large" className="!rounded-lg" />
@@ -760,20 +747,14 @@ useEffect(() => { {/* Model Configuration Card */} -
-
-
-
-
-
- -
-
- {t('模型配置')} -
{t('模型选择和映射设置')}
+ {/* Header: Model Config */} +
+ + + +
+ {t('模型配置')} +
{t('模型选择和映射设置')}
@@ -792,7 +773,6 @@ useEffect(() => { value={inputs.models} autoComplete='new-password' optionList={modelOptions} - size="large" className="!rounded-lg" />
@@ -801,7 +781,6 @@ useEffect(() => { + {isEdit ? ( + + ) : null}
+ {!isEdit && ( + + )} +
{ placeholder={t('输入自定义模型名称')} value={customModel} onChange={(value) => setCustomModel(value.trim())} - size="large" className="!rounded-lg" />
@@ -876,7 +879,6 @@ useEffect(() => { placeholder={t('不填则为模型列表第一个')} onChange={(value) => handleInputChange('test_model', value)} value={inputs.test_model} - size="large" className="!rounded-lg" /> @@ -885,20 +887,14 @@ useEffect(() => { {/* Advanced Settings Card */} -
-
-
-
-
-
- -
-
- {t('高级设置')} -
{t('渠道的高级配置选项')}
+ {/* Header: Advanced Settings */} +
+ + + +
+ {t('高级设置')} +
{t('渠道的高级配置选项')}
@@ -917,7 +913,6 @@ useEffect(() => { value={inputs.groups} autoComplete='new-password' optionList={groupOptions} - size="large" className="!rounded-lg" />
@@ -931,7 +926,6 @@ useEffect(() => { onChange={(value) => handleInputChange('other', value)} value={inputs.other} autoComplete='new-password' - size="large" className="!rounded-lg" />
@@ -973,7 +967,6 @@ useEffect(() => { onChange={(value) => handleInputChange('other', value)} value={inputs.other} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -988,7 +981,6 @@ useEffect(() => { onChange={(value) => handleInputChange('other', value)} value={inputs.other} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -1003,7 +995,6 @@ useEffect(() => { onChange={(value) => handleInputChange('other', value)} value={inputs.other} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -1017,7 +1008,6 @@ useEffect(() => { onChange={(value) => handleInputChange('tag', value)} value={inputs.tag} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -1037,7 +1027,6 @@ useEffect(() => { }} value={inputs.priority} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -1057,7 +1046,6 @@ useEffect(() => { }} value={inputs.weight} autoComplete='new-password' - size="large" className="!rounded-lg" /> @@ -1125,7 +1113,6 @@ useEffect(() => { placeholder={t('请输入组织org-xxx')} onChange={(value) => handleInputChange('openai_organization', value)} value={inputs.openai_organization} - size="large" className="!rounded-lg" /> diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js index 2195724cc73e..9d002d78f890 100644 --- a/web/src/pages/Channel/EditTagModal.js +++ b/web/src/pages/Channel/EditTagModal.js @@ -19,6 +19,7 @@ import { TextArea, Card, Tag, + Avatar, } from '@douyinfe/semi-ui'; import { IconSave, @@ -277,10 +278,7 @@ const EditTagModal = (props) => { borderBottom: '1px solid var(--semi-color-border)', padding: '24px' }} - bodyStyle={{ - backgroundColor: 'var(--semi-color-bg-0)', - padding: '0' - }} + bodyStyle={{ padding: '0' }} visible={visible} width={600} onCancel={handleClose} @@ -289,7 +287,6 @@ const EditTagModal = (props) => {