From 7629ad553a3cd80fb53c0a0c18c069683e4102a3 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 27 Aug 2025 12:22:05 +0800 Subject: [PATCH 01/64] fix: prevent loop auto-migrate with bool default false --- model/twofa.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model/twofa.go b/model/twofa.go index 8e97289f9..2a3d33530 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -16,7 +16,7 @@ type TwoFA struct { Id int `json:"id" gorm:"primaryKey"` UserId int `json:"user_id" gorm:"unique;not null;index"` Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端 - IsEnabled bool `json:"is_enabled" gorm:"default:false"` + IsEnabled bool `json:"is_enabled"` FailedAttempts int `json:"failed_attempts" gorm:"default:0"` LockedUntil *time.Time `json:"locked_until,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` @@ -30,7 +30,7 @@ type TwoFABackupCode struct { Id int `json:"id" gorm:"primaryKey"` UserId int `json:"user_id" gorm:"not null;index"` CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希 - IsUsed bool `json:"is_used" gorm:"default:false"` + IsUsed bool `json:"is_used"` UsedAt *time.Time `json:"used_at,omitempty"` CreatedAt time.Time `json:"created_at"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` From 81e29aaa3db696a180077f3960d04a23ecde0157 Mon Sep 17 00:00:00 2001 From: Sh1n3zZ Date: Tue, 26 Aug 2025 08:29:26 +0800 Subject: [PATCH 02/64] feat: vertex veo (#1450) --- common/database.go | 2 +- controller/setup.go | 2 +- controller/task_video.go | 42 ++- main.go | 2 +- middleware/distributor.go | 2 +- relay/channel/task/vertex/adaptor.go | 344 ++++++++++++++++++++++++ relay/channel/vertex/adaptor.go | 1 + relay/channel/vertex/relay-vertex.go | 5 +- relay/channel/vertex/service_account.go | 47 +++- relay/relay_adaptor.go | 6 +- relay/relay_task.go | 96 ++++++- 11 files changed, 534 insertions(+), 15 deletions(-) create mode 100644 relay/channel/task/vertex/adaptor.go diff --git a/common/database.go b/common/database.go index 71dbd94d5..38a54d5e6 100644 --- a/common/database.go +++ b/common/database.go @@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries var UsingMySQL = false var UsingClickHouse = false -var SQLitePath = "one-api.db?_busy_timeout=30000" +var SQLitePath = "one-api.db?_busy_timeout=30000" \ No newline at end of file diff --git a/controller/setup.go b/controller/setup.go index 8943a1a02..44a7b3a73 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -178,4 +178,4 @@ func boolToString(b bool) string { return "true" } return "false" -} +} \ No newline at end of file diff --git a/controller/task_video.go b/controller/task_video.go index ffb6728ba..73d5c39b1 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -94,7 +94,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) } else { - task.Data = responseBody + task.Data = redactVideoResponseBody(responseBody) } now := time.Now().Unix() @@ -113,11 +113,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.StartTime = now } case model.TaskStatusSuccess: - task.Progress = "100%" + task.Progress = "100%" if task.FinishTime == 0 { task.FinishTime = now } - task.FailReason = taskResult.Url + if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { + task.FailReason = taskResult.Url + } case model.TaskStatusFailure: task.Status = model.TaskStatusFailure task.Progress = "100%" @@ -146,3 +148,37 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha return nil } + +func redactVideoResponseBody(body []byte) []byte { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return body + } + resp, _ := m["response"].(map[string]any) + if resp != nil { + delete(resp, "bytesBase64Encoded") + if v, ok := resp["video"].(string); ok { + resp["video"] = truncateBase64(v) + } + if vs, ok := resp["videos"].([]any); ok { + for i := range vs { + if vm, ok := vs[i].(map[string]any); ok { + delete(vm, "bytesBase64Encoded") + } + } + } + } + b, err := json.Marshal(m) + if err != nil { + return body + } + return b +} + +func truncateBase64(s string) string { + const maxKeep = 256 + if len(s) <= maxKeep { + return s + } + return s[:maxKeep] + "..." +} diff --git a/main.go b/main.go index 2dfddaccf..91311b867 100644 --- a/main.go +++ b/main.go @@ -208,4 +208,4 @@ func InitResources() error { return err } return nil -} +} \ No newline at end of file diff --git a/middleware/distributor.go b/middleware/distributor.go index 1e6df872d..7fefeda49 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -166,9 +166,9 @@ 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") { - err = common.UnmarshalBodyReusable(c, &modelRequest) relayMode := relayconstant.RelayModeUnknown if c.Request.Method == http.MethodPost { + err = common.UnmarshalBodyReusable(c, &modelRequest) relayMode = relayconstant.RelayModeVideoSubmit } else if c.Request.Method == http.MethodGet { relayMode = relayconstant.RelayModeVideoFetchByID diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go new file mode 100644 index 000000000..d2ab826d0 --- /dev/null +++ b/relay/channel/task/vertex/adaptor.go @@ -0,0 +1,344 @@ +package vertex + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + vertexcore "one-api/relay/channel/vertex" + relaycommon "one-api/relay/common" + "one-api/service" +) + +type requestPayload struct { + Instances []map[string]any `json:"instances"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type submitResponse struct { + Name string `json:"name"` +} + +type operationVideo struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` +} + +type operationResponse struct { + Name string `json:"name"` + Done bool `json:"done"` + Response struct { + Type string `json:"@type"` + RaiMediaFilteredCount int `json:"raiMediaFilteredCount"` + Videos []operationVideo `json:"videos"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` + Video string `json:"video"` + } `json:"response"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +type TaskAdaptor struct{} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + info.Action = constant.TaskActionTextGenerate + + req := relaycommon.TaskSubmitReq{} + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if strings.TrimSpace(req.Prompt) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) + } + c.Set("task_request", req) + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials: %w", err) + } + modelName := info.OriginModelName + if v, ok := getRequestModelFromContext(info); ok { + modelName = v + } + if modelName == "" { + modelName = "veo-3.0-generate-001" + } + + region := vertexcore.GetModelRegion(info.ApiVersion, modelName) + if strings.TrimSpace(region) == "" { + region = "global" + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning", + adc.ProjectID, + modelName, + ), nil + } + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", + region, + adc.ProjectID, + region, + modelName, + ), nil +} + +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") + + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return fmt.Errorf("failed to decode credentials: %w", err) + } + + token, err := vertexcore.AcquireAccessToken(*adc, info.ChannelSetting.Proxy) + if err != nil { + return fmt.Errorf("failed to acquire access token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) { + v, ok := c.Get("task_request") + if !ok { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body := requestPayload{ + Instances: []map[string]any{{"prompt": req.Prompt}}, + Parameters: map[string]any{}, + } + if req.Metadata != nil { + if v, ok := req.Metadata["storageUri"]; ok { + body.Parameters["storageUri"] = v + } + if v, ok := req.Metadata["sampleCount"]; ok { + body.Parameters["sampleCount"] = v + } + } + if _, ok := body.Parameters["sampleCount"]; !ok { + body.Parameters["sampleCount"] = 1 + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var s submitResponse + if err := json.Unmarshal(responseBody, &s); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + } + if strings.TrimSpace(s.Name) == "" { + return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError) + } + localID := encodeLocalTaskID(s.Name) + c.JSON(http.StatusOK, gin.H{"task_id": localID}) + return localID, responseBody, nil +} + +func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} } +func (a *TaskAdaptor) GetChannelName() string { return "vertex" } + +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") + } + upstreamName, err := decodeLocalTaskID(taskID) + if err != nil { + return nil, fmt.Errorf("decode task_id failed: %w", err) + } + region := extractRegionFromOperationName(upstreamName) + if region == "" { + region = "us-central1" + } + project := extractProjectFromOperationName(upstreamName) + model := extractModelFromOperationName(upstreamName) + if project == "" || model == "" { + return nil, fmt.Errorf("cannot extract project/model from operation name") + } + var url string + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, model) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, model) + } + payload := map[string]string{"operationName": upstreamName} + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(key), adc); err != nil { + return nil, fmt.Errorf("failed to decode credentials: %w", err) + } + token, err := vertexcore.AcquireAccessToken(*adc, "") + if err != nil { + return nil, fmt.Errorf("failed to acquire access token: %w", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var op operationResponse + if err := json.Unmarshal(respBody, &op); err != nil { + return nil, fmt.Errorf("unmarshal operation response failed: %w", err) + } + ti := &relaycommon.TaskInfo{} + if op.Error.Message != "" { + ti.Status = "FAILURE" + ti.Reason = op.Error.Message + ti.Progress = "100%" + return ti, nil + } + if !op.Done { + ti.Status = "IN_PROGRESS" + ti.Progress = "50%" + return ti, nil + } + ti.Status = "SUCCESS" + ti.Progress = "100%" + if len(op.Response.Videos) > 0 { + v0 := op.Response.Videos[0] + if v0.BytesBase64Encoded != "" { + mime := strings.TrimSpace(v0.MimeType) + if mime == "" { + enc := strings.TrimSpace(v0.Encoding) + if enc == "" { + enc = "mp4" + } + if strings.Contains(enc, "/") { + mime = enc + } else { + mime = "video/" + enc + } + } + ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded + return ti, nil + } + } + if op.Response.BytesBase64Encoded != "" { + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded + return ti, nil + } + if op.Response.Video != "" { // some variants use `video` as base64 + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.Video + return ti, nil + } + return ti, nil +} + +func getRequestModelFromContext(info *relaycommon.TaskRelayInfo) (string, bool) { + return info.OriginModelName, info.OriginModelName != "" +} + +func encodeLocalTaskID(name string) string { + return base64.RawURLEncoding.EncodeToString([]byte(name)) +} + +func decodeLocalTaskID(local string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(local) + if err != nil { + return "", err + } + return string(b), nil +} + +var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`) + +func extractRegionFromOperationName(name string) string { + m := regionRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} + +var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`) + +func extractModelFromOperationName(name string) string { + m := modelRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + idx := strings.Index(name, "models/") + if idx >= 0 { + s := name[idx+len("models/"):] + if p := strings.Index(s, "/operations/"); p > 0 { + return s[:p] + } + } + return "" +} + +var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`) + +func extractProjectFromOperationName(name string) string { + m := projectRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 0b6b26743..d15592bf8 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -174,6 +174,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel return err } req.Set("Authorization", "Bearer "+accessToken) + req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) return nil } diff --git a/relay/channel/vertex/relay-vertex.go b/relay/channel/vertex/relay-vertex.go index 5ed876654..f0b84906a 100644 --- a/relay/channel/vertex/relay-vertex.go +++ b/relay/channel/vertex/relay-vertex.go @@ -12,7 +12,10 @@ func GetModelRegion(other string, localModelName string) string { if m[localModelName] != nil { return m[localModelName].(string) } else { - return m["default"].(string) + if v, ok := m["default"]; ok { + return v.(string) + } + return "global" } } return other diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go index 9a4650d98..f90d5454d 100644 --- a/relay/channel/vertex/service_account.go +++ b/relay/channel/vertex/service_account.go @@ -6,14 +6,15 @@ import ( "encoding/json" "encoding/pem" "errors" - "github.com/bytedance/gopkg/cache/asynccache" - "github.com/golang-jwt/jwt" "net/http" "net/url" relaycommon "one-api/relay/common" "one-api/service" "strings" + "github.com/bytedance/gopkg/cache/asynccache" + "github.com/golang-jwt/jwt" + "fmt" "time" ) @@ -137,3 +138,45 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s return "", fmt.Errorf("failed to get access token: %v", result) } + +func AcquireAccessToken(creds Credentials, proxy string) (string, error) { + signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to create signed JWT: %w", err) + } + return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy) +} + +func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) { + authURL := "https://www.googleapis.com/oauth2/v4/token" + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + data.Set("assertion", signedJWT) + + var client *http.Client + var err error + if proxy != "" { + client, err = service.NewProxyHttpClient(proxy) + if err != nil { + return "", fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + resp, err := client.PostForm(authURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if accessToken, ok := result["access_token"].(string); ok { + return accessToken, nil + } + return "", fmt.Errorf("failed to get access token: %v", result) +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 1ee85986c..0c271210b 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -1,7 +1,6 @@ package relay import ( - "github.com/gin-gonic/gin" "one-api/constant" "one-api/relay/channel" "one-api/relay/channel/ali" @@ -28,6 +27,7 @@ import ( taskjimeng "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" + taskvertex "one-api/relay/channel/task/vertex" taskVidu "one-api/relay/channel/task/vidu" "one-api/relay/channel/tencent" "one-api/relay/channel/vertex" @@ -37,6 +37,8 @@ import ( "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" "strconv" + + "github.com/gin-gonic/gin" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -126,6 +128,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &kling.TaskAdaptor{} case constant.ChannelTypeJimeng: return &taskjimeng.TaskAdaptor{} + case constant.ChannelTypeVertexAi: + return &taskvertex.TaskAdaptor{} case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} } diff --git a/relay/relay_task.go b/relay/relay_task.go index 95b8083b3..6faec176d 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -15,6 +15,8 @@ import ( relayconstant "one-api/relay/constant" "one-api/service" "one-api/setting/ratio_setting" + "strconv" + "strings" "github.com/gin-gonic/gin" ) @@ -32,6 +34,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { if err != nil { return service.TaskErrorWrapper(err, "gen_relay_info_failed", http.StatusInternalServerError) } + relayInfo.InitChannelMeta(c) adaptor := GetTaskAdaptor(platform) if adaptor == nil { @@ -197,6 +200,9 @@ func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) { if taskErr != nil { return taskErr } + if len(respBody) == 0 { + respBody = []byte("{\"code\":\"success\",\"data\":null}") + } c.Writer.Header().Set("Content-Type", "application/json") _, err := io.Copy(c.Writer, bytes.NewBuffer(respBody)) @@ -276,10 +282,92 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d return } - respBody, err = json.Marshal(dto.TaskResponse[any]{ - Code: "success", - Data: TaskModel2Dto(originTask), - }) + func() { + channelModel, err2 := model.GetChannelById(originTask.ChannelId, true) + if err2 != nil { + return + } + if channelModel.Type != constant.ChannelTypeVertexAi { + return + } + baseURL := constant.ChannelBaseURLs[channelModel.Type] + if channelModel.GetBaseURL() != "" { + baseURL = channelModel.GetBaseURL() + } + adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type))) + if adaptor == nil { + return + } + resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ + "task_id": originTask.TaskID, + "action": originTask.Action, + }) + if err2 != nil || resp == nil { + return + } + defer resp.Body.Close() + body, err2 := io.ReadAll(resp.Body) + if err2 != nil { + return + } + ti, err2 := adaptor.ParseTaskResult(body) + if err2 == nil && ti != nil { + if ti.Status != "" { + originTask.Status = model.TaskStatus(ti.Status) + } + if ti.Progress != "" { + originTask.Progress = ti.Progress + } + if ti.Url != "" { + originTask.FailReason = ti.Url + } + _ = originTask.Update() + var raw map[string]any + _ = json.Unmarshal(body, &raw) + format := "mp4" + if respObj, ok := raw["response"].(map[string]any); ok { + if vids, ok := respObj["videos"].([]any); ok && len(vids) > 0 { + if v0, ok := vids[0].(map[string]any); ok { + if mt, ok := v0["mimeType"].(string); ok && mt != "" { + if strings.Contains(mt, "mp4") { + format = "mp4" + } else { + format = mt + } + } + } + } + } + status := "processing" + switch originTask.Status { + case model.TaskStatusSuccess: + status = "succeeded" + case model.TaskStatusFailure: + status = "failed" + case model.TaskStatusQueued, model.TaskStatusSubmitted: + status = "queued" + } + out := map[string]any{ + "error": nil, + "format": format, + "metadata": nil, + "status": status, + "task_id": originTask.TaskID, + "url": originTask.FailReason, + } + respBody, _ = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: out, + }) + } + }() + + if len(respBody) == 0 { + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + } return } From e732c5842675d2aeeb3faa2af633341fb9d9c1ac Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 27 Aug 2025 21:30:52 +0800 Subject: [PATCH 03/64] =?UTF-8?q?feat:=20gemini-2.5-flash-image-preview=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/gemini.go | 16 ++++---- relay/channel/gemini/relay-gemini-native.go | 36 ++++++++++++++++++ relay/compatible_handler.go | 15 ++++++++ service/token_counter.go | 2 +- setting/model_setting/gemini.go | 1 + setting/operation_setting/tools.go | 11 ++++++ setting/ratio_setting/model_ratio.go | 10 +++-- web/src/helpers/render.jsx | 38 +++++++++++++++---- web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 + 9 files changed, 111 insertions(+), 20 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index 5df67ba0b..cd5d74cdd 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -2,11 +2,12 @@ package dto import ( "encoding/json" - "github.com/gin-gonic/gin" "one-api/common" "one-api/logger" "one-api/types" "strings" + + "github.com/gin-gonic/gin" ) type GeminiChatRequest struct { @@ -268,14 +269,15 @@ type GeminiChatResponse struct { } type GeminiUsageMetadata struct { - PromptTokenCount int `json:"promptTokenCount"` - CandidatesTokenCount int `json:"candidatesTokenCount"` - TotalTokenCount int `json:"totalTokenCount"` - ThoughtsTokenCount int `json:"thoughtsTokenCount"` - PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"` + CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"` } -type GeminiPromptTokensDetails struct { +type GeminiModalityTokenCount struct { Modality string `json:"modality"` TokenCount int `json:"tokenCount"` } diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 974a22f50..564b86908 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + imageOutputCounts := 0 + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") { + imageOutputCounts++ + } + } + } + if imageOutputCounts != 0 { + usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290 + usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290 + c.Set("gemini_image_tokens", imageOutputCounts*1290) + } + } + + // if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + // for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { + // if detail.Modality == "IMAGE" { + // usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount + // usage.TotalTokens = usage.TotalTokens - detail.TokenCount + // c.Set("gemini_image_tokens", detail.TokenCount) + // } + // } + // } + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { if detail.Modality == "AUDIO" { usage.PromptTokensDetails.AudioTokens = detail.TokenCount @@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn usage.PromptTokensDetails.TextTokens = detail.TokenCount } } + + if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { + if detail.Modality == "IMAGE" { + usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount + usage.TotalTokens = usage.TotalTokens - detail.TokenCount + c.Set("gemini_image_tokens", detail.TokenCount) + } + } + } } // 直接发送 GeminiChatResponse 响应 diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 1f6c525b5..a3c6ace6e 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -314,11 +314,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } else { quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) } + var dGeminiImageOutputQuota decimal.Decimal + var imageOutputPrice float64 + if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { + imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName) + if imageOutputPrice > 0 { + dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens"))) + dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + } + } // 添加 responses tools call 调用的配额 quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) // 添加 audio input 独立计费 quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) + // 添加 Gemini image output 计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -413,6 +424,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage other["audio_input_token_count"] = audioTokens other["audio_input_price"] = audioInputPrice } + if !dGeminiImageOutputQuota.IsZero() { + other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens") + other["image_output_price"] = imageOutputPrice + } model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ ChannelId: relayInfo.ChannelId, PromptTokens: promptTokens, diff --git a/service/token_counter.go b/service/token_counter.go index bac6c067b..9a929e13e 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -304,7 +304,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco for _, file := range meta.Files { switch file.FileType { case types.FileTypeImage: - if info.RelayFormat == types.RelayFormatGemini { + if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") { tkm += 256 } else { token, err := getImageToken(file, model, info.IsStream) diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go index f132fec88..5412155f1 100644 --- a/setting/model_setting/gemini.go +++ b/setting/model_setting/gemini.go @@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{ SupportedImagineModels: []string{ "gemini-2.0-flash-exp-image-generation", "gemini-2.0-flash-exp", + "gemini-2.5-flash-image-preview", }, ThinkingAdapterEnabled: false, ThinkingAdapterBudgetTokensPercentage: 0.6, diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 549a1862e..b87265ee1 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -24,6 +24,10 @@ const ( ClaudeWebSearchPrice = 10.00 ) +const ( + Gemini25FlashImagePreviewImageOutputPrice = 30.00 +) + func GetClaudeWebSearchPricePerThousand() float64 { return ClaudeWebSearchPrice } @@ -65,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { } return 0 } + +func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { + return Gemini25FlashImagePreviewImageOutputPrice + } + return 0 +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index f06cd71ef..1a1b0afa8 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, + "gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -293,10 +294,11 @@ var ( ) var defaultCompletionRatio = map[string]float64{ - "gpt-4-gizmo-*": 2, - "gpt-4o-gizmo-*": 3, - "gpt-4-all": 2, - "gpt-image-1": 8, + "gpt-4-gizmo-*": 2, + "gpt-4o-gizmo-*": 3, + "gpt-4-all": 2, + "gpt-image-1": 8, + "gemini-2.5-flash-image-preview": 8.3333333333, } // InitRatioSettings initializes all model related settings maps diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index f7d66c798..597576288 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1080,7 +1080,7 @@ export function renderModelPrice( cacheRatio = 1.0, image = false, imageRatio = 1.0, - imageOutputTokens = 0, + imageInputTokens = 0, webSearch = false, webSearchCallCount = 0, webSearchPrice = 0, @@ -1090,6 +1090,8 @@ export function renderModelPrice( audioInputSeperatePrice = false, audioInputTokens = 0, audioInputPrice = 0, + imageOutputTokens = 0, + imageOutputPrice = 0, ) { const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio); groupRatio = effectiveGroupRatio; @@ -1117,9 +1119,9 @@ export function renderModelPrice( let effectiveInputTokens = inputTokens - cacheTokens + cacheTokens * cacheRatio; // Handle image tokens if present - if (image && imageOutputTokens > 0) { + if (image && imageInputTokens > 0) { effectiveInputTokens = - inputTokens - imageOutputTokens + imageOutputTokens * imageRatio; + inputTokens - imageInputTokens + imageInputTokens * imageRatio; } if (audioInputTokens > 0) { effectiveInputTokens -= audioInputTokens; @@ -1129,7 +1131,8 @@ export function renderModelPrice( (audioInputTokens / 1000000) * audioInputPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + - (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + + (imageOutputTokens / 1000000) * imageOutputPrice * groupRatio; return ( <> @@ -1164,7 +1167,7 @@ export function renderModelPrice( )}

)} - {image && imageOutputTokens > 0 && ( + {image && imageInputTokens > 0 && (

{i18next.t( '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})', @@ -1191,17 +1194,26 @@ export function renderModelPrice( })}

)} + {imageOutputPrice > 0 && imageOutputTokens > 0 && ( +

+ {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', { + price: imageOutputPrice, + ratio: groupRatio, + total: imageOutputPrice * groupRatio, + })} +

+ )}

{(() => { // 构建输入部分描述 let inputDesc = ''; - if (image && imageOutputTokens > 0) { + if (image && imageInputTokens > 0) { inputDesc = i18next.t( '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}', { - nonImageInput: inputTokens - imageOutputTokens, - imageInput: imageOutputTokens, + nonImageInput: inputTokens - imageInputTokens, + imageInput: imageInputTokens, imageRatio: imageRatio, price: inputRatioPrice, }, @@ -1271,6 +1283,16 @@ export function renderModelPrice( }, ) : '', + imageOutputPrice > 0 && imageOutputTokens > 0 + ? i18next.t( + ' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}', + { + tokenCounts: imageOutputTokens, + price: imageOutputPrice, + ratio: groupRatio, + }, + ) + : '', ].join(''); return i18next.t( diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 0c6c44529..bd2b1100b 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -445,6 +445,8 @@ export const useLogsData = () => { other?.audio_input_seperate_price || false, other?.audio_input_token_count || 0, other?.audio_input_price || 0, + other?.image_output_token_count || 0, + other?.image_output_price || 0, ); } expandDataLocal.push({ From 4055777110ef55292071a67dd1ce2b409d692479 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:16:25 +0800 Subject: [PATCH 04/64] fix: update model name filtering to be case-sensitive --- web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx | 2 +- web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx index 5ca8686b8..9394ae83d 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx @@ -131,7 +131,7 @@ export default function ModelRatioNotSetEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => searchText - ? model.name.toLowerCase().includes(searchText.toLowerCase()) + ? model.name.includes(searchText) : true, ); diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx index 1205f6d82..680bff4a4 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx @@ -99,7 +99,7 @@ export default function ModelSettingsVisualEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => { const keywordMatch = searchText - ? model.name.toLowerCase().includes(searchText.toLowerCase()) + ? model.name.includes(searchText) : true; const conflictMatch = conflictOnly ? model.hasConflict : true; return keywordMatch && conflictMatch; From af94e11c7da4895c55acbf737b1868d03fdb7729 Mon Sep 17 00:00:00 2001 From: yunayj Date: Fri, 29 Aug 2025 19:06:01 +0800 Subject: [PATCH 05/64] =?UTF-8?q?=E4=BF=AE=E6=94=B9claude=20system?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=BA=E6=95=B0=E7=BB=84=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E5=8D=87API=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/claude/relay-claude.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 0c445bb9a..7550a97c8 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeMessages := make([]dto.ClaudeMessage, 0) isFirstMessage := true + // 初始化system消息数组,用于累积多个system消息 + var systemMessages []dto.ClaudeMediaMessage + for _, message := range formatMessages { if message.Role == "system" { + // 根据Claude API规范,system字段使用数组格式更有通用性 if message.IsStringContent() { - claudeRequest.System = message.StringContent() + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](message.StringContent()), + }) } else { - contents := message.ParseContent() - content := "" - for _, ctx := range contents { + // 支持复合内容的system消息(虽然不常见,但需要考虑完整性) + for _, ctx := range message.ParseContent() { if ctx.Type == "text" { - content += ctx.Text + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](ctx.Text), + }) } + // 未来可以在这里扩展对图片等其他类型的支持 } - claudeRequest.System = content } } else { if isFirstMessage { @@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeMessages = append(claudeMessages, claudeMessage) } } + + // 设置累积的system消息 + if len(systemMessages) > 0 { + claudeRequest.System = systemMessages + } + claudeRequest.Prompt = "" claudeRequest.Messages = claudeMessages return &claudeRequest, nil From 8809c44443af90a518b720cd13681a79d5ae6477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=E3=80=82?= Date: Sun, 31 Aug 2025 07:07:40 +0800 Subject: [PATCH 06/64] =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E5=92=8C=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加用户体验 --- controller/misc.go | 4 + controller/user.go | 185 +++++++- dto/user_settings.go | 1 + model/user.go | 86 ++++ web/src/App.jsx | 40 +- .../layout/HeaderBar/Navigation.jsx | 5 +- web/src/components/layout/HeaderBar/index.jsx | 5 +- web/src/components/layout/SiderBar.jsx | 286 ++++++++----- .../components/settings/OperationSetting.jsx | 16 + .../components/settings/PersonalSetting.jsx | 4 + .../personal/cards/NotificationSettings.jsx | 360 +++++++++++++++- web/src/hooks/common/useHeaderBar.js | 40 +- web/src/hooks/common/useNavigation.js | 94 ++-- web/src/hooks/common/useSidebar.js | 220 ++++++++++ web/src/hooks/common/useUserPermissions.js | 100 +++++ web/src/i18n/locales/en.json | 60 ++- .../Operation/SettingsHeaderNavModules.jsx | 326 ++++++++++++++ .../Operation/SettingsSidebarModulesAdmin.jsx | 362 ++++++++++++++++ .../Personal/SettingsSidebarModulesUser.jsx | 404 ++++++++++++++++++ 19 files changed, 2428 insertions(+), 170 deletions(-) create mode 100644 web/src/hooks/common/useSidebar.js create mode 100644 web/src/hooks/common/useUserPermissions.js create mode 100644 web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx create mode 100644 web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx create mode 100644 web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx diff --git a/controller/misc.go b/controller/misc.go index f30ab8c79..dfe3091b5 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -89,6 +89,10 @@ func GetStatus(c *gin.Context) { "announcements_enabled": cs.AnnouncementsEnabled, "faq_enabled": cs.FAQEnabled, + // 模块管理配置 + "HeaderNavModules": common.OptionMap["HeaderNavModules"], + "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, diff --git a/controller/user.go b/controller/user.go index c9795c0cb..91fdce4cf 100644 --- a/controller/user.go +++ b/controller/user.go @@ -210,6 +210,7 @@ func Register(c *gin.Context) { Password: user.Password, DisplayName: user.Username, InviterId: inviterId, + Role: common.RoleCommonUser, // 明确设置角色为普通用户 } if common.EmailVerificationEnabled { cleanUser.Email = user.Email @@ -426,6 +427,7 @@ func GetAffCode(c *gin.Context) { func GetSelf(c *gin.Context) { id := c.GetInt("id") + userRole := c.GetInt("role") user, err := model.GetUserById(id, false) if err != nil { common.ApiError(c, err) @@ -434,14 +436,136 @@ func GetSelf(c *gin.Context) { // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users user.Remark = "" + // 计算用户权限信息 + permissions := calculateUserPermissions(userRole) + + // 获取用户设置并提取sidebar_modules + userSetting := user.GetSetting() + + // 构建响应数据,包含用户信息和权限 + responseData := map[string]interface{}{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "email": user.Email, + "group": user.Group, + "quota": user.Quota, + "used_quota": user.UsedQuota, + "request_count": user.RequestCount, + "aff_code": user.AffCode, + "aff_count": user.AffCount, + "aff_quota": user.AffQuota, + "aff_history_quota": user.AffHistoryQuota, + "inviter_id": user.InviterId, + "linux_do_id": user.LinuxDOId, + "setting": user.Setting, + "stripe_customer": user.StripeCustomer, + "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 + "permissions": permissions, // 新增权限字段 + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": user, + "data": responseData, }) return } +// 计算用户权限的辅助函数 +func calculateUserPermissions(userRole int) map[string]interface{} { + permissions := map[string]interface{}{} + + // 根据用户角色计算权限 + if userRole == common.RoleRootUser { + // 超级管理员不需要边栏设置功能 + permissions["sidebar_settings"] = false + permissions["sidebar_modules"] = map[string]interface{}{} + } else if userRole == common.RoleAdminUser { + // 管理员可以设置边栏,但不包含系统设置功能 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": map[string]interface{}{ + "setting": false, // 管理员不能访问系统设置 + }, + } + } else { + // 普通用户只能设置个人功能,不包含管理员区域 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": false, // 普通用户不能访问管理员区域 + } + } + + return permissions +} + +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfig(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + + + func GetUserModels(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -528,8 +652,8 @@ func UpdateUser(c *gin.Context) { } func UpdateSelf(c *gin.Context) { - var user model.User - err := json.NewDecoder(c.Request.Body).Decode(&user) + var requestData map[string]interface{} + err := json.NewDecoder(c.Request.Body).Decode(&requestData) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -537,6 +661,60 @@ func UpdateSelf(c *gin.Context) { }) return } + + // 检查是否是sidebar_modules更新请求 + if sidebarModules, exists := requestData["sidebar_modules"]; exists { + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 获取当前用户设置 + currentSetting := user.GetSetting() + + // 更新sidebar_modules字段 + if sidebarModulesStr, ok := sidebarModules.(string); ok { + currentSetting.SidebarModules = sidebarModulesStr + } + + // 保存更新后的设置 + user.SetSetting(currentSetting) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "更新设置失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "设置更新成功", + }) + return + } + + // 原有的用户信息更新逻辑 + var user model.User + requestDataBytes, err := json.Marshal(requestData) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + err = json.Unmarshal(requestDataBytes, &user) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if user.Password == "" { user.Password = "$I_LOVE_U" // make Validator happy :) } @@ -679,6 +857,7 @@ func CreateUser(c *gin.Context) { Username: user.Username, Password: user.Password, DisplayName: user.DisplayName, + Role: user.Role, // 保持管理员设置的角色 } if err := cleanUser.Insert(0); err != nil { common.ApiError(c, err) diff --git a/dto/user_settings.go b/dto/user_settings.go index 2e1a15418..56beb7118 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -8,6 +8,7 @@ type UserSetting struct { NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP + SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 } var ( diff --git a/model/user.go b/model/user.go index 29d7a4462..ea0584c5a 100644 --- a/model/user.go +++ b/model/user.go @@ -91,6 +91,68 @@ func (user *User) SetSetting(setting dto.UserSetting) { user.Setting = string(settingBytes) } +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfigForRole(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + // CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil func CheckUserExistOrDeleted(username string, email string) (bool, error) { var user User @@ -320,10 +382,34 @@ func (user *User) Insert(inviterId int) error { user.Quota = common.QuotaForNewUser //user.SetAccessToken(common.GetUUID()) user.AffCode = common.GetRandomString(4) + + // 初始化用户设置,包括默认的边栏配置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 + user.SetSetting(defaultSetting) + } + result := DB.Create(user) if result.Error != nil { return result.Error } + + // 用户创建成功后,根据角色初始化边栏配置 + // 需要重新获取用户以确保有正确的ID和Role + var createdUser User + if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil { + // 生成基于角色的默认边栏配置 + defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role) + if defaultSidebarConfig != "" { + currentSetting := createdUser.GetSetting() + currentSetting.SidebarModules = defaultSidebarConfig + createdUser.SetSetting(currentSetting) + createdUser.Update(false) + common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role)) + } + } + if common.QuotaForNewUser > 0 { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } diff --git a/web/src/App.jsx b/web/src/App.jsx index e3bd7db85..cb9245244 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { lazy, Suspense } from 'react'; +import React, { lazy, Suspense, useContext, useMemo } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; import Loading from './components/common/ui/Loading'; import User from './pages/User'; @@ -27,6 +27,7 @@ import LoginForm from './components/auth/LoginForm'; import NotFound from './pages/NotFound'; import Forbidden from './pages/Forbidden'; import Setting from './pages/Setting'; +import { StatusContext } from './context/Status'; import PasswordResetForm from './components/auth/PasswordResetForm'; import PasswordResetConfirm from './components/auth/PasswordResetConfirm'; @@ -53,6 +54,29 @@ const About = lazy(() => import('./pages/About')); function App() { const location = useLocation(); + const [statusState] = useContext(StatusContext); + + // 获取模型广场权限配置 + const pricingRequireAuth = useMemo(() => { + const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + if (headerNavModulesConfig) { + try { + const modules = JSON.parse(headerNavModulesConfig); + + // 处理向后兼容性:如果pricing是boolean,默认不需要登录 + if (typeof modules.pricing === 'boolean') { + return false; // 默认不需要登录鉴权 + } + + // 如果是对象格式,使用requireAuth配置 + return modules.pricing?.requireAuth === true; + } catch (error) { + console.error('解析顶栏模块配置失败:', error); + return false; // 默认不需要登录 + } + } + return false; // 默认不需要登录 + }, [statusState?.status?.HeaderNavModules]); return ( @@ -253,9 +277,17 @@ function App() { } key={location.pathname}> - - + pricingRequireAuth ? ( + + } key={location.pathname}> + + + + ) : ( + } key={location.pathname}> + + + ) } /> { +const Navigation = ({ mainNavLinks, isMobile, isLoading, userState, pricingRequireAuth }) => { const renderNavLinks = () => { const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out'; @@ -51,6 +51,9 @@ const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => { if (link.itemKey === 'console' && !userState.user) { targetPath = '/login'; } + if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) { + targetPath = '/login'; + } return ( diff --git a/web/src/components/layout/HeaderBar/index.jsx b/web/src/components/layout/HeaderBar/index.jsx index db104de43..81b51d7fe 100644 --- a/web/src/components/layout/HeaderBar/index.jsx +++ b/web/src/components/layout/HeaderBar/index.jsx @@ -44,6 +44,8 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { isDemoSiteMode, isConsoleRoute, theme, + headerNavModules, + pricingRequireAuth, logout, handleLanguageChange, handleThemeToggle, @@ -60,7 +62,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { getUnreadKeys, } = useNotifications(statusState); - const { mainNavLinks } = useNavigation(t, docsLink); + const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules); return (

@@ -102,6 +104,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { isMobile={isMobile} isLoading={isLoading} userState={userState} + pricingRequireAuth={pricingRequireAuth} /> {} }) => { const { t } = useTranslation(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); + const { isModuleVisible, hasSectionVisibleModules, loading: sidebarLoading } = useSidebar(); const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); @@ -57,117 +59,158 @@ const SiderBar = ({ onNavigate = () => {} }) => { const [routerMapState, setRouterMapState] = useState(routerMap); const workspaceItems = useMemo( - () => [ - { - text: t('数据看板'), - itemKey: 'detail', - to: '/detail', - className: - localStorage.getItem('enable_data_export') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('令牌管理'), - itemKey: 'token', - to: '/token', - }, - { - text: t('使用日志'), - itemKey: 'log', - to: '/log', - }, - { - text: t('绘图日志'), - itemKey: 'midjourney', - to: '/midjourney', - className: - localStorage.getItem('enable_drawing') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('任务日志'), - itemKey: 'task', - to: '/task', - className: - localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', - }, - ], + () => { + const items = [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('令牌管理'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ]; + + // 根据配置过滤项目 + const filteredItems = items.filter(item => { + const configVisible = isModuleVisible('console', item.itemKey); + return configVisible; + }); + + return filteredItems; + }, [ localStorage.getItem('enable_data_export'), localStorage.getItem('enable_drawing'), localStorage.getItem('enable_task'), t, + isModuleVisible, ], ); const financeItems = useMemo( - () => [ - { - text: t('钱包管理'), - itemKey: 'topup', - to: '/topup', - }, - { - text: t('个人设置'), - itemKey: 'personal', - to: '/personal', - }, - ], - [t], + () => { + const items = [ + { + text: t('钱包管理'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ]; + + // 根据配置过滤项目 + const filteredItems = items.filter(item => { + const configVisible = isModuleVisible('personal', item.itemKey); + return configVisible; + }); + + return filteredItems; + }, + [t, isModuleVisible], ); const adminItems = useMemo( - () => [ - { - text: t('渠道管理'), - itemKey: 'channel', - to: '/channel', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('模型管理'), - itemKey: 'models', - to: '/console/models', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('兑换码管理'), - itemKey: 'redemption', - to: '/redemption', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('用户管理'), - itemKey: 'user', - to: '/user', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('系统设置'), - itemKey: 'setting', - to: '/setting', - className: isRoot() ? '' : 'tableHiddle', - }, - ], - [isAdmin(), isRoot(), t], + () => { + const items = [ + { + text: t('渠道管理'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('模型管理'), + itemKey: 'models', + to: '/console/models', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码管理'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ]; + + // 根据配置过滤项目 + const filteredItems = items.filter(item => { + const configVisible = isModuleVisible('admin', item.itemKey); + return configVisible; + }); + + return filteredItems; + }, + [isAdmin(), isRoot(), t, isModuleVisible], ); const chatMenuItems = useMemo( - () => [ - { - text: t('操练场'), - itemKey: 'playground', - to: '/playground', - }, - { - text: t('聊天'), - itemKey: 'chat', - items: chatItems, - }, - ], - [chatItems, t], + () => { + const items = [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ]; + + // 根据配置过滤项目 + const filteredItems = items.filter(item => { + const configVisible = isModuleVisible('chat', item.itemKey); + return configVisible; + }); + + return filteredItems; + }, + [chatItems, t, isModuleVisible], ); // 更新路由映射,添加聊天路由 @@ -213,7 +256,6 @@ const SiderBar = ({ onNavigate = () => {} }) => { updateRouterMapWithChats(chats); } } catch (e) { - console.error(e); showError('聊天数据解析失败'); } } @@ -382,31 +424,41 @@ const SiderBar = ({ onNavigate = () => {} }) => { }} > {/* 聊天区域 */} -
- {!collapsed &&
{t('聊天')}
} - {chatMenuItems.map((item) => renderSubItem(item))} -
+ {hasSectionVisibleModules('chat') && ( +
+ {!collapsed &&
{t('聊天')}
} + {chatMenuItems.map((item) => renderSubItem(item))} +
+ )} {/* 控制台区域 */} - -
- {!collapsed && ( -
{t('控制台')}
- )} - {workspaceItems.map((item) => renderNavItem(item))} -
+ {hasSectionVisibleModules('console') && ( + <> + +
+ {!collapsed && ( +
{t('控制台')}
+ )} + {workspaceItems.map((item) => renderNavItem(item))} +
+ + )} {/* 个人中心区域 */} - -
- {!collapsed && ( -
{t('个人中心')}
- )} - {financeItems.map((item) => renderNavItem(item))} -
+ {hasSectionVisibleModules('personal') && ( + <> + +
+ {!collapsed && ( +
{t('个人中心')}
+ )} + {financeItems.map((item) => renderNavItem(item))} +
+ + )} - {/* 管理员区域 - 只在管理员时显示 */} - {isAdmin() && ( + {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */} + {isAdmin() && hasSectionVisibleModules('admin') && ( <>
diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index d2669c643..05bda1528 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com import React, { useEffect, useState } from 'react'; import { Card, Spin } from '@douyinfe/semi-ui'; import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral'; +import SettingsHeaderNavModules from '../../pages/Setting/Operation/SettingsHeaderNavModules'; +import SettingsSidebarModulesAdmin from '../../pages/Setting/Operation/SettingsSidebarModulesAdmin'; import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords'; import SettingsLog from '../../pages/Setting/Operation/SettingsLog'; import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring'; @@ -46,6 +48,12 @@ const OperationSetting = () => { DemoSiteEnabled: false, SelfUseModeEnabled: false, + /* 顶栏模块管理 */ + HeaderNavModules: '', + + /* 左侧边栏模块管理(管理员) */ + SidebarModulesAdmin: '', + /* 敏感词设置 */ CheckSensitiveEnabled: false, CheckSensitiveOnPromptEnabled: false, @@ -108,6 +116,14 @@ const OperationSetting = () => { + {/* 顶栏模块管理 */} +
+ +
+ {/* 左侧边栏模块管理(管理员) */} +
+ +
{/* 屏蔽词过滤设置 */} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index cdeb1f511..07f2b8c48 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -38,6 +38,8 @@ const PersonalSetting = () => { let navigate = useNavigate(); const { t } = useTranslation(); + + const [inputs, setInputs] = useState({ wechat_verification_code: '', email_verification_code: '', @@ -330,6 +332,8 @@ const PersonalSetting = () => { saveNotificationSettings={saveNotificationSettings} />
+ + diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 6caffde7b..d76706c55 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useContext } from 'react'; import { Button, Typography, @@ -28,11 +28,17 @@ import { Toast, Tabs, TabPane, + Switch, + Row, + Col, } from '@douyinfe/semi-ui'; import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons'; -import { ShieldCheck, Bell, DollarSign } from 'lucide-react'; -import { renderQuotaWithPrompt } from '../../../../helpers'; +import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react'; +import { renderQuotaWithPrompt, API, showSuccess, showError } from '../../../../helpers'; import CodeViewer from '../../../playground/CodeViewer'; +import { StatusContext } from '../../../../context/Status'; +import { UserContext } from '../../../../context/User'; +import { useUserPermissions } from '../../../../hooks/common/useUserPermissions'; const NotificationSettings = ({ t, @@ -41,6 +47,128 @@ const NotificationSettings = ({ saveNotificationSettings, }) => { const formApiRef = useRef(null); + const [statusState] = useContext(StatusContext); + const [userState] = useContext(UserContext); + + // 左侧边栏设置相关状态 + const [sidebarLoading, setSidebarLoading] = useState(false); + const [activeTabKey, setActiveTabKey] = useState('notification'); + const [sidebarModulesUser, setSidebarModulesUser] = useState({ + chat: { + enabled: true, + playground: true, + chat: true + }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true + }, + personal: { + enabled: true, + topup: true, + personal: true + }, + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true + } + }); + const [adminConfig, setAdminConfig] = useState(null); + + // 使用后端权限验证替代前端角色判断 + const { + permissions, + loading: permissionsLoading, + hasSidebarSettingsPermission, + isSidebarSectionAllowed, + isSidebarModuleAllowed, + } = useUserPermissions(); + + // 左侧边栏设置处理函数 + const handleSectionChange = (sectionKey) => { + return (checked) => { + const newModules = { + ...sidebarModulesUser, + [sectionKey]: { + ...sidebarModulesUser[sectionKey], + enabled: checked + } + }; + setSidebarModulesUser(newModules); + }; + }; + + const handleModuleChange = (sectionKey, moduleKey) => { + return (checked) => { + const newModules = { + ...sidebarModulesUser, + [sectionKey]: { + ...sidebarModulesUser[sectionKey], + [moduleKey]: checked + } + }; + setSidebarModulesUser(newModules); + }; + }; + + const saveSidebarSettings = async () => { + setSidebarLoading(true); + try { + const res = await API.put('/api/user/self', { + sidebar_modules: JSON.stringify(sidebarModulesUser) + }); + if (res.data.success) { + showSuccess(t('侧边栏设置保存成功')); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('保存失败')); + } + setSidebarLoading(false); + }; + + const resetSidebarModules = () => { + const defaultConfig = { + chat: { enabled: true, playground: true, chat: true }, + console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true }, + personal: { enabled: true, topup: true, personal: true }, + admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true } + }; + setSidebarModulesUser(defaultConfig); + }; + + // 加载左侧边栏配置 + useEffect(() => { + const loadSidebarConfigs = async () => { + try { + // 获取管理员全局配置 + if (statusState?.status?.SidebarModulesAdmin) { + const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin); + setAdminConfig(adminConf); + } + + // 获取用户个人配置 + const userRes = await API.get('/api/user/self'); + if (userRes.data.success && userRes.data.data.sidebar_modules) { + const userConf = JSON.parse(userRes.data.data.sidebar_modules); + setSidebarModulesUser(userConf); + } + } catch (error) { + console.error('加载边栏配置失败:', error); + } + }; + + loadSidebarConfigs(); + }, [statusState]); // 初始化表单值 useEffect(() => { @@ -54,6 +182,75 @@ const NotificationSettings = ({ handleNotificationSettingChange(field, value); }; + // 检查功能是否被管理员允许 + const isAllowedByAdmin = (sectionKey, moduleKey = null) => { + if (!adminConfig) return true; + + if (moduleKey) { + return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]; + } else { + return adminConfig[sectionKey]?.enabled; + } + }; + + // 区域配置数据(根据权限过滤) + const sectionConfigs = [ + { + key: 'chat', + title: t('聊天区域'), + description: t('操练场和聊天功能'), + modules: [ + { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') }, + { key: 'chat', title: t('聊天'), description: t('聊天会话管理') } + ] + }, + { + key: 'console', + title: t('控制台区域'), + description: t('数据管理和日志查看'), + modules: [ + { key: 'detail', title: t('数据看板'), description: t('系统数据统计') }, + { key: 'token', title: t('令牌管理'), description: t('API令牌管理') }, + { key: 'log', title: t('使用日志'), description: t('API使用记录') }, + { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') }, + { key: 'task', title: t('任务日志'), description: t('系统任务记录') } + ] + }, + { + key: 'personal', + title: t('个人中心区域'), + description: t('用户个人功能'), + modules: [ + { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') }, + { key: 'personal', title: t('个人设置'), description: t('个人信息设置') } + ] + }, + // 管理员区域:根据后端权限控制显示 + { + key: 'admin', + title: t('管理员区域'), + description: t('系统管理功能'), + modules: [ + { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, + { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, + { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') }, + { key: 'user', title: t('用户管理'), description: t('用户账户管理') }, + { key: 'setting', title: t('系统设置'), description: t('系统参数配置') } + ] + } + ].filter(section => { + // 使用后端权限验证替代前端角色判断 + return isSidebarSectionAllowed(section.key); + }).map(section => ({ + ...section, + modules: section.modules.filter(module => + isSidebarModuleAllowed(section.key, module.key) + ) + })).filter(section => + // 过滤掉没有可用模块的区域 + section.modules.length > 0 && isAllowedByAdmin(section.key) + ); + // 表单提交 const handleSubmit = () => { if (formApiRef.current) { @@ -75,10 +272,32 @@ const NotificationSettings = ({ - +
+ {activeTabKey === 'sidebar' ? ( + // 边栏设置标签页的按钮 + <> + + + + ) : ( + // 其他标签页的通用保存按钮 + + )}
} > @@ -103,7 +322,11 @@ const NotificationSettings = ({ onSubmit={handleSubmit} > {() => ( - + setActiveTabKey(key)} + > {/* 通知配置 Tab */} + + {/* 左侧边栏设置 Tab - 根据后端权限控制显示 */} + {hasSidebarSettingsPermission() && ( + + + {t('边栏设置')} + + } + itemKey='sidebar' + > +
+
+ + {t('您可以个性化设置侧边栏的要显示功能')} + +
+ + {/* 边栏设置功能区域容器 */} +
+ + {sectionConfigs.map((section) => ( +
+ {/* 区域标题和总开关 */} +
+
+
+ {section.title} +
+ + {section.description} + +
+ +
+ + {/* 功能模块网格 */} + + {section.modules + .filter(module => isAllowedByAdmin(section.key, module.key)) + .map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+
+ + ))} +
+
+ ))} +
{/* 关闭边栏设置功能区域容器 */} +
+
+ )}
)} diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js index 1b91511ba..9f95a9b9a 100644 --- a/web/src/hooks/common/useHeaderBar.js +++ b/web/src/hooks/common/useHeaderBar.js @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState, useEffect, useContext, useCallback } from 'react'; +import { useState, useEffect, useContext, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { UserContext } from '../../context/User'; @@ -51,6 +51,42 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const docsLink = statusState?.status?.docs_link || ''; const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + // 获取顶栏模块配置 + const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + + // 使用useMemo确保headerNavModules正确响应statusState变化 + const headerNavModules = useMemo(() => { + if (headerNavModulesConfig) { + try { + const modules = JSON.parse(headerNavModulesConfig); + + // 处理向后兼容性:如果pricing是boolean,转换为对象格式 + if (typeof modules.pricing === 'boolean') { + modules.pricing = { + enabled: modules.pricing, + requireAuth: false // 默认不需要登录鉴权 + }; + } + + return modules; + } catch (error) { + console.error('解析顶栏模块配置失败:', error); + return null; + } + } + return null; + }, [headerNavModulesConfig]); + + // 获取模型广场权限配置 + const pricingRequireAuth = useMemo(() => { + if (headerNavModules?.pricing) { + return typeof headerNavModules.pricing === 'object' + ? headerNavModules.pricing.requireAuth + : false; // 默认不需要登录 + } + return false; // 默认不需要登录 + }, [headerNavModules]); + const isConsoleRoute = location.pathname.startsWith('/console'); const theme = useTheme(); @@ -156,6 +192,8 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { isConsoleRoute, theme, drawerOpen, + headerNavModules, + pricingRequireAuth, // Actions logout, diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js index e0bac5521..43d9024ea 100644 --- a/web/src/hooks/common/useNavigation.js +++ b/web/src/hooks/common/useNavigation.js @@ -19,41 +19,67 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react'; -export const useNavigation = (t, docsLink) => { +export const useNavigation = (t, docsLink, headerNavModules) => { const mainNavLinks = useMemo( - () => [ - { - text: t('首页'), - itemKey: 'home', - to: '/', - }, - { - text: t('控制台'), - itemKey: 'console', - to: '/console', - }, - { - text: t('模型广场'), - itemKey: 'pricing', - to: '/pricing', - }, - ...(docsLink - ? [ - { - text: t('文档'), - itemKey: 'docs', - isExternal: true, - externalLink: docsLink, - }, - ] - : []), - { - text: t('关于'), - itemKey: 'about', - to: '/about', - }, - ], - [t, docsLink], + () => { + // 默认配置,如果没有传入配置则显示所有模块 + const defaultModules = { + home: true, + console: true, + pricing: true, + docs: true, + about: true, + }; + + // 使用传入的配置或默认配置 + const modules = headerNavModules || defaultModules; + + const allLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('模型广场'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; + + // 根据配置过滤导航链接 + return allLinks.filter(link => { + if (link.itemKey === 'docs') { + return docsLink && modules.docs; + } + if (link.itemKey === 'pricing') { + // 支持新的pricing配置格式 + return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing; + } + return modules[link.itemKey] === true; + }); + }, + [t, docsLink, headerNavModules], ); return { diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js new file mode 100644 index 000000000..0a695bbd4 --- /dev/null +++ b/web/src/hooks/common/useSidebar.js @@ -0,0 +1,220 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { useState, useEffect, useMemo, useContext } from 'react'; +import { StatusContext } from '../../context/Status'; +import { API } from '../../helpers'; + +export const useSidebar = () => { + const [statusState] = useContext(StatusContext); + const [userConfig, setUserConfig] = useState(null); + const [loading, setLoading] = useState(true); + + // 默认配置 + const defaultAdminConfig = { + chat: { + enabled: true, + playground: true, + chat: true + }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true + }, + personal: { + enabled: true, + topup: true, + personal: true + }, + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true + } + }; + + // 获取管理员配置 + const adminConfig = useMemo(() => { + if (statusState?.status?.SidebarModulesAdmin) { + try { + const config = JSON.parse(statusState.status.SidebarModulesAdmin); + return config; + } catch (error) { + return defaultAdminConfig; + } + } + return defaultAdminConfig; + }, [statusState?.status?.SidebarModulesAdmin]); + + // 加载用户配置的通用方法 + const loadUserConfig = async () => { + try { + setLoading(true); + const res = await API.get('/api/user/self'); + if (res.data.success && res.data.data.sidebar_modules) { + let config; + // 检查sidebar_modules是字符串还是对象 + if (typeof res.data.data.sidebar_modules === 'string') { + config = JSON.parse(res.data.data.sidebar_modules); + } else { + config = res.data.data.sidebar_modules; + } + setUserConfig(config); + } else { + // 当用户没有配置时,生成一个基于管理员配置的默认用户配置 + // 这样可以确保权限控制正确生效 + const defaultUserConfig = {}; + Object.keys(adminConfig).forEach(sectionKey => { + if (adminConfig[sectionKey]?.enabled) { + defaultUserConfig[sectionKey] = { enabled: true }; + // 为每个管理员允许的模块设置默认值为true + Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { + if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { + defaultUserConfig[sectionKey][moduleKey] = true; + } + }); + } + }); + setUserConfig(defaultUserConfig); + } + } catch (error) { + // 出错时也生成默认配置,而不是设置为空对象 + const defaultUserConfig = {}; + Object.keys(adminConfig).forEach(sectionKey => { + if (adminConfig[sectionKey]?.enabled) { + defaultUserConfig[sectionKey] = { enabled: true }; + Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { + if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { + defaultUserConfig[sectionKey][moduleKey] = true; + } + }); + } + }); + setUserConfig(defaultUserConfig); + } finally { + setLoading(false); + } + }; + + // 刷新用户配置的方法(供外部调用) + const refreshUserConfig = async () => { + if (Object.keys(adminConfig).length > 0) { + await loadUserConfig(); + } + }; + + // 加载用户配置 + useEffect(() => { + // 只有当管理员配置加载完成后才加载用户配置 + if (Object.keys(adminConfig).length > 0) { + loadUserConfig(); + } + }, [adminConfig]); + + // 计算最终的显示配置 + const finalConfig = useMemo(() => { + const result = {}; + + // 确保adminConfig已加载 + if (!adminConfig || Object.keys(adminConfig).length === 0) { + return result; + } + + // 如果userConfig未加载,等待加载完成 + if (!userConfig) { + return result; + } + + // 遍历所有区域 + Object.keys(adminConfig).forEach(sectionKey => { + const adminSection = adminConfig[sectionKey]; + const userSection = userConfig[sectionKey]; + + // 如果管理员禁用了整个区域,则该区域不显示 + if (!adminSection?.enabled) { + result[sectionKey] = { enabled: false }; + return; + } + + // 区域级别:用户可以选择隐藏管理员允许的区域 + // 当userSection存在时检查enabled状态,否则默认为true + const sectionEnabled = userSection ? (userSection.enabled !== false) : true; + result[sectionKey] = { enabled: sectionEnabled }; + + // 功能级别:只有管理员和用户都允许的功能才显示 + Object.keys(adminSection).forEach(moduleKey => { + if (moduleKey === 'enabled') return; + + const adminAllowed = adminSection[moduleKey]; + // 当userSection存在时检查模块状态,否则默认为true + const userAllowed = userSection ? (userSection[moduleKey] !== false) : true; + + result[sectionKey][moduleKey] = adminAllowed && userAllowed && sectionEnabled; + }); + }); + + return result; + }, [adminConfig, userConfig]); + + // 检查特定功能是否应该显示 + const isModuleVisible = (sectionKey, moduleKey = null) => { + if (moduleKey) { + return finalConfig[sectionKey]?.[moduleKey] === true; + } else { + return finalConfig[sectionKey]?.enabled === true; + } + }; + + // 检查区域是否有任何可见的功能 + const hasSectionVisibleModules = (sectionKey) => { + const section = finalConfig[sectionKey]; + if (!section?.enabled) return false; + + return Object.keys(section).some(key => + key !== 'enabled' && section[key] === true + ); + }; + + // 获取区域的可见功能列表 + const getVisibleModules = (sectionKey) => { + const section = finalConfig[sectionKey]; + if (!section?.enabled) return []; + + return Object.keys(section) + .filter(key => key !== 'enabled' && section[key] === true); + }; + + return { + loading, + adminConfig, + userConfig, + finalConfig, + isModuleVisible, + hasSectionVisibleModules, + getVisibleModules, + refreshUserConfig + }; +}; diff --git a/web/src/hooks/common/useUserPermissions.js b/web/src/hooks/common/useUserPermissions.js new file mode 100644 index 000000000..743594353 --- /dev/null +++ b/web/src/hooks/common/useUserPermissions.js @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react'; +import { API } from '../../helpers'; + +/** + * 用户权限钩子 - 从后端获取用户权限,替代前端角色判断 + * 确保权限控制的安全性,防止前端绕过 + */ +export const useUserPermissions = () => { + const [permissions, setPermissions] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 加载用户权限(从用户信息接口获取) + const loadPermissions = async () => { + try { + setLoading(true); + setError(null); + const res = await API.get('/api/user/self'); + if (res.data.success) { + const userPermissions = res.data.data.permissions; + setPermissions(userPermissions); + console.log('用户权限加载成功:', userPermissions); + } else { + setError(res.data.message || '获取权限失败'); + console.error('获取权限失败:', res.data.message); + } + } catch (error) { + setError('网络错误,请重试'); + console.error('加载用户权限异常:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadPermissions(); + }, []); + + // 检查是否有边栏设置权限 + const hasSidebarSettingsPermission = () => { + return permissions?.sidebar_settings === true; + }; + + // 检查是否允许访问特定的边栏区域 + const isSidebarSectionAllowed = (sectionKey) => { + if (!permissions?.sidebar_modules) return true; + const sectionPerms = permissions.sidebar_modules[sectionKey]; + return sectionPerms !== false; + }; + + // 检查是否允许访问特定的边栏模块 + const isSidebarModuleAllowed = (sectionKey, moduleKey) => { + if (!permissions?.sidebar_modules) return true; + const sectionPerms = permissions.sidebar_modules[sectionKey]; + + // 如果整个区域被禁用 + if (sectionPerms === false) return false; + + // 如果区域存在但模块被禁用 + if (sectionPerms && sectionPerms[moduleKey] === false) return false; + + return true; + }; + + // 获取允许的边栏区域列表 + const getAllowedSidebarSections = () => { + if (!permissions?.sidebar_modules) return []; + + return Object.keys(permissions.sidebar_modules).filter(sectionKey => + isSidebarSectionAllowed(sectionKey) + ); + }; + + // 获取特定区域允许的模块列表 + const getAllowedSidebarModules = (sectionKey) => { + if (!permissions?.sidebar_modules) return []; + const sectionPerms = permissions.sidebar_modules[sectionKey]; + + if (sectionPerms === false) return []; + if (!sectionPerms || typeof sectionPerms !== 'object') return []; + + return Object.keys(sectionPerms).filter(moduleKey => + moduleKey !== 'enabled' && sectionPerms[moduleKey] === true + ); + }; + + return { + permissions, + loading, + error, + loadPermissions, + hasSidebarSettingsPermission, + isSidebarSectionAllowed, + isSidebarModuleAllowed, + getAllowedSidebarSections, + getAllowedSidebarModules, + }; +}; + +export default useUserPermissions; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 877fa44fe..7b308b9b7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2017,5 +2017,63 @@ "查看密钥": "View key", "查看渠道密钥": "View channel key", "渠道密钥信息": "Channel key information", - "密钥获取成功": "Key acquisition successful" + "密钥获取成功": "Key acquisition successful", + "顶栏管理": "Header Management", + "控制顶栏模块显示状态,全局生效": "Control header module display status, global effect", + "用户主页,展示系统信息": "User homepage, displaying system information", + "用户控制面板,管理账户": "User control panel for account management", + "模型广场": "Model Marketplace", + "模型定价,需要登录访问": "Model pricing, requires login to access", + "文档": "Documentation", + "系统文档和帮助信息": "System documentation and help information", + "关于系统的详细信息": "Detailed information about the system", + "重置为默认": "Reset to Default", + "保存设置": "Save Settings", + "已重置为默认配置": "Reset to default configuration", + "保存成功": "Saved successfully", + "保存失败,请重试": "Save failed, please try again", + "侧边栏管理(全局控制)": "Sidebar Management (Global Control)", + "全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Global control of sidebar areas and functions, users cannot enable functions hidden by administrators", + "聊天区域": "Chat Area", + "操练场和聊天功能": "Playground and chat functions", + "操练场": "Playground", + "AI模型测试环境": "AI model testing environment", + "聊天": "Chat", + "聊天会话管理": "Chat session management", + "控制台区域": "Console Area", + "数据管理和日志查看": "Data management and log viewing", + "数据看板": "Dashboard", + "系统数据统计": "System data statistics", + "令牌管理": "Token Management", + "API令牌管理": "API token management", + "使用日志": "Usage Logs", + "API使用记录": "API usage records", + "绘图日志": "Drawing Logs", + "绘图任务记录": "Drawing task records", + "任务日志": "Task Logs", + "系统任务记录": "System task records", + "个人中心区域": "Personal Center Area", + "用户个人功能": "User personal functions", + "钱包管理": "Wallet Management", + "余额充值管理": "Balance recharge management", + "个人设置": "Personal Settings", + "个人信息设置": "Personal information settings", + "管理员区域": "Administrator Area", + "系统管理功能": "System management functions", + "渠道管理": "Channel Management", + "API渠道配置": "API channel configuration", + "模型管理": "Model Management", + "AI模型配置": "AI model configuration", + "兑换码管理": "Redemption Code Management", + "兑换码生成管理": "Redemption code generation management", + "用户管理": "User Management", + "用户账户管理": "User account management", + "系统设置": "System Settings", + "系统参数配置": "System parameter configuration", + "边栏设置": "Sidebar Settings", + "您可以个性化设置侧边栏的要显示功能": "You can customize the sidebar functions to display", + "保存边栏设置": "Save Sidebar Settings", + "侧边栏设置保存成功": "Sidebar settings saved successfully", + "需要登录访问": "Require Login", + "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace" } diff --git a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx new file mode 100644 index 000000000..623accefb --- /dev/null +++ b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx @@ -0,0 +1,326 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useContext } from 'react'; +import { Button, Card, Col, Form, Row, Switch, Typography } from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { StatusContext } from '../../../context/Status'; + +const { Text } = Typography; + +export default function SettingsHeaderNavModules(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [statusState, statusDispatch] = useContext(StatusContext); + + // 顶栏模块管理状态 + const [headerNavModules, setHeaderNavModules] = useState({ + home: true, + console: true, + pricing: { + enabled: true, + requireAuth: false // 默认不需要登录鉴权 + }, + docs: true, + about: true, + }); + + // 处理顶栏模块配置变更 + function handleHeaderNavModuleChange(moduleKey) { + return (checked) => { + const newModules = { ...headerNavModules }; + if (moduleKey === 'pricing') { + // 对于pricing模块,只更新enabled属性 + newModules[moduleKey] = { + ...newModules[moduleKey], + enabled: checked + }; + } else { + newModules[moduleKey] = checked; + } + setHeaderNavModules(newModules); + }; + } + + // 处理模型广场权限控制变更 + function handlePricingAuthChange(checked) { + const newModules = { ...headerNavModules }; + newModules.pricing = { + ...newModules.pricing, + requireAuth: checked + }; + setHeaderNavModules(newModules); + } + + // 重置顶栏模块为默认配置 + function resetHeaderNavModules() { + const defaultModules = { + home: true, + console: true, + pricing: { + enabled: true, + requireAuth: false + }, + docs: true, + about: true, + }; + setHeaderNavModules(defaultModules); + showSuccess(t('已重置为默认配置')); + } + + // 保存配置 + async function onSubmit() { + setLoading(true); + try { + const res = await API.put('/api/option/', { + key: 'HeaderNavModules', + value: JSON.stringify(headerNavModules), + }); + const { success, message } = res.data; + if (success) { + showSuccess(t('保存成功')); + + // 立即更新StatusContext中的状态 + statusDispatch({ + type: 'set', + payload: { + ...statusState.status, + HeaderNavModules: JSON.stringify(headerNavModules) + } + }); + + // 刷新父组件状态 + if (props.refresh) { + await props.refresh(); + } + } else { + showError(message); + } + } catch (error) { + showError(t('保存失败,请重试')); + } finally { + setLoading(false); + } + } + + useEffect(() => { + // 从 props.options 中获取配置 + if (props.options && props.options.HeaderNavModules) { + try { + const modules = JSON.parse(props.options.HeaderNavModules); + + // 处理向后兼容性:如果pricing是boolean,转换为对象格式 + if (typeof modules.pricing === 'boolean') { + modules.pricing = { + enabled: modules.pricing, + requireAuth: false // 默认不需要登录鉴权 + }; + } + + setHeaderNavModules(modules); + } catch (error) { + // 使用默认配置 + const defaultModules = { + home: true, + console: true, + pricing: { + enabled: true, + requireAuth: false + }, + docs: true, + about: true, + }; + setHeaderNavModules(defaultModules); + } + } + }, [props.options]); + + // 模块配置数据 + const moduleConfigs = [ + { + key: 'home', + title: t('首页'), + description: t('用户主页,展示系统信息') + }, + { + key: 'console', + title: t('控制台'), + description: t('用户控制面板,管理账户') + }, + { + key: 'pricing', + title: t('模型广场'), + description: t('模型定价,需要登录访问'), + hasSubConfig: true // 标识该模块有子配置 + }, + { + key: 'docs', + title: t('文档'), + description: t('系统文档和帮助信息') + }, + { + key: 'about', + title: t('关于'), + description: t('关于系统的详细信息') + } + ]; + + return ( + + + + + {moduleConfigs.map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+ + {/* 为模型广场添加权限控制子开关 */} + {module.key === 'pricing' && (module.key === 'pricing' ? headerNavModules[module.key]?.enabled : headerNavModules[module.key]) && ( +
+
+
+
+ {t('需要登录访问')} +
+ + {t('开启后未登录用户无法访问模型广场')} + +
+
+ +
+
+
+ )} +
+ + ))} + +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx new file mode 100644 index 000000000..ec8485fba --- /dev/null +++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx @@ -0,0 +1,362 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Form, Button, Switch, Row, Col, Typography } from '@douyinfe/semi-ui'; +import { API, showSuccess, showError } from '../../../helpers'; +import { StatusContext } from '../../../context/Status'; + +const { Text } = Typography; + +export default function SettingsSidebarModulesAdmin(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [statusState, statusDispatch] = useContext(StatusContext); + + // 左侧边栏模块管理状态(管理员全局控制) + const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({ + chat: { + enabled: true, + playground: true, + chat: true + }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true + }, + personal: { + enabled: true, + topup: true, + personal: true + }, + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true + } + }); + + // 处理区域级别开关变更 + function handleSectionChange(sectionKey) { + return (checked) => { + const newModules = { + ...sidebarModulesAdmin, + [sectionKey]: { + ...sidebarModulesAdmin[sectionKey], + enabled: checked + } + }; + setSidebarModulesAdmin(newModules); + }; + } + + // 处理功能级别开关变更 + function handleModuleChange(sectionKey, moduleKey) { + return (checked) => { + const newModules = { + ...sidebarModulesAdmin, + [sectionKey]: { + ...sidebarModulesAdmin[sectionKey], + [moduleKey]: checked + } + }; + setSidebarModulesAdmin(newModules); + }; + } + + // 重置为默认配置 + function resetSidebarModules() { + const defaultModules = { + chat: { + enabled: true, + playground: true, + chat: true + }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true + }, + personal: { + enabled: true, + topup: true, + personal: true + }, + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true + } + }; + setSidebarModulesAdmin(defaultModules); + showSuccess(t('已重置为默认配置')); + } + + // 保存配置 + async function onSubmit() { + setLoading(true); + try { + const res = await API.put('/api/option/', { + key: 'SidebarModulesAdmin', + value: JSON.stringify(sidebarModulesAdmin), + }); + const { success, message } = res.data; + if (success) { + showSuccess(t('保存成功')); + + // 立即更新StatusContext中的状态 + statusDispatch({ + type: 'set', + payload: { + ...statusState.status, + SidebarModulesAdmin: JSON.stringify(sidebarModulesAdmin) + } + }); + + // 刷新父组件状态 + if (props.refresh) { + await props.refresh(); + } + } else { + showError(message); + } + } catch (error) { + showError(t('保存失败,请重试')); + } finally { + setLoading(false); + } + } + + useEffect(() => { + // 从 props.options 中获取配置 + if (props.options && props.options.SidebarModulesAdmin) { + try { + const modules = JSON.parse(props.options.SidebarModulesAdmin); + setSidebarModulesAdmin(modules); + } catch (error) { + // 使用默认配置 + const defaultModules = { + chat: { enabled: true, playground: true, chat: true }, + console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true }, + personal: { enabled: true, topup: true, personal: true }, + admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true } + }; + setSidebarModulesAdmin(defaultModules); + } + } + }, [props.options]); + + // 区域配置数据 + const sectionConfigs = [ + { + key: 'chat', + title: t('聊天区域'), + description: t('操练场和聊天功能'), + modules: [ + { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') }, + { key: 'chat', title: t('聊天'), description: t('聊天会话管理') } + ] + }, + { + key: 'console', + title: t('控制台区域'), + description: t('数据管理和日志查看'), + modules: [ + { key: 'detail', title: t('数据看板'), description: t('系统数据统计') }, + { key: 'token', title: t('令牌管理'), description: t('API令牌管理') }, + { key: 'log', title: t('使用日志'), description: t('API使用记录') }, + { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') }, + { key: 'task', title: t('任务日志'), description: t('系统任务记录') } + ] + }, + { + key: 'personal', + title: t('个人中心区域'), + description: t('用户个人功能'), + modules: [ + { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') }, + { key: 'personal', title: t('个人设置'), description: t('个人信息设置') } + ] + }, + { + key: 'admin', + title: t('管理员区域'), + description: t('系统管理功能'), + modules: [ + { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, + { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, + { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') }, + { key: 'user', title: t('用户管理'), description: t('用户账户管理') }, + { key: 'setting', title: t('系统设置'), description: t('系统参数配置') } + ] + } + ]; + + return ( + + + + {sectionConfigs.map((section) => ( +
+ {/* 区域标题和总开关 */} +
+
+
+ {section.title} +
+ + {section.description} + +
+ +
+ + {/* 功能模块网格 */} + + {section.modules.map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+
+ + ))} +
+
+ ))} + +
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx new file mode 100644 index 000000000..bb779c7ea --- /dev/null +++ b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx @@ -0,0 +1,404 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { useState, useEffect, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Switch, Typography, Row, Col, Avatar } from '@douyinfe/semi-ui'; +import { API, showSuccess, showError } from '../../../helpers'; +import { StatusContext } from '../../../context/Status'; +import { UserContext } from '../../../context/User'; +import { useUserPermissions } from '../../../hooks/common/useUserPermissions'; +import { useSidebar } from '../../../hooks/common/useSidebar'; +import { Settings } from 'lucide-react'; + +const { Text } = Typography; + +export default function SettingsSidebarModulesUser() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [statusState] = useContext(StatusContext); + + // 使用后端权限验证替代前端角色判断 + const { + permissions, + loading: permissionsLoading, + hasSidebarSettingsPermission, + isSidebarSectionAllowed, + isSidebarModuleAllowed, + } = useUserPermissions(); + + // 使用useSidebar钩子获取刷新方法 + const { refreshUserConfig } = useSidebar(); + + // 如果没有边栏设置权限,不显示此组件 + if (!permissionsLoading && !hasSidebarSettingsPermission()) { + return null; + } + + // 权限加载中,显示加载状态 + if (permissionsLoading) { + return null; + } + + // 根据用户权限生成默认配置 + const generateDefaultConfig = () => { + const defaultConfig = {}; + + // 聊天区域 - 所有用户都可以访问 + if (isSidebarSectionAllowed('chat')) { + defaultConfig.chat = { + enabled: true, + playground: isSidebarModuleAllowed('chat', 'playground'), + chat: isSidebarModuleAllowed('chat', 'chat') + }; + } + + // 控制台区域 - 所有用户都可以访问 + if (isSidebarSectionAllowed('console')) { + defaultConfig.console = { + enabled: true, + detail: isSidebarModuleAllowed('console', 'detail'), + token: isSidebarModuleAllowed('console', 'token'), + log: isSidebarModuleAllowed('console', 'log'), + midjourney: isSidebarModuleAllowed('console', 'midjourney'), + task: isSidebarModuleAllowed('console', 'task') + }; + } + + // 个人中心区域 - 所有用户都可以访问 + if (isSidebarSectionAllowed('personal')) { + defaultConfig.personal = { + enabled: true, + topup: isSidebarModuleAllowed('personal', 'topup'), + personal: isSidebarModuleAllowed('personal', 'personal') + }; + } + + // 管理员区域 - 只有管理员可以访问 + if (isSidebarSectionAllowed('admin')) { + defaultConfig.admin = { + enabled: true, + channel: isSidebarModuleAllowed('admin', 'channel'), + models: isSidebarModuleAllowed('admin', 'models'), + redemption: isSidebarModuleAllowed('admin', 'redemption'), + user: isSidebarModuleAllowed('admin', 'user'), + setting: isSidebarModuleAllowed('admin', 'setting') + }; + } + + return defaultConfig; + }; + + // 用户个人左侧边栏模块设置 + const [sidebarModulesUser, setSidebarModulesUser] = useState({}); + + // 管理员全局配置 + const [adminConfig, setAdminConfig] = useState(null); + + // 处理区域级别开关变更 + function handleSectionChange(sectionKey) { + return (checked) => { + const newModules = { + ...sidebarModulesUser, + [sectionKey]: { + ...sidebarModulesUser[sectionKey], + enabled: checked + } + }; + setSidebarModulesUser(newModules); + console.log('用户边栏区域配置变更:', sectionKey, checked, newModules); + }; + } + + // 处理功能级别开关变更 + function handleModuleChange(sectionKey, moduleKey) { + return (checked) => { + const newModules = { + ...sidebarModulesUser, + [sectionKey]: { + ...sidebarModulesUser[sectionKey], + [moduleKey]: checked + } + }; + setSidebarModulesUser(newModules); + console.log('用户边栏功能配置变更:', sectionKey, moduleKey, checked, newModules); + }; + } + + // 重置为默认配置(基于权限过滤) + function resetSidebarModules() { + const defaultConfig = generateDefaultConfig(); + setSidebarModulesUser(defaultConfig); + showSuccess(t('已重置为默认配置')); + console.log('用户边栏配置重置为默认:', defaultConfig); + } + + // 保存配置 + async function onSubmit() { + setLoading(true); + try { + console.log('保存用户边栏配置:', sidebarModulesUser); + const res = await API.put('/api/user/self', { + sidebar_modules: JSON.stringify(sidebarModulesUser), + }); + const { success, message } = res.data; + if (success) { + showSuccess(t('保存成功')); + console.log('用户边栏配置保存成功'); + + // 刷新useSidebar钩子中的用户配置,实现实时更新 + await refreshUserConfig(); + console.log('用户边栏配置已刷新,边栏将立即更新'); + } else { + showError(message); + console.error('用户边栏配置保存失败:', message); + } + } catch (error) { + showError(t('保存失败,请重试')); + console.error('用户边栏配置保存异常:', error); + } finally { + setLoading(false); + } + } + + // 统一的配置加载逻辑 + useEffect(() => { + const loadConfigs = async () => { + try { + // 获取管理员全局配置 + if (statusState?.status?.SidebarModulesAdmin) { + const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin); + setAdminConfig(adminConf); + console.log('加载管理员边栏配置:', adminConf); + } + + // 获取用户个人配置 + const userRes = await API.get('/api/user/self'); + if (userRes.data.success && userRes.data.data.sidebar_modules) { + let userConf; + // 检查sidebar_modules是字符串还是对象 + if (typeof userRes.data.data.sidebar_modules === 'string') { + userConf = JSON.parse(userRes.data.data.sidebar_modules); + } else { + userConf = userRes.data.data.sidebar_modules; + } + console.log('从API加载的用户配置:', userConf); + + // 确保用户配置也经过权限过滤 + const filteredUserConf = {}; + Object.keys(userConf).forEach(sectionKey => { + if (isSidebarSectionAllowed(sectionKey)) { + filteredUserConf[sectionKey] = { ...userConf[sectionKey] }; + // 过滤不允许的模块 + Object.keys(userConf[sectionKey]).forEach(moduleKey => { + if (moduleKey !== 'enabled' && !isSidebarModuleAllowed(sectionKey, moduleKey)) { + delete filteredUserConf[sectionKey][moduleKey]; + } + }); + } + }); + setSidebarModulesUser(filteredUserConf); + console.log('权限过滤后的用户配置:', filteredUserConf); + } else { + // 如果用户没有配置,使用权限过滤后的默认配置 + const defaultConfig = generateDefaultConfig(); + setSidebarModulesUser(defaultConfig); + console.log('用户无配置,使用默认配置:', defaultConfig); + } + } catch (error) { + console.error('加载边栏配置失败:', error); + // 出错时也使用默认配置 + const defaultConfig = generateDefaultConfig(); + setSidebarModulesUser(defaultConfig); + } + }; + + // 只有权限加载完成且有边栏设置权限时才加载配置 + if (!permissionsLoading && hasSidebarSettingsPermission()) { + loadConfigs(); + } + }, [statusState, permissionsLoading, hasSidebarSettingsPermission, isSidebarSectionAllowed, isSidebarModuleAllowed]); + + // 检查功能是否被管理员允许 + const isAllowedByAdmin = (sectionKey, moduleKey = null) => { + if (!adminConfig) return true; + + if (moduleKey) { + return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]; + } else { + return adminConfig[sectionKey]?.enabled; + } + }; + + // 区域配置数据(根据后端权限过滤) + const sectionConfigs = [ + { + key: 'chat', + title: t('聊天区域'), + description: t('操练场和聊天功能'), + modules: [ + { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') }, + { key: 'chat', title: t('聊天'), description: t('聊天会话管理') } + ] + }, + { + key: 'console', + title: t('控制台区域'), + description: t('数据管理和日志查看'), + modules: [ + { key: 'detail', title: t('数据看板'), description: t('系统数据统计') }, + { key: 'token', title: t('令牌管理'), description: t('API令牌管理') }, + { key: 'log', title: t('使用日志'), description: t('API使用记录') }, + { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') }, + { key: 'task', title: t('任务日志'), description: t('系统任务记录') } + ] + }, + { + key: 'personal', + title: t('个人中心区域'), + description: t('用户个人功能'), + modules: [ + { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') }, + { key: 'personal', title: t('个人设置'), description: t('个人信息设置') } + ] + }, + { + key: 'admin', + title: t('管理员区域'), + description: t('系统管理功能'), + modules: [ + { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, + { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, + { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') }, + { key: 'user', title: t('用户管理'), description: t('用户账户管理') }, + { key: 'setting', title: t('系统设置'), description: t('系统参数配置') } + ] + } + ].filter(section => { + // 使用后端权限验证替代前端角色判断 + return isSidebarSectionAllowed(section.key); + }).map(section => ({ + ...section, + modules: section.modules.filter(module => + isSidebarModuleAllowed(section.key, module.key) + ) + })).filter(section => + // 过滤掉没有可用模块的区域 + section.modules.length > 0 && isAllowedByAdmin(section.key) + ); + + return ( + + {/* 卡片头部 */} +
+ + + +
+ + {t('左侧边栏个人设置')} + +
+ {t('个性化设置左侧边栏的显示内容')} +
+
+
+ +
+ + {t('您可以个性化设置侧边栏的要显示功能')} + +
+ + {sectionConfigs.map((section) => ( +
+ {/* 区域标题和总开关 */} +
+
+
+ {section.title} +
+ + {section.description} + +
+ +
+ + {/* 功能模块网格 */} + + {section.modules.map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+
+ + ))} +
+
+ ))} + + {/* 底部按钮 */} +
+ + +
+
+ ); +} From 9127449a7a3b16c7abf8b75a3fdf25e84fc47950 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 31 Aug 2025 13:00:28 +0800 Subject: [PATCH 07/64] =?UTF-8?q?=F0=9F=90=9B=20fix(db):=20rename=20compos?= =?UTF-8?q?ite=20unique=20indexes=20to=20avoid=20drop/recreate=20on=20rest?= =?UTF-8?q?art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model: rename `uk_model_name` -> `uk_model_name_delete_at` (composite on `model_name` + `deleted_at`) - Vendor: rename `uk_vendor_name` -> `uk_vendor_name_delete_at` (composite on `name` + `deleted_at`) - Keep legacy cleanup in `model/main.go` to drop old index names (`uk_model_name`, `model_name`, `uk_vendor_name`, `name`) for compatibility. Result: idempotent GORM migrations and no unnecessary index churn on MySQL restarts. Files: - `model/model_meta.go` - `model/vendor_meta.go` --- model/main.go | 11 ++++++----- model/model_meta.go | 4 ++-- model/vendor_meta.go | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/model/main.go b/model/main.go index dbf271521..0fe9ceef9 100644 --- a/model/main.go +++ b/model/main.go @@ -270,9 +270,9 @@ func migrateDB() error { dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在) dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称 - //if !common.UsingPostgreSQL { - // return migrateDBFast() - //} + // 清理旧索引名(兼容历史),避免与新的复合唯一索引冲突 + // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围 + // 计划:该兼容逻辑将在后续几个版本中移除 err := DB.AutoMigrate( &Channel{}, &Token{}, @@ -299,8 +299,9 @@ func migrateDB() error { } func migrateDBFast() error { - // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 - // 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引冲突 + // 清理旧索引名(兼容历史),允许软删除后重新插入同名记录 + // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围 + // 计划:该兼容逻辑将在后续几个版本中移除 dropIndexIfExists("models", "uk_model_name") dropIndexIfExists("models", "model_name") diff --git a/model/model_meta.go b/model/model_meta.go index b7602b0ec..e9582e441 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -21,7 +21,7 @@ type BoundChannel struct { type Model struct { Id int `json:"id"` - ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` Description string `json:"description,omitempty" gorm:"type:text"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` @@ -30,7 +30,7 @@ type Model struct { Status int `json:"status" gorm:"default:1"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` diff --git a/model/vendor_meta.go b/model/vendor_meta.go index 88439f249..20deaea9b 100644 --- a/model/vendor_meta.go +++ b/model/vendor_meta.go @@ -14,13 +14,13 @@ import ( type Vendor struct { Id int `json:"id"` - Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"` Description string `json:"description,omitempty" gorm:"type:text"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Status int `json:"status" gorm:"default:1"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"` } // Insert 创建新的供应商记录 From cdef6da9e999381c06091862c9f1f1042982775b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 31 Aug 2025 13:08:34 +0800 Subject: [PATCH 08/64] =?UTF-8?q?=F0=9F=8E=A8=20style(go):=20format=20enti?= =?UTF-8?q?re=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply canonical Go formatting to all .go files - No functional changes; whitespace/import/struct layout only - Improves consistency, reduces diff noise, and aligns with standard tooling --- common/utils.go | 48 +-- controller/ratio_config.go | 32 +- controller/task_video.go | 2 +- controller/uptime_kuma.go | 30 +- dto/ratio_sync.go | 36 +- middleware/stats.go | 6 +- relay/channel/mokaai/constants.go | 2 +- setting/console_setting/config.go | 40 +- setting/console_setting/validation.go | 484 ++++++++++++------------- setting/ratio_setting/expose_ratio.go | 8 +- setting/ratio_setting/exposed_cache.go | 68 ++-- 11 files changed, 378 insertions(+), 378 deletions(-) diff --git a/common/utils.go b/common/utils.go index 17aecd950..f82538138 100644 --- a/common/utils.go +++ b/common/utils.go @@ -257,32 +257,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64 if err != nil { return 0, errors.Wrap(err, "failed to get audio duration") } - durationStr := string(bytes.TrimSpace(output)) - if durationStr == "N/A" { - // Create a temporary output file name - tmpFp, err := os.CreateTemp("", "audio-*"+ext) - if err != nil { - return 0, errors.Wrap(err, "failed to create temporary file") - } - tmpName := tmpFp.Name() - // Close immediately so ffmpeg can open the file on Windows. - _ = tmpFp.Close() - defer os.Remove(tmpName) + durationStr := string(bytes.TrimSpace(output)) + if durationStr == "N/A" { + // Create a temporary output file name + tmpFp, err := os.CreateTemp("", "audio-*"+ext) + if err != nil { + return 0, errors.Wrap(err, "failed to create temporary file") + } + tmpName := tmpFp.Name() + // Close immediately so ffmpeg can open the file on Windows. + _ = tmpFp.Close() + defer os.Remove(tmpName) - // ffmpeg -y -i filename -vcodec copy -acodec copy - ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName) - if err := ffmpegCmd.Run(); err != nil { - return 0, errors.Wrap(err, "failed to run ffmpeg") - } + // ffmpeg -y -i filename -vcodec copy -acodec copy + ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName) + if err := ffmpegCmd.Run(); err != nil { + return 0, errors.Wrap(err, "failed to run ffmpeg") + } - // Recalculate the duration of the new file - c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName) - output, err := c.Output() - if err != nil { - return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg") - } - durationStr = string(bytes.TrimSpace(output)) - } + // Recalculate the duration of the new file + c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName) + output, err := c.Output() + if err != nil { + return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg") + } + durationStr = string(bytes.TrimSpace(output)) + } return strconv.ParseFloat(durationStr, 64) } diff --git a/controller/ratio_config.go b/controller/ratio_config.go index 6ddc3d9ef..0cb4aa73b 100644 --- a/controller/ratio_config.go +++ b/controller/ratio_config.go @@ -1,24 +1,24 @@ package controller import ( - "net/http" - "one-api/setting/ratio_setting" + "net/http" + "one-api/setting/ratio_setting" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) func GetRatioConfig(c *gin.Context) { - if !ratio_setting.IsExposeRatioEnabled() { - c.JSON(http.StatusForbidden, gin.H{ - "success": false, - "message": "倍率配置接口未启用", - }) - return - } + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": ratio_setting.GetExposedData(), - }) -} \ No newline at end of file + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} diff --git a/controller/task_video.go b/controller/task_video.go index ffb6728ba..84b78f901 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -113,7 +113,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.StartTime = now } case model.TaskStatusSuccess: - task.Progress = "100%" + task.Progress = "100%" if task.FinishTime == 0 { task.FinishTime = now } diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go index 05d6297eb..41b9695c3 100644 --- a/controller/uptime_kuma.go +++ b/controller/uptime_kuma.go @@ -31,7 +31,7 @@ type Monitor struct { type UptimeGroupResult struct { CategoryName string `json:"categoryName"` - Monitors []Monitor `json:"monitors"` + Monitors []Monitor `json:"monitors"` } func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { @@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st url, _ := groupConfig["url"].(string) slug, _ := groupConfig["slug"].(string) categoryName, _ := groupConfig["categoryName"].(string) - + result := UptimeGroupResult{ CategoryName: categoryName, - Monitors: []Monitor{}, + Monitors: []Monitor{}, } - + if url == "" || slug == "" { return result } baseURL := strings.TrimSuffix(url, "/") - + var statusData struct { PublicGroupList []struct { - ID int `json:"id"` - Name string `json:"name"` + ID int `json:"id"` + Name string `json:"name"` MonitorList []struct { ID int `json:"id"` Name string `json:"name"` } `json:"monitorList"` } `json:"publicGroupList"` } - + var heartbeatData struct { HeartbeatList map[string][]struct { Status int `json:"status"` @@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st } g, gCtx := errgroup.WithContext(ctx) - g.Go(func() error { - return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) }) - g.Go(func() error { - return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) }) if g.Wait() != nil { @@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) { client := &http.Client{Timeout: httpTimeout} results := make([]UptimeGroupResult, len(groups)) - + g, gCtx := errgroup.WithContext(ctx) for i, group := range groups { i, group := i, group @@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) { return nil }) } - + g.Wait() c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) -} \ No newline at end of file +} diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go index 6315f31ae..d6bbf68e1 100644 --- a/dto/ratio_sync.go +++ b/dto/ratio_sync.go @@ -1,23 +1,23 @@ package dto 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"` + ID int `json:"id,omitempty"` + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` } type UpstreamRequest struct { - ChannelIDs []int64 `json:"channel_ids"` - Upstreams []UpstreamDTO `json:"upstreams"` - Timeout int `json:"timeout"` + ChannelIDs []int64 `json:"channel_ids"` + Upstreams []UpstreamDTO `json:"upstreams"` + Timeout int `json:"timeout"` } // TestResult 上游测试连通性结果 type TestResult struct { - Name string `json:"name"` - Status string `json:"status"` - Error string `json:"error,omitempty"` + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` } // DifferenceItem 差异项 @@ -25,14 +25,14 @@ type TestResult struct { // Upstreams 为各渠道的上游值,具体数值 / "same" / nil type DifferenceItem struct { - Current interface{} `json:"current"` - Upstreams map[string]interface{} `json:"upstreams"` - Confidence map[string]bool `json:"confidence"` + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` + Confidence map[string]bool `json:"confidence"` } type SyncableChannel struct { - ID int `json:"id"` - Name string `json:"name"` - BaseURL string `json:"base_url"` - Status int `json:"status"` -} \ No newline at end of file + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} diff --git a/middleware/stats.go b/middleware/stats.go index 1c97983f7..e49e56991 100644 --- a/middleware/stats.go +++ b/middleware/stats.go @@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 增加活跃连接数 atomic.AddInt64(&globalStats.activeConnections, 1) - + // 确保在请求结束时减少连接数 defer func() { atomic.AddInt64(&globalStats.activeConnections, -1) }() - + c.Next() } } @@ -38,4 +38,4 @@ func GetStats() StatsInfo { return StatsInfo{ ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), } -} \ No newline at end of file +} diff --git a/relay/channel/mokaai/constants.go b/relay/channel/mokaai/constants.go index 415d83b7f..385a0876b 100644 --- a/relay/channel/mokaai/constants.go +++ b/relay/channel/mokaai/constants.go @@ -6,4 +6,4 @@ var ModelList = []string{ "m3e-small", } -var ChannelName = "mokaai" \ No newline at end of file +var ChannelName = "mokaai" diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go index 6327e5584..8cfcd0ed6 100644 --- a/setting/console_setting/config.go +++ b/setting/console_setting/config.go @@ -3,37 +3,37 @@ package console_setting import "one-api/setting/config" type ConsoleSetting struct { - ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) - UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) - Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) - FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) - ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 - UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 - AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 - FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 } // 默认配置 var defaultConsoleSetting = ConsoleSetting{ - ApiInfo: "", - UptimeKumaGroups: "", - Announcements: "", - FAQ: "", - ApiInfoEnabled: true, - UptimeKumaEnabled: true, - AnnouncementsEnabled: true, - FAQEnabled: true, + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, } // 全局实例 var consoleSetting = defaultConsoleSetting func init() { - // 注册到全局配置管理器,键名为 console_setting - config.GlobalConfig.Register("console_setting", &consoleSetting) + // 注册到全局配置管理器,键名为 console_setting + config.GlobalConfig.Register("console_setting", &consoleSetting) } // GetConsoleSetting 获取 ConsoleSetting 配置实例 func GetConsoleSetting() *ConsoleSetting { - return &consoleSetting -} \ No newline at end of file + return &consoleSetting +} diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index fda6453df..529457761 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -1,304 +1,304 @@ package console_setting import ( - "encoding/json" - "fmt" - "net/url" - "regexp" - "strings" - "time" - "sort" + "encoding/json" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "time" ) var ( - urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) - dangerousChars = []string{" 50 { - return fmt.Errorf("API信息数量不能超过50个") - } + if len(apiInfoList) > 50 { + return fmt.Errorf("API信息数量不能超过50个") + } - for i, apiInfo := range apiInfoList { - urlStr, ok := apiInfo["url"].(string) - if !ok || urlStr == "" { - return fmt.Errorf("第%d个API信息缺少URL字段", i+1) - } - route, ok := apiInfo["route"].(string) - if !ok || route == "" { - return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) - } - description, ok := apiInfo["description"].(string) - if !ok || description == "" { - return fmt.Errorf("第%d个API信息缺少说明字段", i+1) - } - color, ok := apiInfo["color"].(string) - if !ok || color == "" { - return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) - } + for i, apiInfo := range apiInfoList { + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } - if err := validateURL(urlStr, i+1, "API信息"); err != nil { - return err - } + if err := validateURL(urlStr, i+1, "API信息"); err != nil { + return err + } - if len(urlStr) > 500 { - return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) - } - if len(route) > 100 { - return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) - } - if len(description) > 200 { - return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) - } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } - if !validColors[color] { - return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) - } + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } - if err := checkDangerousContent(description, i+1, "API信息"); err != nil { - return err - } - if err := checkDangerousContent(route, i+1, "API信息"); err != nil { - return err - } - } - return nil + if err := checkDangerousContent(description, i+1, "API信息"); err != nil { + return err + } + if err := checkDangerousContent(route, i+1, "API信息"); err != nil { + return err + } + } + return nil } func GetApiInfo() []map[string]interface{} { - return getJSONList(GetConsoleSetting().ApiInfo) + return getJSONList(GetConsoleSetting().ApiInfo) } func validateAnnouncements(announcementsStr string) error { - list, err := parseJSONArray(announcementsStr, "系统公告") - if err != nil { - return err - } - if len(list) > 100 { - return fmt.Errorf("系统公告数量不能超过100个") - } - validTypes := map[string]bool{ - "default": true, "ongoing": true, "success": true, "warning": true, "error": true, - } - for i, ann := range list { - content, ok := ann["content"].(string) - if !ok || content == "" { - return fmt.Errorf("第%d个公告缺少内容字段", i+1) - } - publishDateAny, exists := ann["publishDate"] - if !exists { - return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) - } - publishDateStr, ok := publishDateAny.(string) - if !ok || publishDateStr == "" { - return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) - } - if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { - return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) - } - if t, exists := ann["type"]; exists { - if typeStr, ok := t.(string); ok { - if !validTypes[typeStr] { - return fmt.Errorf("第%d个公告的类型值不合法", i+1) - } - } - } - if len(content) > 500 { - return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) - } - if extra, exists := ann["extra"]; exists { - if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { - return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) - } - } - } - return nil + list, err := parseJSONArray(announcementsStr, "系统公告") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("系统公告数量不能超过100个") + } + validTypes := map[string]bool{ + "default": true, "ongoing": true, "success": true, "warning": true, "error": true, + } + for i, ann := range list { + content, ok := ann["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个公告缺少内容字段", i+1) + } + publishDateAny, exists := ann["publishDate"] + if !exists { + return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) + } + publishDateStr, ok := publishDateAny.(string) + if !ok || publishDateStr == "" { + return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) + } + if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { + return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) + } + if t, exists := ann["type"]; exists { + if typeStr, ok := t.(string); ok { + if !validTypes[typeStr] { + return fmt.Errorf("第%d个公告的类型值不合法", i+1) + } + } + } + if len(content) > 500 { + return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) + } + if extra, exists := ann["extra"]; exists { + if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { + return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + } + } + } + return nil } func validateFAQ(faqStr string) error { - list, err := parseJSONArray(faqStr, "FAQ信息") - if err != nil { - return err - } - if len(list) > 100 { - return fmt.Errorf("FAQ数量不能超过100个") - } - for i, faq := range list { - question, ok := faq["question"].(string) - if !ok || question == "" { - return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) - } - answer, ok := faq["answer"].(string) - if !ok || answer == "" { - return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) - } - if len(question) > 200 { - return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) - } - if len(answer) > 1000 { - return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) - } - } - return nil + list, err := parseJSONArray(faqStr, "FAQ信息") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("FAQ数量不能超过100个") + } + for i, faq := range list { + question, ok := faq["question"].(string) + if !ok || question == "" { + return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) + } + answer, ok := faq["answer"].(string) + if !ok || answer == "" { + return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) + } + if len(question) > 200 { + return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) + } + if len(answer) > 1000 { + return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) + } + } + return nil } func getPublishTime(item map[string]interface{}) time.Time { - if v, ok := item["publishDate"]; ok { - if s, ok2 := v.(string); ok2 { - if t, err := time.Parse(time.RFC3339, s); err == nil { - return t - } - } - } - return time.Time{} + if v, ok := item["publishDate"]; ok { + if s, ok2 := v.(string); ok2 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} } func GetAnnouncements() []map[string]interface{} { - list := getJSONList(GetConsoleSetting().Announcements) - sort.SliceStable(list, func(i, j int) bool { - return getPublishTime(list[i]).After(getPublishTime(list[j])) - }) - return list + list := getJSONList(GetConsoleSetting().Announcements) + sort.SliceStable(list, func(i, j int) bool { + return getPublishTime(list[i]).After(getPublishTime(list[j])) + }) + return list } func GetFAQ() []map[string]interface{} { - return getJSONList(GetConsoleSetting().FAQ) + return getJSONList(GetConsoleSetting().FAQ) } func validateUptimeKumaGroups(groupsStr string) error { - groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") - if err != nil { - return err - } + groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") + if err != nil { + return err + } - if len(groups) > 20 { - return fmt.Errorf("Uptime Kuma分组数量不能超过20个") - } + if len(groups) > 20 { + return fmt.Errorf("Uptime Kuma分组数量不能超过20个") + } - nameSet := make(map[string]bool) + nameSet := make(map[string]bool) - for i, group := range groups { - categoryName, ok := group["categoryName"].(string) - if !ok || categoryName == "" { - return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) - } - if nameSet[categoryName] { - return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) - } - nameSet[categoryName] = true - urlStr, ok := group["url"].(string) - if !ok || urlStr == "" { - return fmt.Errorf("第%d个分组缺少URL字段", i+1) - } - slug, ok := group["slug"].(string) - if !ok || slug == "" { - return fmt.Errorf("第%d个分组缺少Slug字段", i+1) - } - description, ok := group["description"].(string) - if !ok { - description = "" - } + for i, group := range groups { + categoryName, ok := group["categoryName"].(string) + if !ok || categoryName == "" { + return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) + } + if nameSet[categoryName] { + return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) + } + nameSet[categoryName] = true + urlStr, ok := group["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个分组缺少URL字段", i+1) + } + slug, ok := group["slug"].(string) + if !ok || slug == "" { + return fmt.Errorf("第%d个分组缺少Slug字段", i+1) + } + description, ok := group["description"].(string) + if !ok { + description = "" + } - if err := validateURL(urlStr, i+1, "分组"); err != nil { - return err - } + if err := validateURL(urlStr, i+1, "分组"); err != nil { + return err + } - if len(categoryName) > 50 { - return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) - } - if len(urlStr) > 500 { - return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) - } - if len(slug) > 100 { - return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) - } - if len(description) > 200 { - return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) - } + if len(categoryName) > 50 { + return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) + } + if len(slug) > 100 { + return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) + } - if !slugRegex.MatchString(slug) { - return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) - } + if !slugRegex.MatchString(slug) { + return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) + } - if err := checkDangerousContent(description, i+1, "分组"); err != nil { - return err - } - if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { - return err - } - } - return nil + if err := checkDangerousContent(description, i+1, "分组"); err != nil { + return err + } + if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { + return err + } + } + return nil } func GetUptimeKumaGroups() []map[string]interface{} { - return getJSONList(GetConsoleSetting().UptimeKumaGroups) -} \ No newline at end of file + return getJSONList(GetConsoleSetting().UptimeKumaGroups) +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go index 8fca0bcb0..783d9778e 100644 --- a/setting/ratio_setting/expose_ratio.go +++ b/setting/ratio_setting/expose_ratio.go @@ -5,13 +5,13 @@ import "sync/atomic" var exposeRatioEnabled atomic.Bool func init() { - exposeRatioEnabled.Store(false) + exposeRatioEnabled.Store(false) } func SetExposeRatioEnabled(enabled bool) { - exposeRatioEnabled.Store(enabled) + exposeRatioEnabled.Store(enabled) } func IsExposeRatioEnabled() bool { - return exposeRatioEnabled.Load() -} \ No newline at end of file + return exposeRatioEnabled.Load() +} diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index 9e5b6c300..2fe2cd09b 100644 --- a/setting/ratio_setting/exposed_cache.go +++ b/setting/ratio_setting/exposed_cache.go @@ -1,55 +1,55 @@ package ratio_setting import ( - "sync" - "sync/atomic" - "time" + "sync" + "sync/atomic" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) const exposedDataTTL = 30 * time.Second type exposedCache struct { - data gin.H - expiresAt time.Time + data gin.H + expiresAt time.Time } var ( - exposedData atomic.Value - rebuildMu sync.Mutex + exposedData atomic.Value + rebuildMu sync.Mutex ) func InvalidateExposedDataCache() { - exposedData.Store((*exposedCache)(nil)) + exposedData.Store((*exposedCache)(nil)) } func cloneGinH(src gin.H) gin.H { - dst := make(gin.H, len(src)) - for k, v := range src { - dst[k] = v - } - return dst + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst } func GetExposedData() gin.H { - if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { - return cloneGinH(c.data) - } - rebuildMu.Lock() - defer rebuildMu.Unlock() - if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { - return cloneGinH(c.data) - } - newData := gin.H{ - "model_ratio": GetModelRatioCopy(), - "completion_ratio": GetCompletionRatioCopy(), - "cache_ratio": GetCacheRatioCopy(), - "model_price": GetModelPriceCopy(), - } - exposedData.Store(&exposedCache{ - data: newData, - expiresAt: time.Now().Add(exposedDataTTL), - }) - return cloneGinH(newData) -} \ No newline at end of file + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} From 274da13a19b5d2ad7908877cb719b9c2bc123ecc Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sun, 31 Aug 2025 14:28:02 +0800 Subject: [PATCH 09/64] fix: add OptionMap RLock to GetStatus() --- controller/misc.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/controller/misc.go b/controller/misc.go index dfe3091b5..897dad254 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -39,6 +39,8 @@ func TestStatus(c *gin.Context) { func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() data := gin.H{ "version": common.Version, @@ -90,8 +92,8 @@ func GetStatus(c *gin.Context) { "faq_enabled": cs.FAQEnabled, // 模块管理配置 - "HeaderNavModules": common.OptionMap["HeaderNavModules"], - "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + "HeaderNavModules": common.OptionMap["HeaderNavModules"], + "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, From 3a3be213668a3b83b5118530361adc6889668c2e Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sun, 31 Aug 2025 14:40:35 +0800 Subject: [PATCH 10/64] =?UTF-8?q?fix(user):=20UpdateSelf=20=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E6=9D=83=E9=99=90=E6=A3=80=E6=9F=A5=E5=92=8C=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/user.go | 59 +++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/controller/user.go b/controller/user.go index 91fdce4cf..a65eb5aa2 100644 --- a/controller/user.go +++ b/controller/user.go @@ -444,26 +444,26 @@ func GetSelf(c *gin.Context) { // 构建响应数据,包含用户信息和权限 responseData := map[string]interface{}{ - "id": user.Id, - "username": user.Username, - "display_name": user.DisplayName, - "role": user.Role, - "status": user.Status, - "email": user.Email, - "group": user.Group, - "quota": user.Quota, - "used_quota": user.UsedQuota, - "request_count": user.RequestCount, - "aff_code": user.AffCode, - "aff_count": user.AffCount, - "aff_quota": user.AffQuota, + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "email": user.Email, + "group": user.Group, + "quota": user.Quota, + "used_quota": user.UsedQuota, + "request_count": user.RequestCount, + "aff_code": user.AffCode, + "aff_count": user.AffCount, + "aff_quota": user.AffQuota, "aff_history_quota": user.AffHistoryQuota, - "inviter_id": user.InviterId, - "linux_do_id": user.LinuxDOId, - "setting": user.Setting, - "stripe_customer": user.StripeCustomer, - "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 - "permissions": permissions, // 新增权限字段 + "inviter_id": user.InviterId, + "linux_do_id": user.LinuxDOId, + "setting": user.Setting, + "stripe_customer": user.StripeCustomer, + "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 + "permissions": permissions, // 新增权限字段 } c.JSON(http.StatusOK, gin.H{ @@ -564,8 +564,6 @@ func generateDefaultSidebarConfig(userRole int) string { return string(configBytes) } - - func GetUserModels(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -665,6 +663,25 @@ func UpdateSelf(c *gin.Context) { // 检查是否是sidebar_modules更新请求 if sidebarModules, exists := requestData["sidebar_modules"]; exists { userId := c.GetInt("id") + userRole := c.GetInt("role") + // 注意超级管理员目前在 calculateUserPermissions 中被设置为无权更新设置 + perms := calculateUserPermissions(userRole) + allow, ok := perms["sidebar_settings"] + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新设置", + }) + return + } + allowBool, ok := allow.(bool) + if !ok || !allowBool { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新设置", + }) + return + } user, err := model.GetUserById(userId, false) if err != nil { common.ApiError(c, err) From c8acbdb36330559688acf3d818b67531368f57ab Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sun, 31 Aug 2025 14:54:47 +0800 Subject: [PATCH 11/64] fix: update sidebar modules role check --- controller/user.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/controller/user.go b/controller/user.go index a65eb5aa2..3110bf1b0 100644 --- a/controller/user.go +++ b/controller/user.go @@ -664,18 +664,7 @@ func UpdateSelf(c *gin.Context) { if sidebarModules, exists := requestData["sidebar_modules"]; exists { userId := c.GetInt("id") userRole := c.GetInt("role") - // 注意超级管理员目前在 calculateUserPermissions 中被设置为无权更新设置 - perms := calculateUserPermissions(userRole) - allow, ok := perms["sidebar_settings"] - if !ok { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权更新设置", - }) - return - } - allowBool, ok := allow.(bool) - if !ok || !allowBool { + if userRole != common.RoleRootUser && userRole != common.RoleAdminUser { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无权更新设置", From c6f53e4cc8f72d78da4417e597dd82bbe5fb1afe Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sun, 31 Aug 2025 14:59:55 +0800 Subject: [PATCH 12/64] fix: revert 3a3be21 --- controller/user.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/controller/user.go b/controller/user.go index 3110bf1b0..0b9fccf2a 100644 --- a/controller/user.go +++ b/controller/user.go @@ -663,14 +663,6 @@ func UpdateSelf(c *gin.Context) { // 检查是否是sidebar_modules更新请求 if sidebarModules, exists := requestData["sidebar_modules"]; exists { userId := c.GetInt("id") - userRole := c.GetInt("role") - if userRole != common.RoleRootUser && userRole != common.RoleAdminUser { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无权更新设置", - }) - return - } user, err := model.GetUserById(userId, false) if err != nil { common.ApiError(c, err) From 247e029159c933adbc3d9dcdd7104863429a8336 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 1 Sep 2025 15:57:23 +0800 Subject: [PATCH 13/64] feat: bark notification #1699 --- controller/user.go | 35 ++++++++- dto/user_settings.go | 2 + service/quota.go | 23 +++++- service/user_notify.go | 74 +++++++++++++++++++ .../components/settings/PersonalSetting.jsx | 3 + .../personal/cards/NotificationSettings.jsx | 53 +++++++++++++ 6 files changed, 187 insertions(+), 3 deletions(-) diff --git a/controller/user.go b/controller/user.go index 0b9fccf2a..982329cec 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1097,6 +1097,7 @@ type UpdateUserSettingRequest struct { WebhookUrl string `json:"webhook_url,omitempty"` WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` + BarkUrl string `json:"bark_url,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` } @@ -1112,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的预警类型", @@ -1160,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是Bark类型,验证Bark URL + if req.QuotaWarningType == dto.NotifyTypeBark { + if req.BarkUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.BarkUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Bark推送URL", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL必须以http://或https://开头", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1188,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) { settings.NotificationEmail = req.NotificationEmail } + // 如果是Bark类型,添加Bark URL到设置中 + if req.QuotaWarningType == dto.NotifyTypeBark { + settings.BarkUrl = req.BarkUrl + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/dto/user_settings.go b/dto/user_settings.go index 56beb7118..89dd926ef 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -6,6 +6,7 @@ type UserSetting struct { WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址 WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 + BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 @@ -14,4 +15,5 @@ type UserSetting struct { var ( NotifyTypeEmail = "email" // Email 邮件 NotifyTypeWebhook = "webhook" // Webhook + NotifyTypeBark = "bark" // Bark 推送 ) diff --git a/service/quota.go b/service/quota.go index 8f65bd20e..e078a1ad1 100644 --- a/service/quota.go +++ b/service/quota.go @@ -535,8 +535,27 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon if quotaTooLow { prompt := "您的额度即将用尽" topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress) - content := "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" - err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink})) + + // 根据通知方式生成不同的内容格式 + var content string + var values []interface{} + + notifyType := userSetting.NotifyType + if notifyType == "" { + notifyType = dto.NotifyTypeEmail + } + + if notifyType == dto.NotifyTypeBark { + // Bark推送使用简短文本,不支持HTML + content = "{{value}},剩余额度:{{value}},请及时充值" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else { + // 默认内容格式,适用于Email和Webhook + content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} + } + + err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)) if err != nil { common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error())) } diff --git a/service/user_notify.go b/service/user_notify.go index 7c864a1b1..c4a3ea91f 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -2,9 +2,12 @@ package service import ( "fmt" + "net/http" + "net/url" "one-api/common" "one-api/dto" "one-api/model" + "one-api/setting" "strings" ) @@ -51,6 +54,13 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data // 获取 webhook secret webhookSecret := userSetting.WebhookSecret return SendWebhookNotify(webhookURLStr, webhookSecret, data) + case dto.NotifyTypeBark: + barkURL := userSetting.BarkUrl + if barkURL == "" { + common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId)) + return nil + } + return sendBarkNotify(barkURL, data) } return nil } @@ -64,3 +74,67 @@ func sendEmailNotify(userEmail string, data dto.Notify) error { } return common.SendEmail(data.Title, userEmail, content) } + +func sendBarkNotify(barkURL string, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 替换模板变量 + finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title)) + finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content)) + + // 发送GET请求到Bark + var req *http.Request + var resp *http.Response + var err error + + if setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: setting.WorkerValidKey, + Method: http.MethodGet, + Headers: map[string]string{ + "User-Agent": "OneAPI-Bark-Notify/1.0", + }, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send bark request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } else { + // 直接发送请求 + req, err = http.NewRequest(http.MethodGet, finalURL, nil) + if err != nil { + return fmt.Errorf("failed to create bark request: %v", err) + } + + // 设置User-Agent + req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send bark request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 07f2b8c48..422cf0e88 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -67,6 +67,7 @@ const PersonalSetting = () => { webhookUrl: '', webhookSecret: '', notificationEmail: '', + barkUrl: '', acceptUnsetModelRatioModel: false, recordIpLog: false, }); @@ -108,6 +109,7 @@ const PersonalSetting = () => { webhookUrl: settings.webhook_url || '', webhookSecret: settings.webhook_secret || '', notificationEmail: settings.notification_email || '', + barkUrl: settings.bark_url || '', acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -285,6 +287,7 @@ const PersonalSetting = () => { webhook_url: notificationSettings.webhookUrl, webhook_secret: notificationSettings.webhookSecret, notification_email: notificationSettings.notificationEmail, + bark_url: notificationSettings.barkUrl, accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index d76706c55..c7a31bd52 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -347,6 +347,7 @@ const NotificationSettings = ({ > {t('邮件通知')} {t('Webhook通知')} + {t('Bark通知')} )} + + {/* Bark推送设置 */} + {notificationSettings.warningType === 'bark' && ( + <> + handleFormChange('barkUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'bark', + message: t('请输入Bark推送URL'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Bark推送URL必须以http://或https://开头'), + }, + ]} + /> + +
+
+ {t('模板示例')} +
+
+ https://api.day.app/yourkey/{'{{title}}'}/{'{{content}}'}?sound=alarm&group=quota +
+
+
{'title'}: {t('通知标题')}
+
{'content'}: {t('通知内容')}
+
+ {t('更多参数请参考')}{' '} + + Bark 官方文档 + +
+
+
+ + )} From e174861b961d0dec0e8631d8132ce6d6916b14f6 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 1 Sep 2025 16:23:29 +0800 Subject: [PATCH 14/64] feat: add channel remark #1710 --- model/channel.go | 1 + .../table/channels/ChannelsColumnDefs.jsx | 35 +++++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 8 +++++ 3 files changed, 44 insertions(+) diff --git a/model/channel.go b/model/channel.go index f8e1cccc4..39cbc0227 100644 --- a/model/channel.go +++ b/model/channel.go @@ -47,6 +47,7 @@ type Channel struct { Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` HeaderOverride *string `json:"header_override" gorm:"type:text"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 2230877c5..56f745c20 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -35,6 +35,8 @@ import { renderQuota, getChannelIcon, renderQuotaWithAmount, + showSuccess, + showError, } from '../../../helpers'; import { CHANNEL_OPTIONS } from '../../../constants'; import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons'; @@ -216,6 +218,39 @@ export const getChannelsColumns = ({ key: COLUMN_KEYS.NAME, title: t('名称'), dataIndex: 'name', + render: (text, record, index) => { + if (record.remark && record.remark.trim() !== '') { + return ( + +
{record.remark}
+ + + } + trigger='hover' + position='topLeft' + > + {text} +
+ ); + } + return text; + }, }, { key: COLUMN_KEYS.GROUP, diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index de2c70213..7a86fa114 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1993,6 +1993,14 @@ const EditChannelModal = (props) => { showClear onChange={(value) => handleInputChange('tag', value)} /> + handleInputChange('remark', value)} + /> From 1f111a163abc0828cdd712139251fc5070cc46a7 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 1 Sep 2025 23:43:39 +0800 Subject: [PATCH 15/64] =?UTF-8?q?=E2=9C=A8=20feat(ratio-sync,=20ui):=20add?= =?UTF-8?q?=20built=E2=80=91in=20=E2=80=9COfficial=20Ratio=20Preset?= =?UTF-8?q?=E2=80=9D=20and=20harden=20upstream=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (controller/ratio_sync.go): - Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io) - Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config) - Harden HTTP client: - IPv4‑first with IPv6 fallback for github.io - Add ResponseHeaderTimeout - 3 attempts with exponential backoff (200/400/800ms) - Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader) - Robust parsing: support type1 ratio_config map and type2 pricing list - Use net.SplitHostPort for host parsing - Use float tolerance in differences comparison to avoid false mismatches - Remove unused code (tryDirect) and improve warnings Frontend: - UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json - ChannelSelectorModal.jsx: - Pin the official source at the top of the list - Show a green “官方” tag next to the status - Refactor status renderer to accept the full record Notes: - Backward compatible; no API surface changes - Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json --- controller/ratio_sync.go | 91 ++++- web/src/App.jsx | 5 +- .../layout/HeaderBar/Navigation.jsx | 8 +- web/src/components/layout/SiderBar.jsx | 284 ++++++++-------- .../settings/ChannelSelectorModal.jsx | 70 ++-- .../components/settings/PersonalSetting.jsx | 4 - .../personal/cards/NotificationSettings.jsx | 317 +++++++++++------- .../table/channels/ChannelsColumnDefs.jsx | 13 +- web/src/hooks/common/useHeaderBar.js | 2 +- web/src/hooks/common/useNavigation.js | 115 ++++--- web/src/hooks/common/useSidebar.js | 51 +-- web/src/hooks/common/useUserPermissions.js | 39 ++- .../Operation/SettingsHeaderNavModules.jsx | 204 ++++++----- .../Operation/SettingsSidebarModulesAdmin.jsx | 242 ++++++++----- .../Personal/SettingsSidebarModulesUser.jsx | 251 ++++++++------ .../Setting/Ratio/ModelRationNotSetEditor.jsx | 4 +- .../Ratio/ModelSettingsVisualEditor.jsx | 4 +- .../pages/Setting/Ratio/UpstreamRatioSync.jsx | 13 +- 18 files changed, 1023 insertions(+), 694 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 6fba0aac3..7a481c476 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net" "net/http" "one-api/logger" "strings" @@ -21,8 +23,26 @@ const ( defaultTimeoutSeconds = 10 defaultEndpoint = "/api/ratio_config" maxConcurrentFetches = 8 + maxRatioConfigBytes = 10 << 20 // 10MB + floatEpsilon = 1e-9 ) +func nearlyEqual(a, b float64) bool { + if a > b { + return a-b < floatEpsilon + } + return b-a < floatEpsilon +} + +func valuesEqual(a, b interface{}) bool { + af, aok := a.(float64) + bf, bok := b.(float64) + if aok && bok { + return nearlyEqual(af, bf) + } + return a == b +} + var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} type upstreamResult struct { @@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) { sem := make(chan struct{}, maxConcurrentFetches) - client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + // 对 github.io 优先尝试 IPv4,失败则回退 IPv6 + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + client := &http.Client{Transport: transport} for _, chn := range upstreams { wg.Add(1) @@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) { defer func() { <-sem }() endpoint := chItem.Endpoint - if endpoint == "" { - endpoint = defaultEndpoint - } else if !strings.HasPrefix(endpoint, "/") { - endpoint = "/" + endpoint + var fullURL string + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + fullURL = endpoint + } else { + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL = chItem.BaseURL + endpoint } - fullURL := chItem.BaseURL + endpoint uniqueName := chItem.Name if chItem.ID != 0 { @@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) { return } - resp, err := client.Do(httpReq) - if err != nil { - logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) - ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + // 简单重试:最多 3 次,指数退避 + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(httpReq) + if lastErr == nil { + break + } + time.Sleep(time.Duration(200*(1< data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 @@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) { Message string `json:"message"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + if err := json.NewDecoder(limited).Decode(&body); err != nil { logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) ch <- upstreamResult{Name: uniqueName, Err: err.Error()} return @@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) { return } + // 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容) + // 尝试按 type1 解析 var type1Data map[string]any if err := json.Unmarshal(body.Data, &type1Data); err == nil { @@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { upstreamValue = val hasUpstreamValue = true - if localValue != nil && localValue != val { + if localValue != nil && !valuesEqual(localValue, val) { hasDifference = true - } else if localValue == val { + } else if valuesEqual(localValue, val) { upstreamValue = "same" } } @@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) { } } + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: -100, + Name: "官方倍率预设", + BaseURL: "https://basellm.github.io", + Status: 1, + }) + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/web/src/App.jsx b/web/src/App.jsx index cb9245244..635742f91 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -279,7 +279,10 @@ function App() { element={ pricingRequireAuth ? ( - } key={location.pathname}> + } + key={location.pathname} + > diff --git a/web/src/components/layout/HeaderBar/Navigation.jsx b/web/src/components/layout/HeaderBar/Navigation.jsx index b15df662f..3a5e3a3bd 100644 --- a/web/src/components/layout/HeaderBar/Navigation.jsx +++ b/web/src/components/layout/HeaderBar/Navigation.jsx @@ -21,7 +21,13 @@ import React from 'react'; import { Link } from 'react-router-dom'; import SkeletonWrapper from './SkeletonWrapper'; -const Navigation = ({ mainNavLinks, isMobile, isLoading, userState, pricingRequireAuth }) => { +const Navigation = ({ + mainNavLinks, + isMobile, + isLoading, + userState, + pricingRequireAuth, +}) => { const renderNavLinks = () => { const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out'; diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 7f61a2411..37e55d76c 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -50,7 +50,11 @@ const routerMap = { const SiderBar = ({ onNavigate = () => {} }) => { const { t } = useTranslation(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); - const { isModuleVisible, hasSectionVisibleModules, loading: sidebarLoading } = useSidebar(); + const { + isModuleVisible, + hasSectionVisibleModules, + loading: sidebarLoading, + } = useSidebar(); const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); @@ -58,160 +62,148 @@ const SiderBar = ({ onNavigate = () => {} }) => { const location = useLocation(); const [routerMapState, setRouterMapState] = useState(routerMap); - const workspaceItems = useMemo( - () => { - const items = [ - { - text: t('数据看板'), - itemKey: 'detail', - to: '/detail', - className: - localStorage.getItem('enable_data_export') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('令牌管理'), - itemKey: 'token', - to: '/token', - }, - { - text: t('使用日志'), - itemKey: 'log', - to: '/log', - }, - { - text: t('绘图日志'), - itemKey: 'midjourney', - to: '/midjourney', - className: - localStorage.getItem('enable_drawing') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('任务日志'), - itemKey: 'task', - to: '/task', - className: - localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', - }, - ]; + const workspaceItems = useMemo(() => { + const items = [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('令牌管理'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('console', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('console', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [ - localStorage.getItem('enable_data_export'), - localStorage.getItem('enable_drawing'), - localStorage.getItem('enable_task'), - t, - isModuleVisible, - ], - ); + return filteredItems; + }, [ + localStorage.getItem('enable_data_export'), + localStorage.getItem('enable_drawing'), + localStorage.getItem('enable_task'), + t, + isModuleVisible, + ]); - const financeItems = useMemo( - () => { - const items = [ - { - text: t('钱包管理'), - itemKey: 'topup', - to: '/topup', - }, - { - text: t('个人设置'), - itemKey: 'personal', - to: '/personal', - }, - ]; + const financeItems = useMemo(() => { + const items = [ + { + text: t('钱包管理'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('personal', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('personal', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [t, isModuleVisible], - ); + return filteredItems; + }, [t, isModuleVisible]); - const adminItems = useMemo( - () => { - const items = [ - { - text: t('渠道管理'), - itemKey: 'channel', - to: '/channel', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('模型管理'), - itemKey: 'models', - to: '/console/models', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('兑换码管理'), - itemKey: 'redemption', - to: '/redemption', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('用户管理'), - itemKey: 'user', - to: '/user', - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('系统设置'), - itemKey: 'setting', - to: '/setting', - className: isRoot() ? '' : 'tableHiddle', - }, - ]; + const adminItems = useMemo(() => { + const items = [ + { + text: t('渠道管理'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('模型管理'), + itemKey: 'models', + to: '/console/models', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码管理'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('admin', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('admin', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [isAdmin(), isRoot(), t, isModuleVisible], - ); + return filteredItems; + }, [isAdmin(), isRoot(), t, isModuleVisible]); - const chatMenuItems = useMemo( - () => { - const items = [ - { - text: t('操练场'), - itemKey: 'playground', - to: '/playground', - }, - { - text: t('聊天'), - itemKey: 'chat', - items: chatItems, - }, - ]; + const chatMenuItems = useMemo(() => { + const items = [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ]; - // 根据配置过滤项目 - const filteredItems = items.filter(item => { - const configVisible = isModuleVisible('chat', item.itemKey); - return configVisible; - }); + // 根据配置过滤项目 + const filteredItems = items.filter((item) => { + const configVisible = isModuleVisible('chat', item.itemKey); + return configVisible; + }); - return filteredItems; - }, - [chatItems, t, isModuleVisible], - ); + return filteredItems; + }, [chatItems, t, isModuleVisible]); // 更新路由映射,添加聊天路由 const updateRouterMapWithChats = (chats) => { @@ -426,7 +418,9 @@ const SiderBar = ({ onNavigate = () => {} }) => { {/* 聊天区域 */} {hasSectionVisibleModules('chat') && (
- {!collapsed &&
{t('聊天')}
} + {!collapsed && ( +
{t('聊天')}
+ )} {chatMenuItems.map((item) => renderSubItem(item))}
)} diff --git a/web/src/components/settings/ChannelSelectorModal.jsx b/web/src/components/settings/ChannelSelectorModal.jsx index b151151e6..757b0e2f3 100644 --- a/web/src/components/settings/ChannelSelectorModal.jsx +++ b/web/src/components/settings/ChannelSelectorModal.jsx @@ -34,7 +34,6 @@ import { Tag, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; -import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react'; const ChannelSelectorModal = forwardRef( ( @@ -65,6 +64,18 @@ const ChannelSelectorModal = forwardRef( }, })); + // 官方渠道识别 + const isOfficialChannel = (record) => { + const id = record?.key ?? record?.value ?? record?._originalData?.id; + const base = record?._originalData?.base_url || ''; + const name = record?.label || ''; + return ( + id === -100 || + base === 'https://basellm.github.io' || + name === '官方倍率预设' + ); + }; + useEffect(() => { if (!allChannels) return; @@ -77,7 +88,13 @@ const ChannelSelectorModal = forwardRef( }) : allChannels; - setFilteredData(matched); + const sorted = [...matched].sort((a, b) => { + const wa = isOfficialChannel(a) ? 0 : 1; + const wb = isOfficialChannel(b) ? 0 : 1; + return wa - wb; + }); + + setFilteredData(sorted); }, [allChannels, searchText]); const total = filteredData.length; @@ -143,45 +160,49 @@ const ChannelSelectorModal = forwardRef( ); }; - const renderStatusCell = (status) => { + const renderStatusCell = (record) => { + const status = record?._originalData?.status || 0; + const official = isOfficialChannel(record); + let statusTag = null; switch (status) { case 1: - return ( - } - > + statusTag = ( + {t('已启用')} ); + break; case 2: - return ( - }> + statusTag = ( + {t('已禁用')} ); + break; case 3: - return ( - } - > + statusTag = ( + {t('自动禁用')} ); + break; default: - return ( - } - > + statusTag = ( + {t('未知状态')} ); } + return ( +
+ {statusTag} + {official && ( + + {t('官方')} + + )} +
+ ); }; const renderNameCell = (text) => ( @@ -207,8 +228,7 @@ const ChannelSelectorModal = forwardRef( { title: t('状态'), dataIndex: '_originalData.status', - render: (_, record) => - renderStatusCell(record._originalData?.status || 0), + render: (_, record) => renderStatusCell(record), }, { title: t('同步接口'), diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 422cf0e88..3ba8dcfd3 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -38,8 +38,6 @@ const PersonalSetting = () => { let navigate = useNavigate(); const { t } = useTranslation(); - - const [inputs, setInputs] = useState({ wechat_verification_code: '', email_verification_code: '', @@ -335,8 +333,6 @@ const PersonalSetting = () => { saveNotificationSettings={saveNotificationSettings} /> - - diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index c7a31bd52..0b097eaff 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -34,7 +34,12 @@ import { } from '@douyinfe/semi-ui'; import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons'; import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react'; -import { renderQuotaWithPrompt, API, showSuccess, showError } from '../../../../helpers'; +import { + renderQuotaWithPrompt, + API, + showSuccess, + showError, +} from '../../../../helpers'; import CodeViewer from '../../../playground/CodeViewer'; import { StatusContext } from '../../../../context/Status'; import { UserContext } from '../../../../context/User'; @@ -57,7 +62,7 @@ const NotificationSettings = ({ chat: { enabled: true, playground: true, - chat: true + chat: true, }, console: { enabled: true, @@ -65,12 +70,12 @@ const NotificationSettings = ({ token: true, log: true, midjourney: true, - task: true + task: true, }, personal: { enabled: true, topup: true, - personal: true + personal: true, }, admin: { enabled: true, @@ -78,8 +83,8 @@ const NotificationSettings = ({ models: true, redemption: true, user: true, - setting: true - } + setting: true, + }, }); const [adminConfig, setAdminConfig] = useState(null); @@ -99,8 +104,8 @@ const NotificationSettings = ({ ...sidebarModulesUser, [sectionKey]: { ...sidebarModulesUser[sectionKey], - enabled: checked - } + enabled: checked, + }, }; setSidebarModulesUser(newModules); }; @@ -112,8 +117,8 @@ const NotificationSettings = ({ ...sidebarModulesUser, [sectionKey]: { ...sidebarModulesUser[sectionKey], - [moduleKey]: checked - } + [moduleKey]: checked, + }, }; setSidebarModulesUser(newModules); }; @@ -123,7 +128,7 @@ const NotificationSettings = ({ setSidebarLoading(true); try { const res = await API.put('/api/user/self', { - sidebar_modules: JSON.stringify(sidebarModulesUser) + sidebar_modules: JSON.stringify(sidebarModulesUser), }); if (res.data.success) { showSuccess(t('侧边栏设置保存成功')); @@ -139,9 +144,23 @@ const NotificationSettings = ({ const resetSidebarModules = () => { const defaultConfig = { chat: { enabled: true, playground: true, chat: true }, - console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true, + }, personal: { enabled: true, topup: true, personal: true }, - admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true } + admin: { + enabled: true, + channel: true, + models: true, + redemption: true, + user: true, + setting: true, + }, }; setSidebarModulesUser(defaultConfig); }; @@ -187,7 +206,9 @@ const NotificationSettings = ({ if (!adminConfig) return true; if (moduleKey) { - return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]; + return ( + adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey] + ); } else { return adminConfig[sectionKey]?.enabled; } @@ -200,9 +221,13 @@ const NotificationSettings = ({ title: t('聊天区域'), description: t('操练场和聊天功能'), modules: [ - { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') }, - { key: 'chat', title: t('聊天'), description: t('聊天会话管理') } - ] + { + key: 'playground', + title: t('操练场'), + description: t('AI模型测试环境'), + }, + { key: 'chat', title: t('聊天'), description: t('聊天会话管理') }, + ], }, { key: 'console', @@ -212,9 +237,13 @@ const NotificationSettings = ({ { key: 'detail', title: t('数据看板'), description: t('系统数据统计') }, { key: 'token', title: t('令牌管理'), description: t('API令牌管理') }, { key: 'log', title: t('使用日志'), description: t('API使用记录') }, - { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') }, - { key: 'task', title: t('任务日志'), description: t('系统任务记录') } - ] + { + key: 'midjourney', + title: t('绘图日志'), + description: t('绘图任务记录'), + }, + { key: 'task', title: t('任务日志'), description: t('系统任务记录') }, + ], }, { key: 'personal', @@ -222,8 +251,12 @@ const NotificationSettings = ({ description: t('用户个人功能'), modules: [ { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') }, - { key: 'personal', title: t('个人设置'), description: t('个人信息设置') } - ] + { + key: 'personal', + title: t('个人设置'), + description: t('个人信息设置'), + }, + ], }, // 管理员区域:根据后端权限控制显示 { @@ -233,23 +266,35 @@ const NotificationSettings = ({ modules: [ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, - { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') }, + { + key: 'redemption', + title: t('兑换码管理'), + description: t('兑换码生成管理'), + }, { key: 'user', title: t('用户管理'), description: t('用户账户管理') }, - { key: 'setting', title: t('系统设置'), description: t('系统参数配置') } - ] - } - ].filter(section => { - // 使用后端权限验证替代前端角色判断 - return isSidebarSectionAllowed(section.key); - }).map(section => ({ - ...section, - modules: section.modules.filter(module => - isSidebarModuleAllowed(section.key, module.key) - ) - })).filter(section => - // 过滤掉没有可用模块的区域 - section.modules.length > 0 && isAllowedByAdmin(section.key) - ); + { + key: 'setting', + title: t('系统设置'), + description: t('系统参数配置'), + }, + ], + }, + ] + .filter((section) => { + // 使用后端权限验证替代前端角色判断 + return isSidebarSectionAllowed(section.key); + }) + .map((section) => ({ + ...section, + modules: section.modules.filter((module) => + isSidebarModuleAllowed(section.key, module.key), + ), + })) + .filter( + (section) => + // 过滤掉没有可用模块的区域 + section.modules.length > 0 && isAllowedByAdmin(section.key), + ); // 表单提交 const handleSubmit = () => { @@ -491,7 +536,9 @@ const NotificationSettings = ({ handleFormChange('barkUrl', val)} prefix={} extraText={t( @@ -500,8 +547,7 @@ const NotificationSettings = ({ showClear rules={[ { - required: - notificationSettings.warningType === 'bark', + required: notificationSettings.warningType === 'bark', message: t('请输入Bark推送URL'), }, { @@ -516,16 +562,23 @@ const NotificationSettings = ({ {t('模板示例')}
- https://api.day.app/yourkey/{'{{title}}'}/{'{{content}}'}?sound=alarm&group=quota + https://api.day.app/yourkey/{'{{title}}'}/ + {'{{content}}'}?sound=alarm&group=quota
-
{'title'}: {t('通知标题')}
-
{'content'}: {t('通知内容')}
+
+ • {'title'}: {t('通知标题')} +
+
+ • {'content'}: {t('通知内容')} +
- {t('更多参数请参考')}{' '} - + {t('更多参数请参考')} + {' '} + @@ -603,27 +656,25 @@ const NotificationSettings = ({
{t('您可以个性化设置侧边栏的要显示功能')}
- {/* 边栏设置功能区域容器 */}
- {sectionConfigs.map((section) => (
{/* 区域标题和总开关 */} @@ -632,80 +683,102 @@ const NotificationSettings = ({ style={{ backgroundColor: 'var(--semi-color-fill-0)', border: '1px solid var(--semi-color-border-light)', - borderColor: 'var(--semi-color-fill-1)' + borderColor: 'var(--semi-color-fill-1)', }} > -
-
- {section.title} -
- - {section.description} - -
- -
- - {/* 功能模块网格 */} - - {section.modules - .filter(module => isAllowedByAdmin(section.key, module.key)) - .map((module) => ( - - +
+ {section.title} +
+ -
-
-
- {module.title} + {section.description} + +
+ +
+ + {/* 功能模块网格 */} + + {section.modules + .filter((module) => + isAllowedByAdmin(section.key, module.key), + ) + .map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
- - {module.description} - -
-
- -
-
- - - ))} - + + + ))} +
))} -
{/* 关闭边栏设置功能区域容器 */} +
{' '} + {/* 关闭边栏设置功能区域容器 */} )} diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 56f745c20..5b505baed 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -231,11 +231,14 @@ export const getChannelsColumns = ({ theme='outline' onClick={(e) => { e.stopPropagation(); - navigator.clipboard.writeText(record.remark).then(() => { - showSuccess(t('复制成功')); - }).catch(() => { - showError(t('复制失败')); - }); + navigator.clipboard + .writeText(record.remark) + .then(() => { + showSuccess(t('复制成功')); + }) + .catch(() => { + showError(t('复制失败')); + }); }} > {t('复制')} diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js index 9f95a9b9a..3458a1d16 100644 --- a/web/src/hooks/common/useHeaderBar.js +++ b/web/src/hooks/common/useHeaderBar.js @@ -64,7 +64,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { if (typeof modules.pricing === 'boolean') { modules.pricing = { enabled: modules.pricing, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }; } diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js index 43d9024ea..f7e61a203 100644 --- a/web/src/hooks/common/useNavigation.js +++ b/web/src/hooks/common/useNavigation.js @@ -20,67 +20,66 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react'; export const useNavigation = (t, docsLink, headerNavModules) => { - const mainNavLinks = useMemo( - () => { - // 默认配置,如果没有传入配置则显示所有模块 - const defaultModules = { - home: true, - console: true, - pricing: true, - docs: true, - about: true, - }; + const mainNavLinks = useMemo(() => { + // 默认配置,如果没有传入配置则显示所有模块 + const defaultModules = { + home: true, + console: true, + pricing: true, + docs: true, + about: true, + }; - // 使用传入的配置或默认配置 - const modules = headerNavModules || defaultModules; + // 使用传入的配置或默认配置 + const modules = headerNavModules || defaultModules; - const allLinks = [ - { - text: t('首页'), - itemKey: 'home', - to: '/', - }, - { - text: t('控制台'), - itemKey: 'console', - to: '/console', - }, - { - text: t('模型广场'), - itemKey: 'pricing', - to: '/pricing', - }, - ...(docsLink - ? [ - { - text: t('文档'), - itemKey: 'docs', - isExternal: true, - externalLink: docsLink, - }, - ] - : []), - { - text: t('关于'), - itemKey: 'about', - to: '/about', - }, - ]; + const allLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('模型广场'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; - // 根据配置过滤导航链接 - return allLinks.filter(link => { - if (link.itemKey === 'docs') { - return docsLink && modules.docs; - } - if (link.itemKey === 'pricing') { - // 支持新的pricing配置格式 - return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing; - } - return modules[link.itemKey] === true; - }); - }, - [t, docsLink, headerNavModules], - ); + // 根据配置过滤导航链接 + return allLinks.filter((link) => { + if (link.itemKey === 'docs') { + return docsLink && modules.docs; + } + if (link.itemKey === 'pricing') { + // 支持新的pricing配置格式 + return typeof modules.pricing === 'object' + ? modules.pricing.enabled + : modules.pricing; + } + return modules[link.itemKey] === true; + }); + }, [t, docsLink, headerNavModules]); return { mainNavLinks, diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 0a695bbd4..5dce44f9e 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -31,7 +31,7 @@ export const useSidebar = () => { chat: { enabled: true, playground: true, - chat: true + chat: true, }, console: { enabled: true, @@ -39,12 +39,12 @@ export const useSidebar = () => { token: true, log: true, midjourney: true, - task: true + task: true, }, personal: { enabled: true, topup: true, - personal: true + personal: true, }, admin: { enabled: true, @@ -52,8 +52,8 @@ export const useSidebar = () => { models: true, redemption: true, user: true, - setting: true - } + setting: true, + }, }; // 获取管理员配置 @@ -87,12 +87,15 @@ export const useSidebar = () => { // 当用户没有配置时,生成一个基于管理员配置的默认用户配置 // 这样可以确保权限控制正确生效 const defaultUserConfig = {}; - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { if (adminConfig[sectionKey]?.enabled) { defaultUserConfig[sectionKey] = { enabled: true }; // 为每个管理员允许的模块设置默认值为true - Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { - if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { + Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => { + if ( + moduleKey !== 'enabled' && + adminConfig[sectionKey][moduleKey] + ) { defaultUserConfig[sectionKey][moduleKey] = true; } }); @@ -103,10 +106,10 @@ export const useSidebar = () => { } catch (error) { // 出错时也生成默认配置,而不是设置为空对象 const defaultUserConfig = {}; - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { if (adminConfig[sectionKey]?.enabled) { defaultUserConfig[sectionKey] = { enabled: true }; - Object.keys(adminConfig[sectionKey]).forEach(moduleKey => { + Object.keys(adminConfig[sectionKey]).forEach((moduleKey) => { if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) { defaultUserConfig[sectionKey][moduleKey] = true; } @@ -149,7 +152,7 @@ export const useSidebar = () => { } // 遍历所有区域 - Object.keys(adminConfig).forEach(sectionKey => { + Object.keys(adminConfig).forEach((sectionKey) => { const adminSection = adminConfig[sectionKey]; const userSection = userConfig[sectionKey]; @@ -161,18 +164,21 @@ export const useSidebar = () => { // 区域级别:用户可以选择隐藏管理员允许的区域 // 当userSection存在时检查enabled状态,否则默认为true - const sectionEnabled = userSection ? (userSection.enabled !== false) : true; + const sectionEnabled = userSection ? userSection.enabled !== false : true; result[sectionKey] = { enabled: sectionEnabled }; // 功能级别:只有管理员和用户都允许的功能才显示 - Object.keys(adminSection).forEach(moduleKey => { + Object.keys(adminSection).forEach((moduleKey) => { if (moduleKey === 'enabled') return; const adminAllowed = adminSection[moduleKey]; // 当userSection存在时检查模块状态,否则默认为true - const userAllowed = userSection ? (userSection[moduleKey] !== false) : true; + const userAllowed = userSection + ? userSection[moduleKey] !== false + : true; - result[sectionKey][moduleKey] = adminAllowed && userAllowed && sectionEnabled; + result[sectionKey][moduleKey] = + adminAllowed && userAllowed && sectionEnabled; }); }); @@ -192,9 +198,9 @@ export const useSidebar = () => { const hasSectionVisibleModules = (sectionKey) => { const section = finalConfig[sectionKey]; if (!section?.enabled) return false; - - return Object.keys(section).some(key => - key !== 'enabled' && section[key] === true + + return Object.keys(section).some( + (key) => key !== 'enabled' && section[key] === true, ); }; @@ -202,9 +208,10 @@ export const useSidebar = () => { const getVisibleModules = (sectionKey) => { const section = finalConfig[sectionKey]; if (!section?.enabled) return []; - - return Object.keys(section) - .filter(key => key !== 'enabled' && section[key] === true); + + return Object.keys(section).filter( + (key) => key !== 'enabled' && section[key] === true, + ); }; return { @@ -215,6 +222,6 @@ export const useSidebar = () => { isModuleVisible, hasSectionVisibleModules, getVisibleModules, - refreshUserConfig + refreshUserConfig, }; }; diff --git a/web/src/hooks/common/useUserPermissions.js b/web/src/hooks/common/useUserPermissions.js index 743594353..8d57f972e 100644 --- a/web/src/hooks/common/useUserPermissions.js +++ b/web/src/hooks/common/useUserPermissions.js @@ -1,3 +1,21 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ import { useState, useEffect } from 'react'; import { API } from '../../helpers'; @@ -52,22 +70,22 @@ export const useUserPermissions = () => { const isSidebarModuleAllowed = (sectionKey, moduleKey) => { if (!permissions?.sidebar_modules) return true; const sectionPerms = permissions.sidebar_modules[sectionKey]; - + // 如果整个区域被禁用 if (sectionPerms === false) return false; - + // 如果区域存在但模块被禁用 if (sectionPerms && sectionPerms[moduleKey] === false) return false; - + return true; }; // 获取允许的边栏区域列表 const getAllowedSidebarSections = () => { if (!permissions?.sidebar_modules) return []; - - return Object.keys(permissions.sidebar_modules).filter(sectionKey => - isSidebarSectionAllowed(sectionKey) + + return Object.keys(permissions.sidebar_modules).filter((sectionKey) => + isSidebarSectionAllowed(sectionKey), ); }; @@ -75,12 +93,13 @@ export const useUserPermissions = () => { const getAllowedSidebarModules = (sectionKey) => { if (!permissions?.sidebar_modules) return []; const sectionPerms = permissions.sidebar_modules[sectionKey]; - + if (sectionPerms === false) return []; if (!sectionPerms || typeof sectionPerms !== 'object') return []; - - return Object.keys(sectionPerms).filter(moduleKey => - moduleKey !== 'enabled' && sectionPerms[moduleKey] === true + + return Object.keys(sectionPerms).filter( + (moduleKey) => + moduleKey !== 'enabled' && sectionPerms[moduleKey] === true, ); }; diff --git a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx index 623accefb..37dabf546 100644 --- a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx +++ b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx @@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useContext } from 'react'; -import { Button, Card, Col, Form, Row, Switch, Typography } from '@douyinfe/semi-ui'; +import { + Button, + Card, + Col, + Form, + Row, + Switch, + Typography, +} from '@douyinfe/semi-ui'; import { API, showError, showSuccess } from '../../../helpers'; import { useTranslation } from 'react-i18next'; import { StatusContext } from '../../../context/Status'; @@ -29,14 +37,14 @@ export default function SettingsHeaderNavModules(props) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [statusState, statusDispatch] = useContext(StatusContext); - + // 顶栏模块管理状态 const [headerNavModules, setHeaderNavModules] = useState({ home: true, console: true, pricing: { enabled: true, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }, docs: true, about: true, @@ -50,7 +58,7 @@ export default function SettingsHeaderNavModules(props) { // 对于pricing模块,只更新enabled属性 newModules[moduleKey] = { ...newModules[moduleKey], - enabled: checked + enabled: checked, }; } else { newModules[moduleKey] = checked; @@ -64,7 +72,7 @@ export default function SettingsHeaderNavModules(props) { const newModules = { ...headerNavModules }; newModules.pricing = { ...newModules.pricing, - requireAuth: checked + requireAuth: checked, }; setHeaderNavModules(newModules); } @@ -76,7 +84,7 @@ export default function SettingsHeaderNavModules(props) { console: true, pricing: { enabled: true, - requireAuth: false + requireAuth: false, }, docs: true, about: true, @@ -102,8 +110,8 @@ export default function SettingsHeaderNavModules(props) { type: 'set', payload: { ...statusState.status, - HeaderNavModules: JSON.stringify(headerNavModules) - } + HeaderNavModules: JSON.stringify(headerNavModules), + }, }); // 刷新父组件状态 @@ -130,7 +138,7 @@ export default function SettingsHeaderNavModules(props) { if (typeof modules.pricing === 'boolean') { modules.pricing = { enabled: modules.pricing, - requireAuth: false // 默认不需要登录鉴权 + requireAuth: false, // 默认不需要登录鉴权 }; } @@ -142,7 +150,7 @@ export default function SettingsHeaderNavModules(props) { console: true, pricing: { enabled: true, - requireAuth: false + requireAuth: false, }, docs: true, about: true, @@ -157,35 +165,37 @@ export default function SettingsHeaderNavModules(props) { { key: 'home', title: t('首页'), - description: t('用户主页,展示系统信息') + description: t('用户主页,展示系统信息'), }, { key: 'console', title: t('控制台'), - description: t('用户控制面板,管理账户') + description: t('用户控制面板,管理账户'), }, { key: 'pricing', title: t('模型广场'), description: t('模型定价,需要登录访问'), - hasSubConfig: true // 标识该模块有子配置 + hasSubConfig: true, // 标识该模块有子配置 }, { key: 'docs', title: t('文档'), - description: t('系统文档和帮助信息') + description: t('系统文档和帮助信息'), }, { key: 'about', title: t('关于'), - description: t('关于系统的详细信息') - } + description: t('关于系统的详细信息'), + }, ]; return ( - - + {moduleConfigs.map((module) => ( @@ -195,34 +205,38 @@ export default function SettingsHeaderNavModules(props) { border: '1px solid var(--semi-color-border)', transition: 'all 0.2s ease', background: 'var(--semi-color-bg-1)', - minHeight: '80px' + minHeight: '80px', }} bodyStyle={{ padding: '16px' }} hoverable > -
+
-
+
{module.title}
{module.description} @@ -230,78 +244,94 @@ export default function SettingsHeaderNavModules(props) {
{/* 为模型广场添加权限控制子开关 */} - {module.key === 'pricing' && (module.key === 'pricing' ? headerNavModules[module.key]?.enabled : headerNavModules[module.key]) && ( -
-
-
-
- {t('需要登录访问')} + {module.key === 'pricing' && + (module.key === 'pricing' + ? headerNavModules[module.key]?.enabled + : headerNavModules[module.key]) && ( +
+
+
+
+ {t('需要登录访问')} +
+ + {t('开启后未登录用户无法访问模型广场')} + +
+
+
- - {t('开启后未登录用户无法访问模型广场')} - -
-
-
-
- )} + )} ))} - -
+
- + {t('您可以个性化设置侧边栏的要显示功能')}
- {sectionConfigs.map((section) => ( -
- {/* 区域标题和总开关 */} -
-
-
- {section.title} -
- - {section.description} - + {sectionConfigs.map((section) => ( +
+ {/* 区域标题和总开关 */} +
+
+
+ {section.title}
- + + {section.description} +
- - {/* 功能模块网格 */} - - {section.modules.map((module) => ( - - -
-
-
- {module.title} -
- - {module.description} - -
-
- -
-
-
- - ))} -
+
- ))} + + {/* 功能模块网格 */} + + {section.modules.map((module) => ( + + +
+
+
+ {module.title} +
+ + {module.description} + +
+
+ +
+
+
+ + ))} +
+
+ ))} {/* 底部按钮 */}
diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx index 9394ae83d..5d6dd154f 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx @@ -130,9 +130,7 @@ export default function ModelRatioNotSetEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => - searchText - ? model.name.includes(searchText) - : true, + searchText ? model.name.includes(searchText) : true, ); // 然后基于过滤后的数据计算分页数据 diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx index e291e132b..b5ad3e58d 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx @@ -99,9 +99,7 @@ export default function ModelSettingsVisualEditor(props) { // 在 return 语句之前,先处理过滤和分页逻辑 const filteredModels = models.filter((model) => { - const keywordMatch = searchText - ? model.name.includes(searchText) - : true; + const keywordMatch = searchText ? model.name.includes(searchText) : true; const conflictMatch = conflictOnly ? model.hasConflict : true; return keywordMatch && conflictMatch; }); diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx index 0f483717b..86efcce02 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.jsx @@ -151,8 +151,17 @@ export default function UpstreamRatioSync(props) { setChannelEndpoints((prev) => { const merged = { ...prev }; transferData.forEach((channel) => { - if (!merged[channel.key]) { - merged[channel.key] = DEFAULT_ENDPOINT; + const id = channel.key; + const base = channel._originalData?.base_url || ''; + const name = channel.label || ''; + const isOfficial = + id === -100 || + base === 'https://basellm.github.io' || + name === '官方倍率预设'; + if (!merged[id]) { + merged[id] = isOfficial + ? '/llm-metadata/api/newapi/ratio_config-v1-base.json' + : DEFAULT_ENDPOINT; } }); return merged; From fbc19abd28ef9b8ee86a5ae5d0070446e09100b8 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 02:04:22 +0800 Subject: [PATCH 16/64] =?UTF-8?q?=E2=9C=A8=20feat(models-sync):=20official?= =?UTF-8?q?=20upstream=20sync=20with=20conflict=20resolution=20UI,=20opt?= =?UTF-8?q?=E2=80=91out=20flag,=20and=20backend=20resiliency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - Add endpoints: - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0) - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields) - Respect opt‑out: skip models with sync_official = 0 in both preview and apply - Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list - Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM - Make HTTP fetching robust: - Shared http.Client (connection reuse) with 3x exponential backoff retry - 10MB response cap; keep existing IPv4‑first for *.github.io - Vendor handling: - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips - Transactional overwrite to avoid partial updates - Small cleanups and clearer helpers (containsField, coalesce, chooseStatus) Frontend - ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow - New UpstreamConflictModal: - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer - Full i18n coverage - useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply - EditModelModal: add “Participate in official sync” switch; persisted as sync_official - ModelsColumnDefs: add “Participate in official sync” column i18n - Add missing English keys for the new UI and messages; fix quoting issues Refs - Upstream metadata: https://github.com/basellm/llm-metadata --- controller/model_sync.go | 463 ++++++++++++++++++ model/model_meta.go | 1 + router/api-router.go | 2 + .../components/table/models/ModelsActions.jsx | 66 ++- .../table/models/ModelsColumnDefs.jsx | 9 + web/src/components/table/models/index.jsx | 5 + .../table/models/modals/EditModelModal.jsx | 15 +- .../models/modals/UpstreamConflictModal.jsx | 245 +++++++++ web/src/hooks/models/useModelsData.jsx | 84 ++++ web/src/i18n/locales/en.json | 34 +- 10 files changed, 902 insertions(+), 22 deletions(-) create mode 100644 controller/model_sync.go create mode 100644 web/src/components/table/models/modals/UpstreamConflictModal.jsx diff --git a/controller/model_sync.go b/controller/model_sync.go new file mode 100644 index 000000000..5e2803c5d --- /dev/null +++ b/controller/model_sync.go @@ -0,0 +1,463 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strings" + "time" + + "one-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 上游地址 +const ( + upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" + upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json" +) + +type upstreamEnvelope[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []T `json:"data"` +} + +type upstreamModel struct { + Description string `json:"description"` + Endpoints json.RawMessage `json:"endpoints"` + Icon string `json:"icon"` + ModelName string `json:"model_name"` + NameRule int `json:"name_rule"` + Status int `json:"status"` + Tags string `json:"tags"` + VendorName string `json:"vendor_name"` +} + +type upstreamVendor struct { + Description string `json:"description"` + Icon string `json:"icon"` + Name string `json:"name"` + Status int `json:"status"` +} + +type overwriteField struct { + ModelName string `json:"model_name"` + Fields []string `json:"fields"` +} + +type syncRequest struct { + Overwrite []overwriteField `json:"overwrite"` +} + +func newHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + } + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + return &http.Client{Transport: transport} +} + +var httpClient = newHTTPClient() + +func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration(200*(1< id + vendorIDCache := make(map[string]int) + + for _, name := range missing { + up, ok := modelByName[name] + if !ok { + skipped = append(skipped, name) + continue + } + + // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) + var existing model.Model + if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { + if existing.SyncOfficial == 0 { + skipped = append(skipped, name) + continue + } + } + + // 确保 vendor 存在 + vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 创建模型 + mi := &model.Model{ + ModelName: name, + Description: up.Description, + Icon: up.Icon, + Tags: up.Tags, + VendorID: vendorID, + Status: chooseStatus(up.Status, 1), + NameRule: up.NameRule, + } + if err := mi.Insert(); err == nil { + createdModels++ + createdList = append(createdList, name) + } else { + skipped = append(skipped, name) + } + } + + // 4) 处理可选覆盖(更新本地已有模型的差异字段) + if len(req.Overwrite) > 0 { + // vendorIDCache 已用于创建阶段,可复用 + for _, ow := range req.Overwrite { + up, ok := modelByName[ow.ModelName] + if !ok { + continue + } + var local model.Model + if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { + continue + } + + // 跳过被禁用官方同步的模型 + if local.SyncOfficial == 0 { + continue + } + + // 映射 vendor + newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 应用字段覆盖(事务) + _ = model.DB.Transaction(func(tx *gorm.DB) error { + needUpdate := false + if containsField(ow.Fields, "description") { + local.Description = up.Description + needUpdate = true + } + if containsField(ow.Fields, "icon") { + local.Icon = up.Icon + needUpdate = true + } + if containsField(ow.Fields, "tags") { + local.Tags = up.Tags + needUpdate = true + } + if containsField(ow.Fields, "vendor") { + local.VendorID = newVendorID + needUpdate = true + } + if containsField(ow.Fields, "name_rule") { + local.NameRule = up.NameRule + needUpdate = true + } + if containsField(ow.Fields, "status") { + local.Status = chooseStatus(up.Status, local.Status) + needUpdate = true + } + if !needUpdate { + return nil + } + if err := tx.Save(&local).Error; err != nil { + return err + } + updatedModels++ + updatedList = append(updatedList, ow.ModelName) + return nil + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": createdModels, + "created_vendors": createdVendors, + "updated_models": updatedModels, + "skipped_models": skipped, + "created_list": createdList, + "updated_list": updatedList, + }, + }) +} + +func containsField(fields []string, key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + for _, f := range fields { + if strings.ToLower(strings.TrimSpace(f)) == key { + return true + } + } + return false +} + +func coalesce(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func chooseStatus(primary, fallback int) int { + if primary == 0 && fallback != 0 { + return fallback + } + if primary != 0 { + return primary + } + return 1 +} + +// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择) +func SyncUpstreamPreview(c *gin.Context) { + // 1) 拉取上游数据 + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + var vendorsEnv upstreamEnvelope[upstreamVendor] + _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv) + + var modelsEnv upstreamEnvelope[upstreamModel] + if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()}) + return + } + + vendorByName := make(map[string]upstreamVendor) + for _, v := range vendorsEnv.Data { + if v.Name != "" { + vendorByName[v.Name] = v + } + } + modelByName := make(map[string]upstreamModel) + upstreamNames := make([]string, 0, len(modelsEnv.Data)) + for _, m := range modelsEnv.Data { + if m.ModelName != "" { + modelByName[m.ModelName] = m + upstreamNames = append(upstreamNames, m.ModelName) + } + } + + // 2) 本地已有模型 + var locals []model.Model + if len(upstreamNames) > 0 { + _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error + } + + // 本地 vendor 名称映射 + vendorIdSet := make(map[int]struct{}) + for _, m := range locals { + if m.VendorID != 0 { + vendorIdSet[m.VendorID] = struct{}{} + } + } + vendorIDs := make([]int, 0, len(vendorIdSet)) + for id := range vendorIdSet { + vendorIDs = append(vendorIDs, id) + } + idToVendorName := make(map[int]string) + if len(vendorIDs) > 0 { + var dbVendors []model.Vendor + _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error + for _, v := range dbVendors { + idToVendorName[v.Id] = v.Name + } + } + + // 3) 缺失且上游存在的模型 + missingList, _ := model.GetMissingModels() + var missing []string + for _, name := range missingList { + if _, ok := modelByName[name]; ok { + missing = append(missing, name) + } + } + + // 4) 计算冲突字段 + type conflictField struct { + Field string `json:"field"` + Local interface{} `json:"local"` + Upstream interface{} `json:"upstream"` + } + type conflictItem struct { + ModelName string `json:"model_name"` + Fields []conflictField `json:"fields"` + } + + var conflicts []conflictItem + for _, local := range locals { + up, ok := modelByName[local.ModelName] + if !ok { + continue + } + fields := make([]conflictField, 0, 6) + if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { + fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) + } + if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { + fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) + } + if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { + fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) + } + // vendor 对比使用名称 + localVendor := idToVendorName[local.VendorID] + if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { + fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) + } + if local.NameRule != up.NameRule { + fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) + } + if local.Status != chooseStatus(up.Status, local.Status) { + fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) + } + if len(fields) > 0 { + conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "missing": missing, + "conflicts": conflicts, + }, + }) +} + + diff --git a/model/model_meta.go b/model/model_meta.go index e9582e441..a6230553b 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -28,6 +28,7 @@ type Model struct { VendorID int `json:"vendor_id,omitempty" gorm:"index"` Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` Status int `json:"status" gorm:"default:1"` + SyncOfficial int `json:"sync_official" gorm:"default:1"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` diff --git a/router/api-router.go b/router/api-router.go index 311bb0a4b..773857385 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -224,6 +224,8 @@ func SetApiRouter(router *gin.Engine) { modelsRoute := apiRouter.Group("/models") modelsRoute.Use(middleware.AdminAuth()) { + modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview) + modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels) modelsRoute.GET("/missing", controller.GetMissingModels) modelsRoute.GET("/", controller.GetAllModelsMeta) modelsRoute.GET("/search", controller.SearchModelsMeta) diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index cc6b8ef8e..cc6c9afed 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -21,10 +21,11 @@ import React, { useState } from 'react'; import MissingModelsModal from './modals/MissingModelsModal'; import PrefillGroupManagement from './modals/PrefillGroupManagement'; import EditPrefillGroupModal from './modals/EditPrefillGroupModal'; -import { Button, Modal } from '@douyinfe/semi-ui'; +import { Button, Modal, Popover } from '@douyinfe/semi-ui'; import { showSuccess, showError, copy } from '../../../helpers'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import SelectionNotification from './components/SelectionNotification'; +import UpstreamConflictModal from './modals/UpstreamConflictModal'; const ModelsActions = ({ selectedKeys, @@ -32,6 +33,11 @@ const ModelsActions = ({ setEditingModel, setShowEdit, batchDeleteModels, + syncing, + previewing, + syncUpstream, + previewUpstreamDiff, + applyUpstreamOverwrite, compactMode, setCompactMode, t, @@ -42,6 +48,21 @@ const ModelsActions = ({ const [showGroupManagement, setShowGroupManagement] = useState(false); const [showAddPrefill, setShowAddPrefill] = useState(false); const [prefillInit, setPrefillInit] = useState({ id: undefined }); + const [showConflict, setShowConflict] = useState(false); + const [conflicts, setConflicts] = useState([]); + + const handleSyncUpstream = async () => { + // 先预览 + const data = await previewUpstreamDiff?.(); + const conflictItems = data?.conflicts || []; + if (conflictItems.length > 0) { + setConflicts(conflictItems); + setShowConflict(true); + return; + } + // 无冲突,直接同步缺失 + await syncUpstream?.(); + }; // Handle delete selected models with confirmation const handleDeleteSelectedModels = () => { @@ -104,6 +125,38 @@ const ModelsActions = ({ {t('未配置模型')} + +
+ {t( + '模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:', + )} +
+ + https://github.com/basellm/llm-metadata + +
+ } + > + + +
+ } + > + } + > + {t('点击查看差异')} + + + + ); + }, + }; + }); + + return [...base, ...cols.filter(Boolean)]; + }, [t, selections, conflicts]); + + const dataSource = conflicts.map((c) => ({ + key: c.model_name, + model_name: c.model_name, + fields: c.fields || [], + })); + + const handleOk = async () => { + const payload = Object.entries(selections) + .map(([modelName, set]) => ({ + model_name: modelName, + fields: Array.from(set || []), + })) + .filter((x) => x.fields.length > 0); + + if (payload.length === 0) { + onClose?.(); + return; + } + const ok = await onSubmit?.(payload); + if (ok) onClose?.(); + }; + + return ( + + {dataSource.length === 0 ? ( + + ) : ( + <> +
+ {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')} +
+ + + )} + + ); +}; + +export default UpstreamConflictModal; diff --git a/web/src/hooks/models/useModelsData.jsx b/web/src/hooks/models/useModelsData.jsx index 8e024b122..e2068840e 100644 --- a/web/src/hooks/models/useModelsData.jsx +++ b/web/src/hooks/models/useModelsData.jsx @@ -95,6 +95,8 @@ export const useModelsData = () => { const [showAddVendor, setShowAddVendor] = useState(false); const [showEditVendor, setShowEditVendor] = useState(false); const [editingVendor, setEditingVendor] = useState({ id: undefined }); + const [syncing, setSyncing] = useState(false); + const [previewing, setPreviewing] = useState(false); const vendorMap = useMemo(() => { const map = {}; @@ -163,6 +165,81 @@ export const useModelsData = () => { await loadModels(page, pageSize); }; + // Sync upstream models/vendors for missing models only + const syncUpstream = async () => { + setSyncing(true); + try { + const res = await API.post('/api/models/sync_upstream'); + const { success, message, data } = res.data || {}; + if (success) { + const createdModels = data?.created_models || 0; + const createdVendors = data?.created_vendors || 0; + const skipped = (data?.skipped_models || []).length || 0; + showSuccess( + t( + `已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`, + ), + ); + await loadVendors(); + await refresh(); + } else { + showError(message || t('同步失败')); + } + } catch (e) { + showError(t('同步失败')); + } + setSyncing(false); + }; + + // Preview upstream differences + const previewUpstreamDiff = async () => { + setPreviewing(true); + try { + const res = await API.get('/api/models/sync_upstream/preview'); + const { success, message, data } = res.data || {}; + if (success) { + return data || { missing: [], conflicts: [] }; + } + showError(message || t('预览失败')); + return { missing: [], conflicts: [] }; + } catch (e) { + showError(t('预览失败')); + return { missing: [], conflicts: [] }; + } finally { + setPreviewing(false); + } + }; + + // Apply selected overwrite + const applyUpstreamOverwrite = async (overwrite = []) => { + setSyncing(true); + try { + const res = await API.post('/api/models/sync_upstream', { overwrite }); + const { success, message, data } = res.data || {}; + if (success) { + const createdModels = data?.created_models || 0; + const updatedModels = data?.updated_models || 0; + const createdVendors = data?.created_vendors || 0; + const skipped = (data?.skipped_models || []).length || 0; + showSuccess( + t( + `完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`, + ), + ); + await loadVendors(); + await refresh(); + return true; + } + showError(message || t('同步失败')); + return false; + } catch (e) { + showError(t('同步失败')); + return false; + } finally { + setSyncing(false); + } + }; + // Search models with keyword and vendor const searchModels = async () => { const { searchKeyword = '', searchVendor = '' } = getFormValues(); @@ -398,5 +475,12 @@ export const useModelsData = () => { // Translation t, + + // Upstream sync + syncing, + previewing, + syncUpstream, + previewUpstreamDiff, + applyUpstreamOverwrite, }; }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7b308b9b7..1d113f106 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,6 +1,5 @@ { "主页": "Home", - "文档": "Docs", "控制台": "Console", "$%.6f 额度": "$%.6f quota", "或": "or", @@ -165,7 +164,6 @@ "出现错误,第 ${count} 次重试中...": "Error occurred, retry attempt ${count}...", "首页": "Home", "渠道": "Channel", - "渠道管理": "Channels", "令牌": "Tokens", "兑换额度": "Redeem", "充值": "Recharge", @@ -174,7 +172,6 @@ "设置": "Settings", "关于": "About", "价格": "Pricing", - "聊天": "Chat", "注销成功!": "Logout successful!", "注销": "Logout", "登录": "Sign in", @@ -443,9 +440,7 @@ "兑换码": "Redeem Code", "管理用户": "Manage Users", "额度明细": "Quota Details", - "个人设置": "Personal Settings", "运营设置": "Operation Settings", - "系统设置": "System Settings", "其他设置": "Other Settings", "项目仓库地址": "Project Repository Address", "可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown", @@ -585,10 +580,7 @@ "确定是否要修复数据库一致性?": "Are you sure you want to repair database consistency?", "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.", "当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!", - "令牌管理": "API Keys", - "使用日志": "Usage log", "Midjourney日志": "Midjourney", - "数据看板": "Dashboard", "模型列表": "Model list", "常见问题": "FAQ", "免费体验": "Free trial", @@ -628,7 +620,6 @@ "重置成功": "Reset successful", "加载数据出错:": "Error loading data:", "加载数据时发生错误: ": "An error occurred while loading data:", - "保存成功": "Saved successfully", "部分保存失败,请重试": "Partial saving failed, please try again", "请检查输入": "Please check your input", "如何区分不同分组不同模型的价格:供参考的配置方式": "How to distinguish the prices of different models in different groups: configuration method for reference", @@ -653,7 +644,6 @@ "窗口等待": "window wait", "失败": "Failed", "绘图": "Drawing", - "绘图日志": "Drawing log", "放大": "Upscalers", "微妙放大": "Upscale (Subtle)", "创造放大": "Upscale (Creative)", @@ -793,7 +783,6 @@ "邮箱": "Email", "已有账户?": "Already have an account?", "创意任务": "Tasks", - "用户管理": "User Management", "任务ID(点击查看详情)": "Task ID (click to view details)", "进度": "schedule", "花费时间": "spend time", @@ -943,7 +932,6 @@ "不是合法的 JSON 字符串": "Not a valid JSON string", "个人中心": "Personal center", "代理商": "Agent", - "钱包管理": "Wallet", "备注": "Remark", "工作台": "Workbench", "已复制:": "Copied:", @@ -957,7 +945,6 @@ "黑夜模式": "Dark mode", "管理员设置": "Admin", "待更新": "To be updated", - "模型广场": "Pricing", "支付中..": "Paying", "查看图片": "View pictures", "并发限制": "Concurrency limit", @@ -1043,7 +1030,6 @@ "在iframe中加载": "Load in iframe", "补全倍率": "Completion ratio", "保存分组数据失败": "Failed to save group data", - "保存失败,请重试": "Save failed, please try again", "没有可用的使用信息": "No usage information available", "使用详情": "Usage details", "收起": "Collapse", @@ -1187,7 +1173,6 @@ "知识库 ID": "Knowledge Base ID", "请输入知识库 ID,例如:123456": "Please enter knowledge base ID, e.g.: 123456", "可选值": "Optional value", - "任务日志": "Task log", "你好": "Hello", "你好,请问有什么可以帮助您的吗?": "Hello, how may I help you?", "用户分组": "Your default group", @@ -1329,7 +1314,6 @@ "当剩余额度低于此数值时,系统将通过选择的方式发送通知": "When the remaining quota is lower than this value, the system will send a notification through the selected method", "Webhook请求结构": "Webhook request structure", "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求": "Only https is supported, the system will send a notification through POST, please ensure the address can receive POST requests", - "保存设置": "Save settings", "通知邮箱": "Notification email", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used", "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used", @@ -1501,7 +1485,6 @@ "收益": "Earnings", "无邀请人": "No Inviter", "邀请人": "Inviter", - "兑换码管理": "Redemption Code", "设置兑换码的基本信息": "Set redemption code basic information", "设置兑换码的额度和数量": "Set redemption code quota and quantity", "编辑用户": "Edit User", @@ -1595,7 +1578,6 @@ "加载中...": "Loading...", "正在跳转...": "Redirecting...", "暂无公告": "No Notice", - "操练场": "Playground", "欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system", "数据库检查": "Database Check", "验证数据库连接状态": "Verify database connection status", @@ -1811,7 +1793,6 @@ "系统提示覆盖": "System prompt override", "模型: {{ratio}}": "Model: {{ratio}}", "专属倍率": "Exclusive group ratio", - "模型管理": "Models", "匹配类型": "Matching type", "描述": "Description", "供应商": "Vendor", @@ -2075,5 +2056,18 @@ "保存边栏设置": "Save Sidebar Settings", "侧边栏设置保存成功": "Sidebar settings saved successfully", "需要登录访问": "Require Login", - "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace" + "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace", + "同步官方": "Sync official", + "参与官方同步": "Participate in official sync", + "关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)", + "选择要覆盖的冲突项": "Select conflict items to overwrite", + "点击查看差异": "Click to view differences", + "无冲突项": "No conflict items", + "应用覆盖": "Apply overwrite", + "仅会覆盖你勾选的字段,未勾选的字段保持本地不变。": "Only selected fields will be overwritten; unselected fields remain unchanged.", + "本地": "Local", + "官方": "Official", + "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:", + "是": "Yes", + "否": "No" } From a47a37d315f063784fe2775d68b2e16709d944ec Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 02:24:17 +0800 Subject: [PATCH 17/64] =?UTF-8?q?=F0=9F=A7=B9=20refactor(db):=20remove=20l?= =?UTF-8?q?egacy=20models/vendors=20index=20cleanup=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete dropIndexIfExists helper from `model/main.go` - Remove all calls to dropIndexIfExists in `migrateDB` and `migrateDBFast` - Drop related comments and MySQL-only DROP INDEX code paths - Keep GORM AutoMigrate as the sole migration path for `Model` and `Vendor` Why: - Simplifies migrations and avoids destructive index drops at startup - Prevents noisy MySQL 1091 errors and vendor-specific branches - Aligns with composite unique indexes (uk_model_name_delete_at, uk_vendor_name_delete_at) Impact: - No expected runtime behavior change; schema remains managed by GORM - Legacy single-column unique indexes (if any) will no longer be auto-dropped - Safe across MySQL/PostgreSQL/SQLite; MySQL Chinese charset checks remain intact Verification: - Lint passed for `model/main.go` - Confirmed no remaining `DROP INDEX` or `dropIndexIfExists` references --- model/main.go | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/model/main.go b/model/main.go index 0fe9ceef9..1a38d371b 100644 --- a/model/main.go +++ b/model/main.go @@ -64,22 +64,6 @@ var DB *gorm.DB var LOG_DB *gorm.DB -// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors -func dropIndexIfExists(tableName string, indexName string) { - if !common.UsingMySQL { - return - } - var count int64 - // Check index existence via information_schema - err := DB.Raw( - "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", - tableName, indexName, - ).Scan(&count).Error - if err == nil && count > 0 { - _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error - } -} - func createRootAccountIfNeed() error { var user User //if user.Status != common.UserStatusEnabled { @@ -263,16 +247,6 @@ func InitLogDB() (err error) { } func migrateDB() error { - // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 - // 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引 (model_name, deleted_at) 冲突 - dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在) - dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称 - - dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在) - dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称 - // 清理旧索引名(兼容历史),避免与新的复合唯一索引冲突 - // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围 - // 计划:该兼容逻辑将在后续几个版本中移除 err := DB.AutoMigrate( &Channel{}, &Token{}, @@ -299,14 +273,6 @@ func migrateDB() error { } func migrateDBFast() error { - // 清理旧索引名(兼容历史),允许软删除后重新插入同名记录 - // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围 - // 计划:该兼容逻辑将在后续几个版本中移除 - dropIndexIfExists("models", "uk_model_name") - dropIndexIfExists("models", "model_name") - - dropIndexIfExists("vendors", "uk_vendor_name") - dropIndexIfExists("vendors", "name") var wg sync.WaitGroup From 5ac9ebdebbb8b380cfb556ed4aa199f7e1c1ca03 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 03:38:01 +0800 Subject: [PATCH 18/64] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20skeleton=20load?= =?UTF-8?q?ing=20states=20for=20sidebar=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive skeleton screen implementation for sidebar to improve loading UX, matching the existing headerbar skeleton pattern. ## Features Added - **Sidebar skeleton screens**: Complete 1:1 recreation of sidebar structure during loading - **Responsive skeleton layouts**: Different layouts for expanded (164×30px) and collapsed (44×44px) states - **Skeleton component enhancements**: Extended SkeletonWrapper with new skeleton types (sidebar, button, sidebarNavItem, sidebarGroupTitle) - **Minimum loading time**: Integrated useMinimumLoadingTime hook with 500ms duration for smooth UX ## Layout Specifications - **Expanded nav items**: 164×30px with 8px horizontal margins and 3px vertical margins - **Collapsed nav items**: 44×44px with 4px bottom margin and 8px horizontal margins - **Collapse button**: 156×24px (expanded) / 36×24px (collapsed) with rounded corners - **Container padding**: 12px top padding, 8px horizontal margins - **Group labels**: 4px 15px 8px padding matching real sidebar-group-label styles ## Code Improvements - **Refactored skeleton rendering**: Eliminated code duplication using reusable components (NavRow, CollapsedRow) - **Configuration-driven sections**: Sections defined as config objects with title widths and item widths - **Fixed width calculations**: Removed random width generation, using precise fixed widths per menu item - **Proper CSS class alignment**: Uses real sidebar CSS classes (sidebar-section, sidebar-group-label, sidebar-divider) ## UI/UX Enhancements - **Bottom-aligned collapse button**: Fixed positioning using margin-top: auto to stay at viewport bottom - **Accurate spacing**: Matches real sidebar margins, padding, and spacing exactly - **Skeleton stability**: Fixed width values prevent layout shifts during loading - **Clean file structure**: Removed redundant HeaderBar.js export file ## Technical Details - Extended SkeletonWrapper component with sidebar-specific skeleton types - Integrated skeleton loading state management in SiderBar component - Added support for collapsed state awareness in skeleton rendering - Implemented precise dimension matching for pixel-perfect loading states Closes: Sidebar skeleton loading implementation --- web/src/components/dashboard/ChartsPanel.jsx | 47 +-- web/src/components/layout/HeaderBar.js | 20 - .../layout/HeaderBar/HeaderLogo.jsx | 2 +- .../layout/HeaderBar/Navigation.jsx | 2 +- .../layout/HeaderBar/SkeletonWrapper.jsx | 148 ------- .../components/layout/HeaderBar/UserArea.jsx | 2 +- web/src/components/layout/PageLayout.jsx | 2 +- web/src/components/layout/SiderBar.jsx | 227 +++++----- .../layout/components/SkeletonWrapper.jsx | 394 ++++++++++++++++++ .../models/modals/UpstreamConflictModal.jsx | 8 +- web/src/index.css | 1 + 11 files changed, 535 insertions(+), 318 deletions(-) delete mode 100644 web/src/components/layout/HeaderBar.js delete mode 100644 web/src/components/layout/HeaderBar/SkeletonWrapper.jsx create mode 100644 web/src/components/layout/components/SkeletonWrapper.jsx diff --git a/web/src/components/dashboard/ChartsPanel.jsx b/web/src/components/dashboard/ChartsPanel.jsx index dc1684a20..0992adace 100644 --- a/web/src/components/dashboard/ChartsPanel.jsx +++ b/web/src/components/dashboard/ChartsPanel.jsx @@ -20,11 +20,6 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Tabs, TabPane } from '@douyinfe/semi-ui'; import { PieChart } from 'lucide-react'; -import { - IconHistogram, - IconPulse, - IconPieChart2Stroked, -} from '@douyinfe/semi-icons'; import { VChart } from '@visactor/react-vchart'; const ChartsPanel = ({ @@ -51,46 +46,14 @@ const ChartsPanel = ({ {t('模型数据分析')} - - - {t('消耗分布')} - - } - itemKey='1' - /> - - - {t('消耗趋势')} - - } - itemKey='2' - /> - - - {t('调用次数分布')} - - } - itemKey='3' - /> - - - {t('调用次数排行')} - - } - itemKey='4' - /> + {t('消耗分布')}} itemKey='1' /> + {t('消耗趋势')}} itemKey='2' /> + {t('调用次数分布')}} itemKey='3' /> + {t('调用次数排行')}} itemKey='4' /> } diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js deleted file mode 100644 index fc21dc7b0..000000000 --- a/web/src/components/layout/HeaderBar.js +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -export { default } from './HeaderBar/index'; diff --git a/web/src/components/layout/HeaderBar/HeaderLogo.jsx b/web/src/components/layout/HeaderBar/HeaderLogo.jsx index c81e75d2d..73be0516b 100644 --- a/web/src/components/layout/HeaderBar/HeaderLogo.jsx +++ b/web/src/components/layout/HeaderBar/HeaderLogo.jsx @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Link } from 'react-router-dom'; import { Typography, Tag } from '@douyinfe/semi-ui'; -import SkeletonWrapper from './SkeletonWrapper'; +import SkeletonWrapper from '../components/SkeletonWrapper'; const HeaderLogo = ({ isMobile, diff --git a/web/src/components/layout/HeaderBar/Navigation.jsx b/web/src/components/layout/HeaderBar/Navigation.jsx index 3a5e3a3bd..e2a4a696e 100644 --- a/web/src/components/layout/HeaderBar/Navigation.jsx +++ b/web/src/components/layout/HeaderBar/Navigation.jsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Link } from 'react-router-dom'; -import SkeletonWrapper from './SkeletonWrapper'; +import SkeletonWrapper from '../components/SkeletonWrapper'; const Navigation = ({ mainNavLinks, diff --git a/web/src/components/layout/HeaderBar/SkeletonWrapper.jsx b/web/src/components/layout/HeaderBar/SkeletonWrapper.jsx deleted file mode 100644 index c6224450d..000000000 --- a/web/src/components/layout/HeaderBar/SkeletonWrapper.jsx +++ /dev/null @@ -1,148 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Skeleton } from '@douyinfe/semi-ui'; - -const SkeletonWrapper = ({ - loading = false, - type = 'text', - count = 1, - width = 60, - height = 16, - isMobile = false, - className = '', - children, - ...props -}) => { - if (!loading) { - return children; - } - - // 导航链接骨架屏 - const renderNavigationSkeleton = () => { - const skeletonLinkClasses = isMobile - ? 'flex items-center gap-1 p-1 w-full rounded-md' - : 'flex items-center gap-1 p-2 rounded-md'; - - return Array(count) - .fill(null) - .map((_, index) => ( -
- - } - /> -
- )); - }; - - // 用户区域骨架屏 (头像 + 文本) - const renderUserAreaSkeleton = () => { - return ( -
- - } - /> -
- - } - /> -
-
- ); - }; - - // Logo图片骨架屏 - const renderImageSkeleton = () => { - return ( - - } - /> - ); - }; - - // 系统名称骨架屏 - const renderTitleSkeleton = () => { - return ( - } - /> - ); - }; - - // 通用文本骨架屏 - const renderTextSkeleton = () => { - return ( -
- } - /> -
- ); - }; - - // 根据类型渲染不同的骨架屏 - switch (type) { - case 'navigation': - return renderNavigationSkeleton(); - case 'userArea': - return renderUserAreaSkeleton(); - case 'image': - return renderImageSkeleton(); - case 'title': - return renderTitleSkeleton(); - case 'text': - default: - return renderTextSkeleton(); - } -}; - -export default SkeletonWrapper; diff --git a/web/src/components/layout/HeaderBar/UserArea.jsx b/web/src/components/layout/HeaderBar/UserArea.jsx index 5d2c04483..8ea70f47f 100644 --- a/web/src/components/layout/HeaderBar/UserArea.jsx +++ b/web/src/components/layout/HeaderBar/UserArea.jsx @@ -28,7 +28,7 @@ import { IconKey, } from '@douyinfe/semi-icons'; import { stringToColor } from '../../../helpers'; -import SkeletonWrapper from './SkeletonWrapper'; +import SkeletonWrapper from '../components/SkeletonWrapper'; const UserArea = ({ userState, diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index 72df89ebb..f8cdfb0cb 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import HeaderBar from './HeaderBar'; +import HeaderBar from './headerbar'; import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar'; import App from '../../App'; diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 37e55d76c..fad22240d 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -24,7 +24,9 @@ import { getLucideIcon } from '../../helpers/render'; import { ChevronLeft } from 'lucide-react'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useSidebar } from '../../hooks/common/useSidebar'; +import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; import { isAdmin, isRoot, showError } from '../../helpers'; +import SkeletonWrapper from './components/SkeletonWrapper'; import { Nav, Divider, Button } from '@douyinfe/semi-ui'; @@ -56,6 +58,8 @@ const SiderBar = ({ onNavigate = () => {} }) => { loading: sidebarLoading, } = useSidebar(); + const showSkeleton = useMinimumLoadingTime(sidebarLoading, 500); + const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); const [openedKeys, setOpenedKeys] = useState([]); @@ -377,120 +381,137 @@ const SiderBar = ({ onNavigate = () => {} }) => { className='sidebar-container' style={{ width: 'var(--sidebar-current-width)' }} > - + {/* 底部折叠按钮 */}
- + +
); diff --git a/web/src/components/layout/components/SkeletonWrapper.jsx b/web/src/components/layout/components/SkeletonWrapper.jsx new file mode 100644 index 000000000..ba26e6966 --- /dev/null +++ b/web/src/components/layout/components/SkeletonWrapper.jsx @@ -0,0 +1,394 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Skeleton } from '@douyinfe/semi-ui'; + +const SkeletonWrapper = ({ + loading = false, + type = 'text', + count = 1, + width = 60, + height = 16, + isMobile = false, + className = '', + collapsed = false, + showAdmin = true, + children, + ...props +}) => { + if (!loading) { + return children; + } + + // 导航链接骨架屏 + const renderNavigationSkeleton = () => { + const skeletonLinkClasses = isMobile + ? 'flex items-center gap-1 p-1 w-full rounded-md' + : 'flex items-center gap-1 p-2 rounded-md'; + + return Array(count) + .fill(null) + .map((_, index) => ( +
+ + } + /> +
+ )); + }; + + // 用户区域骨架屏 (头像 + 文本) + const renderUserAreaSkeleton = () => { + return ( +
+ + } + /> +
+ + } + /> +
+
+ ); + }; + + // Logo图片骨架屏 + const renderImageSkeleton = () => { + return ( + + } + /> + ); + }; + + // 系统名称骨架屏 + const renderTitleSkeleton = () => { + return ( + } + /> + ); + }; + + // 通用文本骨架屏 + const renderTextSkeleton = () => { + return ( +
+ } + /> +
+ ); + }; + + // 按钮骨架屏(支持圆角) + const renderButtonSkeleton = () => { + return ( +
+ + } + /> +
+ ); + }; + + // 侧边栏导航项骨架屏 (图标 + 文本) + const renderSidebarNavItemSkeleton = () => { + return Array(count) + .fill(null) + .map((_, index) => ( +
+ {/* 图标骨架屏 */} +
+ + } + /> +
+ {/* 文本骨架屏 */} + + } + /> +
+ )); + }; + + // 侧边栏组标题骨架屏 + const renderSidebarGroupTitleSkeleton = () => { + return ( +
+ + } + /> +
+ ); + }; + + // 完整侧边栏骨架屏 - 1:1 还原,去重实现 + const renderSidebarSkeleton = () => { + const NAV_WIDTH = 164; + const NAV_HEIGHT = 30; + const COLLAPSED_WIDTH = 44; + const COLLAPSED_HEIGHT = 44; + const ICON_SIZE = 16; + const TITLE_HEIGHT = 12; + const TEXT_HEIGHT = 16; + + const renderIcon = () => ( + + } + /> + ); + + const renderLabel = (labelWidth) => ( + + } + /> + ); + + const NavRow = ({ labelWidth }) => ( +
+
+ {renderIcon()} +
+ {renderLabel(labelWidth)} +
+ ); + + const CollapsedRow = ({ keyPrefix, index }) => ( +
+ + } + /> +
+ ); + + if (collapsed) { + return ( +
+ {Array(2) + .fill(null) + .map((_, i) => ( + + ))} + {Array(5) + .fill(null) + .map((_, i) => ( + + ))} + {Array(2) + .fill(null) + .map((_, i) => ( + + ))} + {Array(5) + .fill(null) + .map((_, i) => ( + + ))} +
+ ); + } + + const sections = [ + { key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' }, + { key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] }, + { key: 'personal', titleWidth: 64, itemWidths: [64, 64] }, + ...(showAdmin + ? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }] + : []), + ]; + + return ( +
+ {sections.map((sec, idx) => ( + + {sec.wrapper === 'section' ? ( +
+
+ + } + /> +
+ {sec.itemWidths.map((w, i) => ( + + ))} +
+ ) : ( +
+
+ + } + /> +
+ {sec.itemWidths.map((w, i) => ( + + ))} +
+ )} +
+ ))} +
+ ); + }; + + // 根据类型渲染不同的骨架屏 + switch (type) { + case 'navigation': + return renderNavigationSkeleton(); + case 'userArea': + return renderUserAreaSkeleton(); + case 'image': + return renderImageSkeleton(); + case 'title': + return renderTitleSkeleton(); + case 'sidebarNavItem': + return renderSidebarNavItemSkeleton(); + case 'sidebarGroupTitle': + return renderSidebarGroupTitleSkeleton(); + case 'sidebar': + return renderSidebarSkeleton(); + case 'button': + return renderButtonSkeleton(); + case 'text': + default: + return renderTextSkeleton(); + } +}; + +export default SkeletonWrapper; diff --git a/web/src/components/table/models/modals/UpstreamConflictModal.jsx b/web/src/components/table/models/modals/UpstreamConflictModal.jsx index 5b7646639..439166ee6 100644 --- a/web/src/components/table/models/modals/UpstreamConflictModal.jsx +++ b/web/src/components/table/models/modals/UpstreamConflictModal.jsx @@ -91,6 +91,7 @@ const UpstreamConflictModal = ({ { title: t('模型'), dataIndex: 'model_name', + fixed: 'left', render: (text) => {text}, }, ]; @@ -235,7 +236,12 @@ const UpstreamConflictModal = ({
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
-
+
)} diff --git a/web/src/index.css b/web/src/index.css index fbbd76827..a2e1d08c7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -184,6 +184,7 @@ code { justify-content: center; align-items: center; padding: 12px; + margin-top: auto; cursor: pointer; background-color: var(--semi-color-bg-0); position: sticky; From 1442666cc02691e07fb4d65b4f1c89f8f7b15df1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 03:42:31 +0800 Subject: [PATCH 19/64] =?UTF-8?q?=F0=9F=8C=8F=20i18n:=20replace=20to=20cor?= =?UTF-8?q?rect=20punctuation=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1d113f106..0271efdb0 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2064,7 +2064,7 @@ "点击查看差异": "Click to view differences", "无冲突项": "No conflict items", "应用覆盖": "Apply overwrite", - "仅会覆盖你勾选的字段,未勾选的字段保持本地不变。": "Only selected fields will be overwritten; unselected fields remain unchanged.", + "仅会覆盖你勾选的字段,未勾选的字段保持本地不变。": "Only selected fields will be overwritten, unselected fields remain unchanged.", "本地": "Local", "官方": "Official", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:", From 860ab51434414b9196ae39557385671ef2120c02 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 03:54:32 +0800 Subject: [PATCH 20/64] =?UTF-8?q?=F0=9F=90=9B=20fix(web/layout):=20explici?= =?UTF-8?q?tly=20import=20headerbar/index.jsx=20to=20resolve=20Linux=20bui?= =?UTF-8?q?ld=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux/Vite build failed with: “Could not resolve "./headerbar" from "src/components/layout/PageLayout.jsx"” On Linux and with stricter ESM/rollup resolution, directory index files (index.jsx) may not be auto-resolved reliably. Explicitly importing the index file ensures cross-platform stability. Changes: - Update PageLayout import from "./headerbar" to "./headerbar/index.jsx" Impact: - Fixes build on Linux - No runtime behavior changes Verification: - Linter passes for web/src/components/layout/PageLayout.jsx Notes: - Prefer explicit index file imports (and extensions) to avoid platform differences. --- web/src/components/layout/PageLayout.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index f8cdfb0cb..adb57306c 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import HeaderBar from './headerbar'; +import HeaderBar from './headerbar/index.jsx'; import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar'; import App from '../../App'; From daffba3641041a86bf319a222bc9c455c69e5647 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 04:03:19 +0800 Subject: [PATCH 21/64] =?UTF-8?q?=F0=9F=A4=96=20fix(web/layout):=20rename?= =?UTF-8?q?=20HeaderBar=20->=20headerbar=20(case=20sensitive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/layout/PageLayout.jsx | 2 +- .../layout/headerbar/ActionButtons.jsx | 74 +++++++ .../layout/headerbar/HeaderLogo.jsx | 81 ++++++++ .../layout/headerbar/LanguageSelector.jsx | 59 ++++++ .../layout/headerbar/MobileMenuButton.jsx | 56 +++++ .../layout/headerbar/Navigation.jsx | 88 ++++++++ .../layout/headerbar/NewYearButton.jsx | 62 ++++++ .../layout/headerbar/NotificationButton.jsx | 46 ++++ .../layout/headerbar/ThemeToggle.jsx | 109 ++++++++++ .../components/layout/headerbar/UserArea.jsx | 196 ++++++++++++++++++ web/src/components/layout/headerbar/index.jsx | 132 ++++++++++++ 11 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 web/src/components/layout/headerbar/ActionButtons.jsx create mode 100644 web/src/components/layout/headerbar/HeaderLogo.jsx create mode 100644 web/src/components/layout/headerbar/LanguageSelector.jsx create mode 100644 web/src/components/layout/headerbar/MobileMenuButton.jsx create mode 100644 web/src/components/layout/headerbar/Navigation.jsx create mode 100644 web/src/components/layout/headerbar/NewYearButton.jsx create mode 100644 web/src/components/layout/headerbar/NotificationButton.jsx create mode 100644 web/src/components/layout/headerbar/ThemeToggle.jsx create mode 100644 web/src/components/layout/headerbar/UserArea.jsx create mode 100644 web/src/components/layout/headerbar/index.jsx diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index adb57306c..f8cdfb0cb 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import HeaderBar from './headerbar/index.jsx'; +import HeaderBar from './headerbar'; import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar'; import App from '../../App'; diff --git a/web/src/components/layout/headerbar/ActionButtons.jsx b/web/src/components/layout/headerbar/ActionButtons.jsx new file mode 100644 index 000000000..545b5227b --- /dev/null +++ b/web/src/components/layout/headerbar/ActionButtons.jsx @@ -0,0 +1,74 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import NewYearButton from './NewYearButton'; +import NotificationButton from './NotificationButton'; +import ThemeToggle from './ThemeToggle'; +import LanguageSelector from './LanguageSelector'; +import UserArea from './UserArea'; + +const ActionButtons = ({ + isNewYear, + unreadCount, + onNoticeOpen, + theme, + onThemeToggle, + currentLang, + onLanguageChange, + userState, + isLoading, + isMobile, + isSelfUseMode, + logout, + navigate, + t, +}) => { + return ( +
+ + + + + + + + + +
+ ); +}; + +export default ActionButtons; diff --git a/web/src/components/layout/headerbar/HeaderLogo.jsx b/web/src/components/layout/headerbar/HeaderLogo.jsx new file mode 100644 index 000000000..73be0516b --- /dev/null +++ b/web/src/components/layout/headerbar/HeaderLogo.jsx @@ -0,0 +1,81 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Typography, Tag } from '@douyinfe/semi-ui'; +import SkeletonWrapper from '../components/SkeletonWrapper'; + +const HeaderLogo = ({ + isMobile, + isConsoleRoute, + logo, + logoLoaded, + isLoading, + systemName, + isSelfUseMode, + isDemoSiteMode, + t, +}) => { + if (isMobile && isConsoleRoute) { + return null; + } + + return ( + +
+ + logo +
+
+
+ + + {systemName} + + + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + + )} +
+
+ + ); +}; + +export default HeaderLogo; diff --git a/web/src/components/layout/headerbar/LanguageSelector.jsx b/web/src/components/layout/headerbar/LanguageSelector.jsx new file mode 100644 index 000000000..cbfd69b35 --- /dev/null +++ b/web/src/components/layout/headerbar/LanguageSelector.jsx @@ -0,0 +1,59 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button, Dropdown } from '@douyinfe/semi-ui'; +import { Languages } from 'lucide-react'; +import { CN, GB } from 'country-flag-icons/react/3x2'; + +const LanguageSelector = ({ currentLang, onLanguageChange, t }) => { + return ( + + onLanguageChange('zh')} + className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`} + > + + 中文 + + onLanguageChange('en')} + className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`} + > + + English + + + } + > + + + ); + } else { + const showRegisterButton = !isSelfUseMode; + + const commonSizingAndLayoutClass = + 'flex items-center justify-center !py-[10px] !px-1.5'; + + const loginButtonSpecificStyling = + '!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'; + let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; + + let registerButtonClasses = `${commonSizingAndLayoutClass}`; + + const loginButtonTextSpanClass = + '!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5'; + const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5'; + + if (showRegisterButton) { + if (isMobile) { + loginButtonClasses += ' !rounded-full'; + } else { + loginButtonClasses += ' !rounded-l-full !rounded-r-none'; + } + registerButtonClasses += ' !rounded-r-full !rounded-l-none'; + } else { + loginButtonClasses += ' !rounded-full'; + } + + return ( +
+ + + + {showRegisterButton && ( +
+ + + +
+ )} +
+ ); + } +}; + +export default UserArea; diff --git a/web/src/components/layout/headerbar/index.jsx b/web/src/components/layout/headerbar/index.jsx new file mode 100644 index 000000000..81b51d7fe --- /dev/null +++ b/web/src/components/layout/headerbar/index.jsx @@ -0,0 +1,132 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { useHeaderBar } from '../../../hooks/common/useHeaderBar'; +import { useNotifications } from '../../../hooks/common/useNotifications'; +import { useNavigation } from '../../../hooks/common/useNavigation'; +import NoticeModal from '../NoticeModal'; +import MobileMenuButton from './MobileMenuButton'; +import HeaderLogo from './HeaderLogo'; +import Navigation from './Navigation'; +import ActionButtons from './ActionButtons'; + +const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { + const { + userState, + statusState, + isMobile, + collapsed, + logoLoaded, + currentLang, + isLoading, + systemName, + logo, + isNewYear, + isSelfUseMode, + docsLink, + isDemoSiteMode, + isConsoleRoute, + theme, + headerNavModules, + pricingRequireAuth, + logout, + handleLanguageChange, + handleThemeToggle, + handleMobileMenuToggle, + navigate, + t, + } = useHeaderBar({ onMobileMenuToggle, drawerOpen }); + + const { + noticeVisible, + unreadCount, + handleNoticeOpen, + handleNoticeClose, + getUnreadKeys, + } = useNotifications(statusState); + + const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules); + + return ( +
+ 0 ? 'system' : 'inApp'} + unreadKeys={getUnreadKeys()} + /> + +
+
+
+ + + +
+ + + + +
+
+
+ ); +}; + +export default HeaderBar; From 5cbd9da3f550149ade0b227d5a83911f5336b163 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 04:10:32 +0800 Subject: [PATCH 22/64] fix(web/layout): normalize HeaderBar -> headerbar (case) --- .../layout/HeaderBar/ActionButtons.jsx | 74 ------- .../layout/HeaderBar/HeaderLogo.jsx | 81 -------- .../layout/HeaderBar/LanguageSelector.jsx | 59 ------ .../layout/HeaderBar/MobileMenuButton.jsx | 56 ----- .../layout/HeaderBar/Navigation.jsx | 88 -------- .../layout/HeaderBar/NewYearButton.jsx | 62 ------ .../layout/HeaderBar/NotificationButton.jsx | 46 ---- .../layout/HeaderBar/ThemeToggle.jsx | 109 ---------- .../components/layout/HeaderBar/UserArea.jsx | 196 ------------------ web/src/components/layout/HeaderBar/index.jsx | 132 ------------ 10 files changed, 903 deletions(-) delete mode 100644 web/src/components/layout/HeaderBar/ActionButtons.jsx delete mode 100644 web/src/components/layout/HeaderBar/HeaderLogo.jsx delete mode 100644 web/src/components/layout/HeaderBar/LanguageSelector.jsx delete mode 100644 web/src/components/layout/HeaderBar/MobileMenuButton.jsx delete mode 100644 web/src/components/layout/HeaderBar/Navigation.jsx delete mode 100644 web/src/components/layout/HeaderBar/NewYearButton.jsx delete mode 100644 web/src/components/layout/HeaderBar/NotificationButton.jsx delete mode 100644 web/src/components/layout/HeaderBar/ThemeToggle.jsx delete mode 100644 web/src/components/layout/HeaderBar/UserArea.jsx delete mode 100644 web/src/components/layout/HeaderBar/index.jsx diff --git a/web/src/components/layout/HeaderBar/ActionButtons.jsx b/web/src/components/layout/HeaderBar/ActionButtons.jsx deleted file mode 100644 index 545b5227b..000000000 --- a/web/src/components/layout/HeaderBar/ActionButtons.jsx +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import NewYearButton from './NewYearButton'; -import NotificationButton from './NotificationButton'; -import ThemeToggle from './ThemeToggle'; -import LanguageSelector from './LanguageSelector'; -import UserArea from './UserArea'; - -const ActionButtons = ({ - isNewYear, - unreadCount, - onNoticeOpen, - theme, - onThemeToggle, - currentLang, - onLanguageChange, - userState, - isLoading, - isMobile, - isSelfUseMode, - logout, - navigate, - t, -}) => { - return ( -
- - - - - - - - - -
- ); -}; - -export default ActionButtons; diff --git a/web/src/components/layout/HeaderBar/HeaderLogo.jsx b/web/src/components/layout/HeaderBar/HeaderLogo.jsx deleted file mode 100644 index 73be0516b..000000000 --- a/web/src/components/layout/HeaderBar/HeaderLogo.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Link } from 'react-router-dom'; -import { Typography, Tag } from '@douyinfe/semi-ui'; -import SkeletonWrapper from '../components/SkeletonWrapper'; - -const HeaderLogo = ({ - isMobile, - isConsoleRoute, - logo, - logoLoaded, - isLoading, - systemName, - isSelfUseMode, - isDemoSiteMode, - t, -}) => { - if (isMobile && isConsoleRoute) { - return null; - } - - return ( - -
- - logo -
-
-
- - - {systemName} - - - {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( - - {isSelfUseMode ? t('自用模式') : t('演示站点')} - - )} -
-
- - ); -}; - -export default HeaderLogo; diff --git a/web/src/components/layout/HeaderBar/LanguageSelector.jsx b/web/src/components/layout/HeaderBar/LanguageSelector.jsx deleted file mode 100644 index cbfd69b35..000000000 --- a/web/src/components/layout/HeaderBar/LanguageSelector.jsx +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Button, Dropdown } from '@douyinfe/semi-ui'; -import { Languages } from 'lucide-react'; -import { CN, GB } from 'country-flag-icons/react/3x2'; - -const LanguageSelector = ({ currentLang, onLanguageChange, t }) => { - return ( - - onLanguageChange('zh')} - className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`} - > - - 中文 - - onLanguageChange('en')} - className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`} - > - - English - - - } - > - - - ); - } else { - const showRegisterButton = !isSelfUseMode; - - const commonSizingAndLayoutClass = - 'flex items-center justify-center !py-[10px] !px-1.5'; - - const loginButtonSpecificStyling = - '!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'; - let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; - - let registerButtonClasses = `${commonSizingAndLayoutClass}`; - - const loginButtonTextSpanClass = - '!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5'; - const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5'; - - if (showRegisterButton) { - if (isMobile) { - loginButtonClasses += ' !rounded-full'; - } else { - loginButtonClasses += ' !rounded-l-full !rounded-r-none'; - } - registerButtonClasses += ' !rounded-r-full !rounded-l-none'; - } else { - loginButtonClasses += ' !rounded-full'; - } - - return ( -
- - - - {showRegisterButton && ( -
- - - -
- )} -
- ); - } -}; - -export default UserArea; diff --git a/web/src/components/layout/HeaderBar/index.jsx b/web/src/components/layout/HeaderBar/index.jsx deleted file mode 100644 index 81b51d7fe..000000000 --- a/web/src/components/layout/HeaderBar/index.jsx +++ /dev/null @@ -1,132 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { useHeaderBar } from '../../../hooks/common/useHeaderBar'; -import { useNotifications } from '../../../hooks/common/useNotifications'; -import { useNavigation } from '../../../hooks/common/useNavigation'; -import NoticeModal from '../NoticeModal'; -import MobileMenuButton from './MobileMenuButton'; -import HeaderLogo from './HeaderLogo'; -import Navigation from './Navigation'; -import ActionButtons from './ActionButtons'; - -const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { - const { - userState, - statusState, - isMobile, - collapsed, - logoLoaded, - currentLang, - isLoading, - systemName, - logo, - isNewYear, - isSelfUseMode, - docsLink, - isDemoSiteMode, - isConsoleRoute, - theme, - headerNavModules, - pricingRequireAuth, - logout, - handleLanguageChange, - handleThemeToggle, - handleMobileMenuToggle, - navigate, - t, - } = useHeaderBar({ onMobileMenuToggle, drawerOpen }); - - const { - noticeVisible, - unreadCount, - handleNoticeOpen, - handleNoticeClose, - getUnreadKeys, - } = useNotifications(statusState); - - const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules); - - return ( -
- 0 ? 'system' : 'inApp'} - unreadKeys={getUnreadKeys()} - /> - -
-
-
- - - -
- - - - -
-
-
- ); -}; - -export default HeaderBar; From f7ae3621f402563b20c693f0a903df8c64ef1027 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 2 Sep 2025 10:49:06 +0800 Subject: [PATCH 23/64] feat: use audio token usage if return --- relay/channel/openai/relay-openai.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index cce9235b5..4b13a7df1 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -2,6 +2,7 @@ package openai import ( "bytes" + "encoding/json" "fmt" "io" "math" @@ -280,11 +281,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { defer service.CloseResponseBodyGracefully(resp) - // count tokens by audio file duration - audioTokens, err := countAudioTokens(c) - if err != nil { - return types.NewError(err, types.ErrorCodeCountTokenFailed), nil - } responseBody, err := io.ReadAll(resp.Body) if err != nil { return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil @@ -292,6 +288,26 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel // 写入新的 response body service.IOCopyBytesGracefully(c, resp, responseBody) + var responseData struct { + Usage *dto.Usage `json:"usage"` + } + if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil { + if responseData.Usage.TotalTokens > 0 { + usage := responseData.Usage + if usage.PromptTokens == 0 { + usage.PromptTokens = usage.InputTokens + } + if usage.CompletionTokens == 0 { + usage.CompletionTokens = usage.OutputTokens + } + return nil, usage + } + } + + audioTokens, err := countAudioTokens(c) + if err != nil { + return types.NewError(err, types.ErrorCodeCountTokenFailed), nil + } usage := &dto.Usage{} usage.PromptTokens = audioTokens usage.CompletionTokens = 0 From 203abf4430a005da66e4d35957c1b5187c6b4f9d Mon Sep 17 00:00:00 2001 From: xuebin Date: Tue, 19 Aug 2025 16:37:19 +0800 Subject: [PATCH 24/64] =?UTF-8?q?openai=20v1/models=20=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20=E8=A7=A3=E5=86=B3=E6=8E=A5=E5=85=A5trae?= =?UTF-8?q?=E6=97=B6=E5=80=99=E7=9A=84=E5=AD=97=E6=AE=B5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/model.go | 1 + 1 file changed, 1 insertion(+) diff --git a/controller/model.go b/controller/model.go index 31a66b297..5c0688213 100644 --- a/controller/model.go +++ b/controller/model.go @@ -174,6 +174,7 @@ func ListModels(c *gin.Context) { c.JSON(200, gin.H{ "success": true, "data": userOpenAiModels, + "object": "list", }) } From d9f37d16f7f9e296d7bf0e12f75ad2ce3e54153f Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 17:07:01 +0800 Subject: [PATCH 25/64] =?UTF-8?q?=F0=9F=8E=A8=20fix:=20sidebar=20skeleton?= =?UTF-8?q?=20background=20and=20icon=20spacing=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set sidebar skeleton background to use theme variable (--semi-color-bg-0) instead of hardcoded white for better dark mode compatibility - Apply consistent background to both collapsed and expanded skeleton states - Ensure sidebar container uses theme background when skeleton is loading - Remove duplicate margin-right classes from skeleton wrapper components that conflicted with CSS definitions - Simplify navigation text structure by removing unnecessary div wrappers to improve text truncation - Add proper text layout styles for better truncation handling when menu items have long names - Standardize icon-to-text spacing across all sidebar navigation items --- web/src/components/layout/SiderBar.jsx | 35 +++++++++---------- .../layout/components/SkeletonWrapper.jsx | 4 +-- web/src/index.css | 5 +++ 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index fad22240d..793e48355 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -58,7 +58,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { loading: sidebarLoading, } = useSidebar(); - const showSkeleton = useMinimumLoadingTime(sidebarLoading, 500); + const showSkeleton = useMinimumLoadingTime(sidebarLoading); const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); @@ -305,14 +305,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { key={item.itemKey} itemKey={item.itemKey} text={ -
- - {item.text} - -
+ + {item.text} + } icon={
@@ -335,14 +333,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { key={item.itemKey} itemKey={item.itemKey} text={ -
- - {item.text} - -
+ + {item.text} + } icon={
@@ -379,7 +375,10 @@ const SiderBar = ({ onNavigate = () => {} }) => { return (
{/* 图标骨架屏 */} -
+
-
+
{renderIcon()}
{renderLabel(labelWidth)} diff --git a/web/src/index.css b/web/src/index.css index a2e1d08c7..251bf79c6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -71,6 +71,11 @@ code { margin-right: 0; } +.sidebar-nav .semi-navigation-item-text { + flex: 1; + min-width: 0; +} + .semi-navigation-item, .semi-navigation-sub-title { height: 100% !important; From faad6bcd0cf5d4753d5e743362a80e7f09e2add8 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 2 Sep 2025 18:28:23 +0800 Subject: [PATCH 26/64] fix: adjust column spans in JSONEditor for better layout #1719 --- controller/model.go | 2 +- web/src/components/common/ui/JSONEditor.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/controller/model.go b/controller/model.go index c9cb24d9b..f0571b995 100644 --- a/controller/model.go +++ b/controller/model.go @@ -207,7 +207,7 @@ func ListModels(c *gin.Context, modelType int) { c.JSON(200, gin.H{ "success": true, "data": userOpenAiModels, - "object": "list", + "object": "list", }) } } diff --git a/web/src/components/common/ui/JSONEditor.jsx b/web/src/components/common/ui/JSONEditor.jsx index 7acdc2e37..d89753872 100644 --- a/web/src/components/common/ui/JSONEditor.jsx +++ b/web/src/components/common/ui/JSONEditor.jsx @@ -443,7 +443,7 @@ const JSONEditor = ({ return ( -
+
-
{renderValueInput(pair.id, pair.value)} + {renderValueInput(pair.id, pair.value)} @@ -196,6 +202,20 @@ const ModelsActions = ({ + setShowSyncModal(false)} + loading={syncing || previewing} + t={t} + onConfirm={async ({ option, locale }) => { + setSyncLocale(locale); + if (option === 'official') { + await handleSyncUpstream(locale); + } + setShowSyncModal(false); + }} + /> + setShowMissingModal(false)} @@ -224,7 +244,10 @@ const ModelsActions = ({ onClose={() => setShowConflict(false)} conflicts={conflicts} onSubmit={async (payload) => { - return await applyUpstreamOverwrite?.(payload); + return await applyUpstreamOverwrite?.({ + ...payload, + locale: syncLocale, + }); }} t={t} loading={syncing} diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx index 0f9462b1e..f8fa78fdd 100644 --- a/web/src/components/table/models/modals/MissingModelsModal.jsx +++ b/web/src/components/table/models/modals/MissingModelsModal.jsx @@ -96,7 +96,7 @@ const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => { title: '', dataIndex: 'operate', fixed: 'right', - width: 100, + width: 120, render: (text, record) => ( + )} + + {step === 0 && ( + + )} + {step === 1 && ( + + )} + + } + width={isMobile ? '100%' : 'small'} + > +
+ + + + +
+ + {step === 0 && ( +
+ setOption(e?.target?.value ?? e)} + type='card' + direction='horizontal' + aria-label='同步方式选择' + name='sync-mode-selection' + > + + {t('官方模型同步')} + + + {t('配置文件同步')} + + +
+ )} + + {step === 1 && ( +
+
+ {t('请选择同步语言')} +
+
+ setLocale(e?.target?.value ?? e)} + type='card' + direction='horizontal' + aria-label='语言选择' + name='sync-locale-selection' + > + + EN + + + ZH + + + JA + + +
+
+ )} + + ); +}; + +export default SyncWizardModal; diff --git a/web/src/components/table/models/modals/UpstreamConflictModal.jsx b/web/src/components/table/models/modals/UpstreamConflictModal.jsx index 439166ee6..8682ccb64 100644 --- a/web/src/components/table/models/modals/UpstreamConflictModal.jsx +++ b/web/src/components/table/models/modals/UpstreamConflictModal.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { Modal, Table, @@ -26,9 +26,12 @@ import { Empty, Tag, Popover, + Input, } from '@douyinfe/semi-ui'; import { MousePointerClick } from 'lucide-react'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants'; +import { IconSearch } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -52,6 +55,8 @@ const UpstreamConflictModal = ({ }) => { const [selections, setSelections] = useState({}); const isMobile = useIsMobile(); + const [currentPage, setCurrentPage] = useState(1); + const [searchKeyword, setSearchKeyword] = useState(''); const formatValue = (v) => { if (v === null || v === undefined) return '-'; @@ -70,12 +75,14 @@ const UpstreamConflictModal = ({ init[item.model_name] = new Set(); }); setSelections(init); + setCurrentPage(1); + setSearchKeyword(''); } else { setSelections({}); } }, [visible, conflicts]); - const toggleField = (modelName, field, checked) => { + const toggleField = useCallback((modelName, field, checked) => { setSelections((prev) => { const next = { ...prev }; const set = new Set(next[modelName] || []); @@ -84,7 +91,67 @@ const UpstreamConflictModal = ({ next[modelName] = set; return next; }); - }; + }, []); + + // 构造数据源与过滤后的数据源 + const dataSource = useMemo( + () => + (conflicts || []).map((c) => ({ + key: c.model_name, + model_name: c.model_name, + fields: c.fields || [], + })), + [conflicts], + ); + + const filteredDataSource = useMemo(() => { + const kw = (searchKeyword || '').toLowerCase(); + if (!kw) return dataSource; + return dataSource.filter((item) => + (item.model_name || '').toLowerCase().includes(kw), + ); + }, [dataSource, searchKeyword]); + + // 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置 + const getPresentRowsForField = useCallback( + (fieldKey) => + (filteredDataSource || []).filter((row) => + (row.fields || []).some((f) => f.field === fieldKey), + ), + [filteredDataSource], + ); + + const getHeaderState = useCallback( + (fieldKey) => { + const presentRows = getPresentRowsForField(fieldKey); + const selectedCount = presentRows.filter((row) => + selections[row.model_name]?.has(fieldKey), + ).length; + const allCount = presentRows.length; + return { + headerChecked: allCount > 0 && selectedCount === allCount, + headerIndeterminate: selectedCount > 0 && selectedCount < allCount, + hasAny: allCount > 0, + }; + }, + [getPresentRowsForField, selections], + ); + + const applyHeaderChange = useCallback( + (fieldKey, checked) => { + setSelections((prev) => { + const next = { ...prev }; + getPresentRowsForField(fieldKey).forEach((row) => { + const set = new Set(next[row.model_name] || []); + if (checked) set.add(fieldKey); + else set.delete(fieldKey); + next[row.model_name] = set; + }); + return next; + }); + }, + [getPresentRowsForField], + ); const columns = useMemo(() => { const base = [ @@ -100,37 +167,11 @@ const UpstreamConflictModal = ({ const rawLabel = FIELD_LABELS[fieldKey] || fieldKey; const label = t(rawLabel); - // 统计列头复选框状态(仅统计存在该字段冲突的行) - const presentRows = (conflicts || []).filter((row) => - (row.fields || []).some((f) => f.field === fieldKey), - ); - const selectedCount = presentRows.filter((row) => - selections[row.model_name]?.has(fieldKey), - ).length; - const allCount = presentRows.length; - if (allCount === 0) { - return null; // 若此字段在所有行中都不存在,则不展示该列 - } - const headerChecked = allCount > 0 && selectedCount === allCount; - const headerIndeterminate = selectedCount > 0 && selectedCount < allCount; - - const onHeaderChange = (e) => { - const checked = e?.target?.checked; - setSelections((prev) => { - const next = { ...prev }; - (conflicts || []).forEach((row) => { - const hasField = (row.fields || []).some( - (f) => f.field === fieldKey, - ); - if (!hasField) return; - const set = new Set(next[row.model_name] || []); - if (checked) set.add(fieldKey); - else set.delete(fieldKey); - next[row.model_name] = set; - }); - return next; - }); - }; + const { headerChecked, headerIndeterminate, hasAny } = + getHeaderState(fieldKey); + if (!hasAny) return null; + const onHeaderChange = (e) => + applyHeaderChange(fieldKey, e?.target?.checked); return { title: ( @@ -194,13 +235,20 @@ const UpstreamConflictModal = ({ }); return [...base, ...cols.filter(Boolean)]; - }, [t, selections, conflicts]); + }, [ + t, + selections, + filteredDataSource, + getHeaderState, + applyHeaderChange, + toggleField, + ]); - const dataSource = conflicts.map((c) => ({ - key: c.model_name, - model_name: c.model_name, - fields: c.fields || [], - })); + const pagedDataSource = useMemo(() => { + const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE; + const end = start + MODEL_TABLE_PAGE_SIZE; + return filteredDataSource.slice(start, end); + }, [filteredDataSource, currentPage]); const handleOk = async () => { const payload = Object.entries(selections) @@ -236,12 +284,41 @@ const UpstreamConflictModal = ({
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
-
+ {/* 搜索框 */} +
+ { + setSearchKeyword(v); + setCurrentPage(1); + }} + className='!w-full' + prefix={} + showClear + /> +
+ {filteredDataSource.length > 0 ? ( +
setCurrentPage(page), + }} + scroll={{ x: 'max-content' }} + /> + ) : ( + + )} )} diff --git a/web/src/hooks/models/useModelsData.jsx b/web/src/hooks/models/useModelsData.jsx index e2068840e..57b4bea3d 100644 --- a/web/src/hooks/models/useModelsData.jsx +++ b/web/src/hooks/models/useModelsData.jsx @@ -166,10 +166,13 @@ export const useModelsData = () => { }; // Sync upstream models/vendors for missing models only - const syncUpstream = async () => { + const syncUpstream = async (opts = {}) => { + const locale = opts?.locale; setSyncing(true); try { - const res = await API.post('/api/models/sync_upstream'); + const body = {}; + if (locale) body.locale = locale; + const res = await API.post('/api/models/sync_upstream', body); const { success, message, data } = res.data || {}; if (success) { const createdModels = data?.created_models || 0; @@ -192,10 +195,12 @@ export const useModelsData = () => { }; // Preview upstream differences - const previewUpstreamDiff = async () => { + const previewUpstreamDiff = async (opts = {}) => { + const locale = opts?.locale; setPreviewing(true); try { - const res = await API.get('/api/models/sync_upstream/preview'); + const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`; + const res = await API.get(url); const { success, message, data } = res.data || {}; if (success) { return data || { missing: [], conflicts: [] }; @@ -211,10 +216,15 @@ export const useModelsData = () => { }; // Apply selected overwrite - const applyUpstreamOverwrite = async (overwrite = []) => { + const applyUpstreamOverwrite = async (payloadOrArray = []) => { + const isArray = Array.isArray(payloadOrArray); + const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || []; + const locale = isArray ? undefined : payloadOrArray.locale; setSyncing(true); try { - const res = await API.post('/api/models/sync_upstream', { overwrite }); + const body = { overwrite }; + if (locale) body.locale = locale; + const res = await API.post('/api/models/sync_upstream', body); const { success, message, data } = res.data || {}; if (success) { const createdModels = data?.created_models || 0; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0271efdb0..2574c1832 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1801,7 +1801,7 @@ "已绑定渠道": "Bound channels", "更新时间": "Update time", "未配置模型": "No model configured", - "预填组管理": "Pre-filled group management", + "预填组管理": "Pre-filled group", "搜索供应商": "Search vendor", "新增供应商": "Add vendor", "创建新的模型": "Create new model", @@ -2057,9 +2057,20 @@ "侧边栏设置保存成功": "Sidebar settings saved successfully", "需要登录访问": "Require Login", "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace", - "同步官方": "Sync official", "参与官方同步": "Participate in official sync", "关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)", + "同步": "Sync", + "同步向导": "Sync Wizard", + "选择方式": "Select method", + "选择同步来源": "Select sync source", + "选择语言": "Select language", + "选择同步语言": "Select sync language", + "请选择同步语言": "Please select sync language", + "从官方模型库同步": "Sync from official model library", + "官方模型同步": "Official models sync", + "从配置文件同步": "Sync from config file", + "配置文件同步": "Config file sync", + "开始同步": "Start sync", "选择要覆盖的冲突项": "Select conflict items to overwrite", "点击查看差异": "Click to view differences", "无冲突项": "No conflict items", From fa7ba4a390e43bd4f9fb0d8602d4f68fd581c797 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 19:07:17 +0800 Subject: [PATCH 28/64] =?UTF-8?q?=F0=9F=90=9B=20fix(models=20sync):=20send?= =?UTF-8?q?=20correct=20overwrite=20payload=20and=20drop=20fallback=20Ensu?= =?UTF-8?q?re=20UpstreamConflictModal=20submits=20{=20overwrite:=20payload?= =?UTF-8?q?,=20locale=20}=20instead=20of=20spreading=20an=20array=20into?= =?UTF-8?q?=20an=20object=20Remove=20numeric-key=20fallback=20from=20apply?= =?UTF-8?q?UpstreamOverwrite=20for=20simpler=20and=20explicit=20logic=20Ef?= =?UTF-8?q?fect:=20selected=20fields=20are=20now=20actually=20updated;=20s?= =?UTF-8?q?uccess=20message=20shows=20updated=20model=20count=20Refs:=20ba?= =?UTF-8?q?ckend=20SyncUpstreamModels=20expects=20overwrite:=20overwriteFi?= =?UTF-8?q?eld[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/table/models/ModelsActions.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index 929e3557b..7e8f163bf 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -245,7 +245,7 @@ const ModelsActions = ({ conflicts={conflicts} onSubmit={async (payload) => { return await applyUpstreamOverwrite?.({ - ...payload, + overwrite: payload, locale: syncLocale, }); }} From ebaaecb9d903e9b44bfc42c881c451cf1888c784 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 3 Sep 2025 00:06:27 +0800 Subject: [PATCH 29/64] =?UTF-8?q?=F0=9F=90=9B=20fix(models-sync):=20allow?= =?UTF-8?q?=20sync=20when=20no=20conflicts=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When syncing official models, clicking "Apply overwrite" with zero selected conflict fields resulted in no request being sent and the modal simply closing. This blocked creation of missing models/vendors even though the backend supports an empty `overwrite` array and will still create missing items. Changes: - Remove the early-return guard in `UpstreamConflictModal.handleOk` - Always call `onSubmit(payload)` even when `payload` is empty - Keep closing behavior when the request succeeds Behavior: - Users can now proceed with upstream sync without selecting any conflict fields - Missing models/vendors are created as expected - Existing models are not overwritten unless fields are explicitly selected Affected: - web/src/components/table/models/modals/UpstreamConflictModal.jsx Quality: - Lint passes - No breaking changes - No visual/UI changes beyond the intended behavior Test plan: 1) Open official models sync and trigger a conflicts preview 2) Click "Apply overwrite" without selecting any fields 3) Expect the sync to proceed and a success toast indicating created models 4) Re-try with some fields selected to confirm overwrites still work --- .../components/table/models/modals/UpstreamConflictModal.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/src/components/table/models/modals/UpstreamConflictModal.jsx b/web/src/components/table/models/modals/UpstreamConflictModal.jsx index 8682ccb64..3993f6dc7 100644 --- a/web/src/components/table/models/modals/UpstreamConflictModal.jsx +++ b/web/src/components/table/models/modals/UpstreamConflictModal.jsx @@ -258,10 +258,6 @@ const UpstreamConflictModal = ({ })) .filter((x) => x.fields.length > 0); - if (payload.length === 0) { - onClose?.(); - return; - } const ok = await onSubmit?.(payload); if (ok) onClose?.(); }; From 56fc3441da1eb9349857115c1a34011f525b9f99 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 3 Sep 2025 14:00:52 +0800 Subject: [PATCH 30/64] feat(monitor_setting): implement automatic channel testing configuration --- controller/channel-test.go | 34 +++++--- main.go | 10 +-- relay/common/relay_utils.go | 6 +- service/file_decoder.go | 3 + service/token_counter.go | 80 +------------------ setting/operation_setting/monitor_setting.go | 34 ++++++++ .../components/settings/OperationSetting.jsx | 7 +- .../Setting/Operation/SettingsMonitoring.jsx | 36 +++++++++ 8 files changed, 107 insertions(+), 103 deletions(-) create mode 100644 setting/operation_setting/monitor_setting.go diff --git a/controller/channel-test.go b/controller/channel-test.go index 81f7e19ab..5fc6d749c 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -20,6 +20,7 @@ import ( relayconstant "one-api/relay/constant" "one-api/relay/helper" "one-api/service" + "one-api/setting/operation_setting" "one-api/types" "strconv" "strings" @@ -477,15 +478,26 @@ func TestAllChannels(c *gin.Context) { return } -func AutomaticallyTestChannels(frequency int) { - if frequency <= 0 { - common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test") - return - } - for { - time.Sleep(time.Duration(frequency) * time.Minute) - common.SysLog("testing all channels") - _ = testAllChannels(false) - common.SysLog("channel test finished") - } +var autoTestChannelsOnce sync.Once + +func AutomaticallyTestChannels() { + autoTestChannelsOnce.Do(func() { + for { + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + time.Sleep(10 * time.Minute) + continue + } + frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes + common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency)) + for { + time.Sleep(time.Duration(frequency) * time.Minute) + common.SysLog("automatically testing all channels") + _ = testAllChannels(false) + common.SysLog("automatically channel test finished") + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + break + } + } + } + }) } diff --git a/main.go b/main.go index 2dfddaccf..cc2288a61 100644 --- a/main.go +++ b/main.go @@ -94,13 +94,9 @@ func main() { } go controller.AutomaticallyUpdateChannels(frequency) } - if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { - frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) - if err != nil { - common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error()) - } - go controller.AutomaticallyTestChannels(frequency) - } + + go controller.AutomaticallyTestChannels() + if common.IsMasterNode && constant.UpdateTask { gopool.Go(func() { controller.UpdateMidjourneyTaskBulk() diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 290865854..3d5efcb6d 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -2,12 +2,10 @@ package common import ( "fmt" - "github.com/gin-gonic/gin" - _ "image/gif" - _ "image/jpeg" - _ "image/png" "one-api/constant" "strings" + + "github.com/gin-gonic/gin" ) func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { diff --git a/service/file_decoder.go b/service/file_decoder.go index 94f3f0282..99fdc3f9a 100644 --- a/service/file_decoder.go +++ b/service/file_decoder.go @@ -5,6 +5,9 @@ import ( "encoding/base64" "fmt" "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "net/http" "one-api/common" diff --git a/service/token_counter.go b/service/token_counter.go index b7dd81f52..be5c2e80c 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "log" "math" "one-api/common" @@ -357,33 +360,6 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco return tkm, nil } -//func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) (int, error) { -// tkm := 0 -// msgTokens, err := CountTokenMessages(info, request.Messages, request.Model, request.Stream) -// if err != nil { -// return 0, err -// } -// tkm += msgTokens -// if request.Tools != nil { -// openaiTools := request.Tools -// countStr := "" -// for _, tool := range openaiTools { -// countStr = tool.Function.Name -// if tool.Function.Description != "" { -// countStr += tool.Function.Description -// } -// if tool.Function.Parameters != nil { -// countStr += fmt.Sprintf("%v", tool.Function.Parameters) -// } -// } -// toolTokens := CountTokenInput(countStr, request.Model) -// tkm += 8 -// tkm += toolTokens -// } -// -// return tkm, nil -//} - func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) { tkm := 0 @@ -543,56 +519,6 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, return textToken, audioToken, nil } -//func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, model string, stream bool) (int, error) { -// //recover when panic -// tokenEncoder := getTokenEncoder(model) -// // Reference: -// // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb -// // https://github.com/pkoukk/tiktoken-go/issues/6 -// // -// // Every message follows <|start|>{role/name}\n{content}<|end|>\n -// var tokensPerMessage int -// var tokensPerName int -// -// tokensPerMessage = 3 -// tokensPerName = 1 -// -// tokenNum := 0 -// for _, message := range messages { -// tokenNum += tokensPerMessage -// tokenNum += getTokenNum(tokenEncoder, message.Role) -// if message.Content != nil { -// if message.Name != nil { -// tokenNum += tokensPerName -// tokenNum += getTokenNum(tokenEncoder, *message.Name) -// } -// arrayContent := message.ParseContent() -// for _, m := range arrayContent { -// if m.Type == dto.ContentTypeImageURL { -// imageUrl := m.GetImageMedia() -// imageTokenNum, err := getImageToken(info, imageUrl, model, stream) -// if err != nil { -// return 0, err -// } -// tokenNum += imageTokenNum -// log.Printf("image token num: %d", imageTokenNum) -// } else if m.Type == dto.ContentTypeInputAudio { -// // TODO: 音频token数量计算 -// tokenNum += 100 -// } else if m.Type == dto.ContentTypeFile { -// tokenNum += 5000 -// } else if m.Type == dto.ContentTypeVideoUrl { -// tokenNum += 5000 -// } else { -// tokenNum += getTokenNum(tokenEncoder, m.Text) -// } -// } -// } -// } -// tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|> -// return tokenNum, nil -//} - func CountTokenInput(input any, model string) int { switch v := input.(type) { case string: diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go new file mode 100644 index 000000000..1d0bbec40 --- /dev/null +++ b/setting/operation_setting/monitor_setting.go @@ -0,0 +1,34 @@ +package operation_setting + +import ( + "one-api/setting/config" + "os" + "strconv" +) + +type MonitorSetting struct { + AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` + AutoTestChannelMinutes int `json:"auto_test_channel_minutes"` +} + +// 默认配置 +var monitorSetting = MonitorSetting{ + AutoTestChannelEnabled: false, + AutoTestChannelMinutes: 10, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("monitor_setting", &monitorSetting) +} + +func GetMonitorSetting() *MonitorSetting { + if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) + if err == nil && frequency > 0 { + monitorSetting.AutoTestChannelEnabled = true + monitorSetting.AutoTestChannelMinutes = frequency + } + } + return &monitorSetting +} diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 05bda1528..0d6e44107 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -68,6 +68,8 @@ const OperationSetting = () => { AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', + 'monitor_setting.auto_test_channel_enabled': false, + 'monitor_setting.auto_test_channel_minutes': 10, }); let [loading, setLoading] = useState(false); @@ -78,10 +80,7 @@ const OperationSetting = () => { if (success) { let newInputs = {}; data.forEach((item) => { - if ( - item.key.endsWith('Enabled') || - ['DefaultCollapseSidebar'].includes(item.key) - ) { + if (typeof inputs[item.key] === 'boolean') { newInputs[item.key] = toBoolean(item.value); } else { newInputs[item.key] = item.value; diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index f4de4f6ea..d64f19b63 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -38,6 +38,8 @@ export default function SettingsMonitoring(props) { AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', + 'monitor_setting.auto_test_channel_enabled': false, + 'monitor_setting.auto_test_channel_minutes': 10, }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); @@ -98,6 +100,40 @@ export default function SettingsMonitoring(props) { style={{ marginBottom: 15 }} > + + + + setInputs({ + ...inputs, + 'monitor_setting.auto_test_channel_enabled': value, + }) + } + /> + + + + setInputs({ + ...inputs, + 'monitor_setting.auto_test_channel_minutes': parseInt(value), + }) + } + /> + + Date: Wed, 3 Sep 2025 14:30:25 +0800 Subject: [PATCH 31/64] feat(option): enhance UpdateOption to handle various value types and improve validation --- .env.example | 2 -- common/utils.go | 10 +++++++++- controller/option.go | 32 ++++++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 72645404e..c7851385b 100644 --- a/.env.example +++ b/.env.example @@ -56,8 +56,6 @@ # SESSION_SECRET=random_string # 其他配置 -# 渠道测试频率(单位:秒) -# CHANNEL_TEST_FREQUENCY=10 # 生成默认token # GENERATE_DEFAULT_TOKEN=false # Cohere 安全设置 diff --git a/common/utils.go b/common/utils.go index f82538138..883abfd1a 100644 --- a/common/utils.go +++ b/common/utils.go @@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string { return fmt.Sprintf("%d", inter.(int)) case float64: return fmt.Sprintf("%f", inter.(float64)) + case bool: + if inter.(bool) { + return "true" + } else { + return "false" + } + case nil: + return "" } - return "Not Implemented" + return fmt.Sprintf("%v", inter) } func UnescapeHTML(x string) interface{} { diff --git a/controller/option.go b/controller/option.go index decdb0d40..e5f2b75b0 100644 --- a/controller/option.go +++ b/controller/option.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "fmt" "net/http" "one-api/common" "one-api/model" @@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) { return } +type OptionUpdateRequest struct { + Key string `json:"key"` + Value any `json:"value"` +} + func UpdateOption(c *gin.Context) { - var option model.Option + var option OptionUpdateRequest err := json.NewDecoder(c.Request.Body).Decode(&option) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ @@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) { }) return } + switch option.Value.(type) { + case bool: + option.Value = common.Interface2String(option.Value.(bool)) + case float64: + option.Value = common.Interface2String(option.Value.(float64)) + case int: + option.Value = common.Interface2String(option.Value.(int)) + default: + option.Value = fmt.Sprintf("%v", option.Value) + } switch option.Key { case "GitHubOAuthEnabled": if option.Value == "true" && common.GitHubClientId == "" { @@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) { return } case "GroupRatio": - err = ratio_setting.CheckGroupRatio(option.Value) + err = ratio_setting.CheckGroupRatio(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -113,7 +129,7 @@ func UpdateOption(c *gin.Context) { return } case "ModelRequestRateLimitGroup": - err = setting.CheckModelRequestRateLimitGroup(option.Value) + err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -122,7 +138,7 @@ func UpdateOption(c *gin.Context) { return } case "console_setting.api_info": - err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo") + err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -131,7 +147,7 @@ func UpdateOption(c *gin.Context) { return } case "console_setting.announcements": - err = console_setting.ValidateConsoleSettings(option.Value, "Announcements") + err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -140,7 +156,7 @@ func UpdateOption(c *gin.Context) { return } case "console_setting.faq": - err = console_setting.ValidateConsoleSettings(option.Value, "FAQ") + err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -149,7 +165,7 @@ func UpdateOption(c *gin.Context) { return } case "console_setting.uptime_kuma_groups": - err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups") + err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups") if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -158,7 +174,7 @@ func UpdateOption(c *gin.Context) { return } } - err = model.UpdateOption(option.Key, option.Value) + err = model.UpdateOption(option.Key, option.Value.(string)) if err != nil { common.ApiError(c, err) return From e2f736bd2d5d5d48e6dcd8ae31c9621007ac4561 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 3 Sep 2025 14:43:51 +0800 Subject: [PATCH 32/64] feat(readme): update format conversion feature details in README --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 45b048340..d68b3e135 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,11 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 思考转内容功能 17. 🔄 针对用户的模型限流功能 -18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: +18. 🔄 请求格式转换功能,支持以下三种格式转换: + 1. OpenAI Chat Completions => Claude Messages + 2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: 1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项 2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费 3. 支持的渠道: From 3064ff093a5a7705cdfd59555cbbaf20f0b3dc20 Mon Sep 17 00:00:00 2001 From: Calcium-Ion <61247483+Calcium-Ion@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:45:00 +0800 Subject: [PATCH 33/64] Add request format conversion functionality Updated the features list to include request format conversion functionality and adjusted the order of items. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 45b048340..48218cd70 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,11 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 思考转内容功能 17. 🔄 针对用户的模型限流功能 -18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: +18. 🔄 请求格式转换功能,支持以下三种格式转换: + 1. OpenAI Chat Completions => Claude Messages + 2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型) + 3. OpenAI Chat Completions => Gemini Chat +20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: 1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项 2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费 3. 支持的渠道: From 91a627ddfccdb4bb0d027cdd720548a4de1c9946 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 3 Sep 2025 15:52:54 +0800 Subject: [PATCH 34/64] fix(channel): implement per-channel locking to ensure thread-safe updates in multi-key mode --- model/channel.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/model/channel.go b/model/channel.go index 39cbc0227..a61b3eccf 100644 --- a/model/channel.go +++ b/model/channel.go @@ -607,8 +607,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri return false } if channelCache.ChannelInfo.IsMultiKey { + // Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() // 如果是多Key模式,更新缓存中的状态 handlerMultiKeyUpdate(channelCache, usingKey, status, reason) + pollingLock.Unlock() //CacheUpdateChannel(channelCache) //return true } else { @@ -639,7 +643,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri if channel.ChannelInfo.IsMultiKey { beforeStatus := channel.Status + // Protect map writes with the same per-channel lock used by readers + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() handlerMultiKeyUpdate(channel, usingKey, status, reason) + pollingLock.Unlock() if beforeStatus != channel.Status { shouldUpdateAbilities = true } From e61c1dc7386e5cdd5803ea888b9af9a0f06df3ce Mon Sep 17 00:00:00 2001 From: Nekohy Date: Thu, 4 Sep 2025 23:36:19 +0800 Subject: [PATCH 35/64] fix: allow the negative number for override.go --- relay/common/override.go | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index c8f216ed5..212cf7b47 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/tidwall/gjson" "github.com/tidwall/sjson" + "regexp" + "strconv" "strings" ) @@ -151,7 +153,9 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri } func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) { - value := gjson.Get(jsonStr, condition.Path) + // 处理负数索引 + path := processNegativeIndex(jsonStr, condition.Path) + value := gjson.Get(jsonStr, path) if !value.Exists() { if condition.PassMissingKey { return true, nil @@ -177,6 +181,37 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e return result, nil } +func processNegativeIndex(jsonStr string, path string) string { + re := regexp.MustCompile(`\.(-\d+)`) + matches := re.FindAllStringSubmatch(path, -1) + + if len(matches) == 0 { + return path + } + + result := path + for _, match := range matches { + negIndex := match[1] + index, _ := strconv.Atoi(negIndex) + + arrayPath := strings.Split(path, negIndex)[0] + if strings.HasSuffix(arrayPath, ".") { + arrayPath = arrayPath[:len(arrayPath)-1] + } + + array := gjson.Get(jsonStr, arrayPath) + if array.IsArray() { + length := len(array.Array()) + actualIndex := length + index + if actualIndex >= 0 && actualIndex < length { + result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1) + } + } + } + + return result +} + // compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式 func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) { switch mode { @@ -274,21 +309,25 @@ func applyOperations(jsonStr string, operations []ParamOperation) (string, error if !ok { continue // 条件不满足,跳过当前操作 } + // 处理路径中的负数索引 + opPath := processNegativeIndex(result, op.Path) + opFrom := processNegativeIndex(result, op.From) + opTo := processNegativeIndex(result, op.To) switch op.Mode { case "delete": - result, err = sjson.Delete(result, op.Path) + result, err = sjson.Delete(result, opPath) case "set": - if op.KeepOrigin && gjson.Get(result, op.Path).Exists() { + if op.KeepOrigin && gjson.Get(result, opPath).Exists() { continue } - result, err = sjson.Set(result, op.Path, op.Value) + result, err = sjson.Set(result, opPath, op.Value) case "move": - result, err = moveValue(result, op.From, op.To) + result, err = moveValue(result, opFrom, opTo) case "prepend": - result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, true) + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true) case "append": - result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, false) + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false) default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } From c0187d50fff888808d2e953c8268077eb907f976 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:58:24 +0800 Subject: [PATCH 36/64] fix: add error handling for missing built-in tools and validate response in stream handler --- relay/channel/openai/relay_responses.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 88fb88083..2414fca28 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -48,7 +48,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http } // 解析 Tools 用量 for _, tool := range responsesResponse.Tools { - info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++ + buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])] + if !ok { + logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"])) + continue + } + buildToolinfo.CallCount++ } return &usage, nil } @@ -72,7 +77,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp sendResponsesStreamData(c, streamResponse, data) switch streamResponse.Type { case "response.completed": - if streamResponse.Response.Usage != nil { + if streamResponse.Response != nil && streamResponse.Response.Usage != nil { if streamResponse.Response.Usage.InputTokens != 0 { usage.PromptTokens = streamResponse.Response.Usage.InputTokens } From e5a5d2de7c5ff5d37fb412f423d3c63ac3c454b8 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 6 Sep 2025 21:57:26 +0800 Subject: [PATCH 37/64] =?UTF-8?q?=F0=9F=90=9B=20fix(models):=20export=20se?= =?UTF-8?q?tActivePage=20to=20prevent=20tab-change=20TypeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Clicking a vendor tab triggered “setActivePage is not a function” from ModelsTabs.jsx:43. Root cause: ModelsTabs expects `setActivePage` via props (spread from `useModelsData`), but the hook did not expose it in its return object, so the prop resolved to `undefined`. Fix: Export `setActivePage` from `useModelsData`’s return object so `ModelsTabs` receives a valid function. Result: Tab switching now correctly resets pagination to page 1 and reloads models without runtime errors. Files: - web/src/hooks/models/useModelsData.jsx Test plan: - Open the Models page - Click different vendor tabs - Verify no crash occurs and the list reloads with page reset to 1 Refs: web/src/components/table/models/ModelsTabs.jsx:43 --- web/src/hooks/models/useModelsData.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/hooks/models/useModelsData.jsx b/web/src/hooks/models/useModelsData.jsx index 57b4bea3d..b1bd8b65d 100644 --- a/web/src/hooks/models/useModelsData.jsx +++ b/web/src/hooks/models/useModelsData.jsx @@ -462,6 +462,7 @@ export const useModelsData = () => { copyText, // Pagination + setActivePage, handlePageChange, handlePageSizeChange, From a77a88308aea7cad6350c013ad8b0eeefcb992be Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Sun, 7 Sep 2025 07:42:25 +0800 Subject: [PATCH 38/64] fix: enhance tool usage parsing with additional nil checks and error logging --- relay/channel/openai/relay_responses.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 2414fca28..e188889e4 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -46,10 +46,13 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens } } + if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil { + return &usage, nil + } // 解析 Tools 用量 for _, tool := range responsesResponse.Tools { buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])] - if !ok { + if !ok || buildToolinfo == nil { logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"])) continue } From b29efbde5263c777d111c02b858ac0e2e6516d67 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sun, 7 Sep 2025 23:03:19 +0800 Subject: [PATCH 39/64] feat(relay-claude): mapping stop reason and send text delta on block start type - convert claude stop reason "max_tokens" to openai "length" - send content_block_start content text delta --- relay/channel/claude/relay-claude.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 0c445bb9a..3c5524fa9 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -32,7 +32,7 @@ func stopReasonClaude2OpenAI(reason string) string { case "end_turn": return "stop" case "max_tokens": - return "max_tokens" + return "length" case "tool_use": return "tool_calls" default: @@ -426,7 +426,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse choice.Delta.Role = "assistant" } else if claudeResponse.Type == "content_block_start" { if claudeResponse.ContentBlock != nil { - //choice.Delta.SetContentString(claudeResponse.ContentBlock.Text) + // 如果是文本块,尽可能发送首段文本(若存在) + if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil { + choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text) + } if claudeResponse.ContentBlock.Type == "tool_use" { tools = append(tools, dto.ToolCallResponse{ Index: common.GetPointer(fcIdx), From c40a4f5444d123f1fb0eb5d109c852980871cd67 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 9 Sep 2025 23:18:07 +0800 Subject: [PATCH 40/64] fix: claude header was not set correctly --- relay/channel/aws/relay-aws.go | 7 ++++++- relay/channel/claude/relay-claude.go | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index 5822e363a..26e234fa3 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -130,7 +130,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (* Usage: &dto.Usage{}, } - handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage) + // 复制上游 Content-Type 到客户端响应头 + if awsResp.ContentType != nil && *awsResp.ContentType != "" { + c.Writer.Header().Set("Content-Type", *awsResp.ContentType) + } + + handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, RequestModeMessage) if handlerErr != nil { return handlerErr, nil } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 511db2c6b..682256416 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -276,7 +276,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe isFirstMessage := true // 初始化system消息数组,用于累积多个system消息 var systemMessages []dto.ClaudeMediaMessage - + for _, message := range formatMessages { if message.Role == "system" { // 根据Claude API规范,system字段使用数组格式更有通用性 @@ -401,12 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeMessages = append(claudeMessages, claudeMessage) } } - + // 设置累积的system消息 if len(systemMessages) > 0 { claudeRequest.System = systemMessages } - + claudeRequest.Prompt = "" claudeRequest.Messages = claudeMessages return &claudeRequest, nil @@ -716,7 +716,7 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon. return claudeInfo.Usage, nil } -func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError { +func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, data []byte, requestMode int) *types.NewAPIError { var claudeResponse dto.ClaudeResponse err := common.Unmarshal(data, &claudeResponse) if err != nil { @@ -754,7 +754,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests) } - service.IOCopyBytesGracefully(c, nil, responseData) + service.IOCopyBytesGracefully(c, httpResp, responseData) return nil } @@ -775,7 +775,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI if common.DebugEnabled { println("responseBody: ", string(responseBody)) } - handleErr := HandleClaudeResponseData(c, info, claudeInfo, responseBody, requestMode) + handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode) if handleErr != nil { return nil, handleErr } From 041782c49e0289b9d2e64a318e81e4f75754dabf Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 9 Sep 2025 23:23:53 +0800 Subject: [PATCH 41/64] chore: remove PR branching strategy workflow file --- .github/workflows/pr-target-branch-check.yml | 21 -------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/pr-target-branch-check.yml diff --git a/.github/workflows/pr-target-branch-check.yml b/.github/workflows/pr-target-branch-check.yml deleted file mode 100644 index e7bd4c817..000000000 --- a/.github/workflows/pr-target-branch-check.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Check PR Branching Strategy -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -jobs: - check-branching-strategy: - runs-on: ubuntu-latest - steps: - - name: Enforce branching strategy - run: | - if [[ "${{ github.base_ref }}" == "main" ]]; then - if [[ "${{ github.head_ref }}" != "alpha" ]]; then - echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch." - exit 1 - fi - elif [[ "${{ github.base_ref }}" != "alpha" ]]; then - echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch." - exit 1 - fi - echo "Branching strategy check passed." \ No newline at end of file From 3f9698bb470a8a6b6499c79a5f98c9ba3cfafab4 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 10 Sep 2025 15:29:07 +0800 Subject: [PATCH 42/64] =?UTF-8?q?feat:=20dalle=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=AD=97=E6=AE=B5=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/openai_image.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/dto/openai_image.go b/dto/openai_image.go index 9e838688e..bc888dc71 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -59,6 +59,29 @@ func (i *ImageRequest) UnmarshalJSON(data []byte) error { return nil } +// 序列化时需要重新把字段平铺 +func (r ImageRequest) MarshalJSON() ([]byte, error) { + // 将已定义字段转为 map + type Alias ImageRequest + alias := Alias(r) + base, err := json.Marshal(alias) + if err != nil { + return nil, err + } + + var baseMap map[string]json.RawMessage + if err := json.Unmarshal(base, &baseMap); err != nil { + return nil, err + } + + // 合并 ExtraFields + for k, v := range r.Extra { + baseMap[k] = v + } + + return json.Marshal(baseMap) +} + func GetJSONFieldNames(t reflect.Type) map[string]struct{} { fields := make(map[string]struct{}) for i := 0; i < t.NumField(); i++ { From fcdfd027cd0140c98861cdc8e05050846344a75e Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 10 Sep 2025 15:30:23 +0800 Subject: [PATCH 43/64] =?UTF-8?q?fix:=20openai=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=20claude=20=E6=B2=A1=E8=AE=A1=E8=B4=B9=20cre?= =?UTF-8?q?ate=20cache=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel-test.go | 2 +- relay/audio_handler.go | 2 +- relay/claude_handler.go | 2 +- relay/compatible_handler.go | 20 ++++++++++++++++++-- relay/embedding_handler.go | 2 +- relay/gemini_handler.go | 4 ++-- relay/image_handler.go | 2 +- relay/rerank_handler.go | 2 +- relay/responses_handler.go | 2 +- service/error.go | 6 ++++-- 10 files changed, 31 insertions(+), 13 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 5fc6d749c..5a668c488 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -235,7 +235,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { if resp != nil { httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - err := service.RelayErrorHandler(httpResp, true) + err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) return testResult{ context: c, localErr: err, diff --git a/relay/audio_handler.go b/relay/audio_handler.go index 711cc7a9b..1357e3816 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -53,7 +53,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if resp != nil { httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59c052f62..dbdc6ee1c 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -111,7 +111,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ httpResp = resp.(*http.Response) info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index a3c6ace6e..8f27fd60b 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -158,7 +158,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types httpResp = resp.(*http.Response) info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { - newApiErr := service.RelayErrorHandler(httpResp, false) + newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newApiErr, statusCodeMappingStr) return newApiErr @@ -195,6 +195,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage imageTokens := usage.PromptTokensDetails.ImageTokens audioTokens := usage.PromptTokensDetails.AudioTokens completionTokens := usage.CompletionTokens + cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + modelName := relayInfo.OriginModelName tokenName := ctx.GetString("token_name") @@ -204,6 +206,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage modelRatio := relayInfo.PriceData.ModelRatio groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio modelPrice := relayInfo.PriceData.ModelPrice + cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio // Convert values to decimal for precise calculation dPromptTokens := decimal.NewFromInt(int64(promptTokens)) @@ -211,12 +214,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage dImageTokens := decimal.NewFromInt(int64(imageTokens)) dAudioTokens := decimal.NewFromInt(int64(audioTokens)) dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) + dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens)) dCompletionRatio := decimal.NewFromFloat(completionRatio) dCacheRatio := decimal.NewFromFloat(cacheRatio) dImageRatio := decimal.NewFromFloat(imageRatio) dModelRatio := decimal.NewFromFloat(modelRatio) dGroupRatio := decimal.NewFromFloat(groupRatio) dModelPrice := decimal.NewFromFloat(modelPrice) + dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) ratio := dModelRatio.Mul(dGroupRatio) @@ -284,6 +289,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage baseTokens = baseTokens.Sub(dCacheTokens) cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } + var dCachedCreationTokensWithRatio decimal.Decimal + if !dCachedCreationTokens.IsZero() { + baseTokens = baseTokens.Sub(dCachedCreationTokens) + dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) + } // 减去 image tokens var imageTokensWithRatio decimal.Decimal @@ -302,7 +312,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) } } - promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio) + promptQuota := baseTokens.Add(cachedTokensWithRatio). + Add(imageTokensWithRatio). + Add(dCachedCreationTokensWithRatio) completionQuota := dCompletionTokens.Mul(dCompletionRatio) @@ -395,6 +407,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage other["image_ratio"] = imageRatio other["image_output"] = imageTokens } + if cachedCreationTokens != 0 { + other["cache_creation_tokens"] = cachedCreationTokens + other["cache_creation_ratio"] = cachedCreationRatio + } if !dWebSearchQuota.IsZero() { if relayInfo.ResponsesUsageInfo != nil { if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 26dcf9719..3d8962bb4 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if resp != nil { httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 460fd2f58..0252d6578 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -152,7 +152,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ httpResp = resp.(*http.Response) info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError @@ -249,7 +249,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI if resp != nil { httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } diff --git a/relay/image_handler.go b/relay/image_handler.go index 14a7103c3..e2789ae5e 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -91,7 +91,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type httpResp = resp.(*http.Response) info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index fa3c7bbb4..46d2e25f6 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -81,7 +81,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if resp != nil { httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/relay/responses_handler.go b/relay/responses_handler.go index f5f624c92..d1c5d2158 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -82,7 +82,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { - newAPIError = service.RelayErrorHandler(httpResp, false) + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) // reset status code 重置状态码 service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError diff --git a/service/error.go b/service/error.go index ef5cbbde6..5c3bddd6e 100644 --- a/service/error.go +++ b/service/error.go @@ -1,12 +1,14 @@ package service import ( + "context" "errors" "fmt" "io" "net/http" "one-api/common" "one-api/dto" + "one-api/logger" "one-api/types" "strconv" "strings" @@ -78,7 +80,7 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude return claudeErr } -func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { +func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) responseBody, err := io.ReadAll(resp.Body) @@ -94,7 +96,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) } else { if common.DebugEnabled { - println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) + logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) } newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) } From 27a0a447d0cf12c3b527f3797f4140dacd6498bc Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 10 Sep 2025 15:31:35 +0800 Subject: [PATCH 44/64] =?UTF-8?q?fix:=20err=20=E5=A6=82=E6=9E=9C=E6=98=AF?= =?UTF-8?q?=20newApiErr=20=E5=88=99=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/api_request.go | 3 +-- types/error.go | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index a50d5bdb5..a065caff7 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -264,9 +264,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http } resp, err := client.Do(req) - if err != nil { - return nil, err + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) } if resp == nil { return nil, errors.New("resp is nil") diff --git a/types/error.go b/types/error.go index f653e9a28..883ee0641 100644 --- a/types/error.go +++ b/types/error.go @@ -185,6 +185,14 @@ func (e *NewAPIError) ToClaudeError() ClaudeError { type NewAPIErrorOptions func(*NewAPIError) func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError { + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + for _, op := range ops { + op(newErr) + } + return newErr + } e := &NewAPIError{ Err: err, RelayError: nil, @@ -199,8 +207,21 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI } func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { - if errorCode == ErrorCodeDoRequestFailed { - err = errors.New("upstream error: do request failed") + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + if newErr.RelayError == nil { + openaiError := OpenAIError{ + Message: newErr.Error(), + Type: string(errorCode), + Code: errorCode, + } + newErr.RelayError = openaiError + } + for _, op := range ops { + op(newErr) + } + return newErr } openaiError := OpenAIError{ Message: err.Error(), @@ -305,6 +326,15 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { } } +func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions { + return func(e *NewAPIError) { + if common.DebugEnabled { + fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err) + } + e.Err = errors.New(replaceStr) + } +} + func IsRecordErrorLog(e *NewAPIError) bool { if e == nil { return false From cda73a2ec5be50c8b6723b8a84440845a4b30f45 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 10 Sep 2025 19:53:32 +0800 Subject: [PATCH 45/64] =?UTF-8?q?fix:=20dalle=20log=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=BC=A0=E6=95=B0=20N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/relay.go | 13 ++++++------- relay/image_handler.go | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index d3d93192e..07c3aeaac 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -277,14 +277,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) - - gopool.Go(func() { - // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 - // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously - if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 + // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously + if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + gopool.Go(func() { service.DisableChannel(channelError, err.Error()) - } - }) + }) + } if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) { // 保存错误日志到mysql中 diff --git a/relay/image_handler.go b/relay/image_handler.go index e2789ae5e..9c873d47f 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -120,7 +120,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type var logContent string if len(request.Size) > 0 { - logContent = fmt.Sprintf("大小 %s, 品质 %s", request.Size, quality) + logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N) } postConsumeQuota(c, info, usage.(*dto.Usage), logContent) From 47aaa695b2c90b0a169a3010b0e91ab4d5fe9640 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Wed, 10 Sep 2025 20:30:00 +0800 Subject: [PATCH 46/64] feat: support amazon nova --- relay/channel/aws/adaptor.go | 10 +++++ relay/channel/aws/constants.go | 11 +++++ relay/channel/aws/dto.go | 53 +++++++++++++++++++++++ relay/channel/aws/relay-aws.go | 78 ++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 1526a7f75..9d5e5891e 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -60,7 +60,16 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } + // 检查是否为Nova模型 + if isNovaModel(request.Model) { + novaReq := convertToNovaRequest(request) + c.Set("request_model", request.Model) + c.Set("converted_request", novaReq) + c.Set("is_nova_model", true) + return novaReq, nil + } + // 原有的Claude模型处理逻辑 var claudeReq *dto.ClaudeRequest var err error claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request) @@ -69,6 +78,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn } c.Set("request_model", claudeReq.Model) c.Set("converted_request", claudeReq) + c.Set("is_nova_model", false) return claudeReq, err } diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 3f8800b1e..8ed8f0318 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -1,5 +1,7 @@ package aws +import "strings" + var awsModelIDMap = map[string]string{ "claude-instant-1.2": "anthropic.claude-instant-v1", "claude-2.0": "anthropic.claude-v2", @@ -14,6 +16,10 @@ var awsModelIDMap = map[string]string{ "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", + // Nova models + "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0", + "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0", + "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ @@ -67,3 +73,8 @@ var awsRegionCrossModelPrefixMap = map[string]string{ } var ChannelName = "aws" + +// 判断是否为Nova模型 +func isNovaModel(modelId string) bool { + return strings.HasPrefix(modelId, "amazon.nova-") +} diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go index 0188c30a9..25851ff6f 100644 --- a/relay/channel/aws/dto.go +++ b/relay/channel/aws/dto.go @@ -34,3 +34,56 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest { Thinking: req.Thinking, } } + +// Nova模型使用messages-v1格式 +type NovaMessage struct { + Role string `json:"role"` + Content []NovaContent `json:"content"` +} + +type NovaContent struct { + Text string `json:"text"` +} + +type NovaRequest struct { + SchemaVersion string `json:"schemaVersion"` + Messages []NovaMessage `json:"messages"` + InferenceConfig NovaInferenceConfig `json:"inferenceConfig,omitempty"` +} + +type NovaInferenceConfig struct { + MaxTokens int `json:"maxTokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` +} + +// 转换OpenAI请求为Nova格式 +func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { + novaMessages := make([]NovaMessage, len(req.Messages)) + for i, msg := range req.Messages { + novaMessages[i] = NovaMessage{ + Role: msg.Role, + Content: []NovaContent{{Text: msg.StringContent()}}, + } + } + + novaReq := &NovaRequest{ + SchemaVersion: "messages-v1", + Messages: novaMessages, + } + + // 设置推理配置 + if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 { + if req.MaxTokens != 0 { + novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens) + } + if req.Temperature != nil && *req.Temperature != 0 { + novaReq.InferenceConfig.Temperature = *req.Temperature + } + if req.TopP != 0 { + novaReq.InferenceConfig.TopP = req.TopP + } + } + + return novaReq +} diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index 26e234fa3..3df6b33dd 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -1,6 +1,7 @@ package aws import ( + "encoding/json" "fmt" "net/http" "one-api/common" @@ -93,7 +94,13 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (* } awsModelId := awsModelID(c.GetString("request_model")) + // 检查是否为Nova模型 + isNova, _ := c.Get("is_nova_model") + if isNova == true { + return handleNovaRequest(c, awsCli, info, awsModelId) + } + // 原有的Claude处理逻辑 awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region) canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) if canCrossRegion { @@ -209,3 +216,74 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage) return nil, claudeInfo.Usage } + +// Nova模型处理函数 +func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) { + novaReq_, ok := c.Get("converted_request") + if !ok { + return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil + } + novaReq := novaReq_.(*NovaRequest) + + // 使用InvokeModel API,但使用Nova格式的请求体 + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + reqBody, err := json.Marshal(novaReq) + if err != nil { + return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil + } + awsReq.Body = reqBody + + awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq) + if err != nil { + return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil + } + + // 解析Nova响应 + var novaResp struct { + Output struct { + Message struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"output"` + Usage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` + } `json:"usage"` + } + + if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil { + return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil + } + + // 构造OpenAI格式响应 + response := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Object: "chat.completion", + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + Choices: []dto.OpenAITextResponseChoice{{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: novaResp.Output.Message.Content[0].Text, + }, + FinishReason: "stop", + }}, + Usage: dto.Usage{ + PromptTokens: novaResp.Usage.InputTokens, + CompletionTokens: novaResp.Usage.OutputTokens, + TotalTokens: novaResp.Usage.TotalTokens, + }, + } + + c.JSON(http.StatusOK, response) + return nil, &response.Usage +} From 684caa36731ea63ab19a630a29debfbb26d435ec Mon Sep 17 00:00:00 2001 From: huanghejian Date: Thu, 11 Sep 2025 10:01:54 +0800 Subject: [PATCH 47/64] feat: amazon.nova-premier-v1:0 --- relay/channel/aws/constants.go | 7 ++++--- relay/channel/aws/dto.go | 16 +++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 8ed8f0318..7f18d57a1 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -17,9 +17,10 @@ var awsModelIDMap = map[string]string{ "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", // Nova models - "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0", - "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0", - "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0", + "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0", + "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0", + "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0", + "amazon.nova-premier-v1:0": "us.amazon.nova-premier-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go index 25851ff6f..cef16c11f 100644 --- a/relay/channel/aws/dto.go +++ b/relay/channel/aws/dto.go @@ -35,7 +35,7 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest { } } -// Nova模型使用messages-v1格式 +// NovaMessage Nova模型使用messages-v1格式 type NovaMessage struct { Role string `json:"role"` Content []NovaContent `json:"content"` @@ -46,15 +46,17 @@ type NovaContent struct { } type NovaRequest struct { - SchemaVersion string `json:"schemaVersion"` - Messages []NovaMessage `json:"messages"` - InferenceConfig NovaInferenceConfig `json:"inferenceConfig,omitempty"` + SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0" + Messages []NovaMessage `json:"messages"` // 对话消息列表 + InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选 } type NovaInferenceConfig struct { - MaxTokens int `json:"maxTokens,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"topP,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数 + Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1) + TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1) + TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128) + StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列 } // 转换OpenAI请求为Nova格式 From e3bc40f11b8bd3c57ca3435ba09af0b5b65a1c56 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Thu, 11 Sep 2025 12:17:16 +0800 Subject: [PATCH 48/64] pref: support amazon nova --- relay/channel/aws/constants.go | 32 ++++++++++++++++++++++++++------ relay/channel/aws/dto.go | 1 + relay/channel/aws/relay-aws.go | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 7f18d57a1..72d0f9890 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -17,10 +17,10 @@ var awsModelIDMap = map[string]string{ "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", // Nova models - "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0", - "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0", - "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0", - "amazon.nova-premier-v1:0": "us.amazon.nova-premier-v1:0", + "nova-micro-v1:0": "amazon.nova-micro-v1:0", + "nova-lite-v1:0": "amazon.nova-lite-v1:0", + "nova-pro-v1:0": "amazon.nova-pro-v1:0", + "nova-premier-v1:0": "amazon.nova-premier-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ @@ -65,7 +65,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "anthropic.claude-opus-4-1-20250805-v1:0": { "us": true, }, -} + // Nova models - all support three major regions + "amazon.nova-micro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-lite-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-pro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-premier-v1:0": { + "us": true, + "eu": true, + "apac": true, + }} var awsRegionCrossModelPrefixMap = map[string]string{ "us": "us", @@ -77,5 +97,5 @@ var ChannelName = "aws" // 判断是否为Nova模型 func isNovaModel(modelId string) bool { - return strings.HasPrefix(modelId, "amazon.nova-") + return strings.HasPrefix(modelId, "nova-") } diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go index cef16c11f..53daef288 100644 --- a/relay/channel/aws/dto.go +++ b/relay/channel/aws/dto.go @@ -76,6 +76,7 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { // 设置推理配置 if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 { + novaReq.InferenceConfig = &NovaInferenceConfig{} if req.MaxTokens != 0 { novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens) } diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index 3df6b33dd..eef26855a 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -97,6 +97,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (* // 检查是否为Nova模型 isNova, _ := c.Get("is_nova_model") if isNova == true { + // Nova模型也支持跨区域 + awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region) + canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) + if canCrossRegion { + awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix) + } return handleNovaRequest(c, awsCli, info, awsModelId) } From db6a788e0d4798c62922714a8e33d3f4780f095e Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Thu, 11 Sep 2025 12:28:57 +0800 Subject: [PATCH 49/64] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=20ImageRequest?= =?UTF-8?q?=20=E7=9A=84=20JSON=20=E5=BA=8F=E5=88=97=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A6=86=E7=9B=96=E5=90=88=E5=B9=B6=20ExtraF?= =?UTF-8?q?ields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/openai_image.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dto/openai_image.go b/dto/openai_image.go index bc888dc71..5aece25f2 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -64,19 +64,21 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) { // 将已定义字段转为 map type Alias ImageRequest alias := Alias(r) - base, err := json.Marshal(alias) + base, err := common.Marshal(alias) if err != nil { return nil, err } var baseMap map[string]json.RawMessage - if err := json.Unmarshal(base, &baseMap); err != nil { + if err := common.Unmarshal(base, &baseMap); err != nil { return nil, err } // 合并 ExtraFields for k, v := range r.Extra { - baseMap[k] = v + if _, exists := baseMap[k]; !exists { + baseMap[k] = v + } } return json.Marshal(baseMap) From 70c27bc662fd4edb6487261538208bc0a2e802a9 Mon Sep 17 00:00:00 2001 From: huanghejian Date: Thu, 11 Sep 2025 12:31:43 +0800 Subject: [PATCH 50/64] feat: improve nova config --- relay/channel/aws/dto.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go index 53daef288..9c9fe946f 100644 --- a/relay/channel/aws/dto.go +++ b/relay/channel/aws/dto.go @@ -75,7 +75,7 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { } // 设置推理配置 - if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 { + if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil { novaReq.InferenceConfig = &NovaInferenceConfig{} if req.MaxTokens != 0 { novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens) @@ -86,7 +86,40 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { if req.TopP != 0 { novaReq.InferenceConfig.TopP = req.TopP } + if req.TopK != 0 { + novaReq.InferenceConfig.TopK = req.TopK + } + if req.Stop != nil { + if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 { + novaReq.InferenceConfig.StopSequences = stopSequences + } + } } return novaReq } + +// parseStopSequences 解析停止序列,支持字符串或字符串数组 +func parseStopSequences(stop any) []string { + if stop == nil { + return nil + } + + switch v := stop.(type) { + case string: + if v != "" { + return []string{v} + } + case []string: + return v + case []interface{}: + var sequences []string + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + sequences = append(sequences, str) + } + } + return sequences + } + return nil +} From b25ac0bfb69ba6a5f1bd3f352567c7c8ad9a8f9e Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Thu, 11 Sep 2025 16:04:32 +0800 Subject: [PATCH 51/64] =?UTF-8?q?fix:=20=E9=A2=84=E6=89=A3=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E4=BD=BF=E7=94=A8=20relay=20info=20=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/relay.go | 6 +++--- service/pre_consume_quota.go | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 07c3aeaac..23d725153 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -139,15 +139,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) - preConsumedQuota, newAPIError := service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) if newAPIError != nil { return } defer func() { // Only return quota if downstream failed and quota was actually pre-consumed - if newAPIError != nil && preConsumedQuota != 0 { - service.ReturnPreConsumedQuota(c, relayInfo, preConsumedQuota) + if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 { + service.ReturnPreConsumedQuota(c, relayInfo) } }() diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go index 86b04e526..3cfabc1a4 100644 --- a/service/pre_consume_quota.go +++ b/service/pre_consume_quota.go @@ -13,13 +13,13 @@ import ( "github.com/gin-gonic/gin" ) -func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) { - if preConsumedQuota != 0 { - logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota))) +func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { + if relayInfo.FinalPreConsumedQuota != 0 { + logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota))) gopool.Go(func() { relayInfoCopy := *relayInfo - err := PostConsumeQuota(&relayInfoCopy, -preConsumedQuota, 0, false) + err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false) if err != nil { common.SysLog("error return pre-consumed quota: " + err.Error()) } @@ -29,16 +29,16 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, pr // PreConsumeQuota checks if the user has enough quota to pre-consume. // It returns the pre-consumed quota if successful, or an error if not. -func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, *types.NewAPIError) { +func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { userQuota, err := model.GetUserQuota(relayInfo.UserId, false) if err != nil { - return 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } if userQuota <= 0 { - return 0, types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } if userQuota-preConsumedQuota < 0 { - return 0, types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } trustQuota := common.GetTrustQuota() @@ -65,14 +65,14 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo if preConsumedQuota > 0 { err := PreConsumeTokenQuota(relayInfo, preConsumedQuota) if err != nil { - return 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) if err != nil { - return 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) + return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) } logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota))) } relayInfo.FinalPreConsumedQuota = preConsumedQuota - return preConsumedQuota, nil + return nil } From 93adcd57d7d851d90ee051e1daf8db7ea6b52655 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 11 Sep 2025 21:02:12 +0800 Subject: [PATCH 52/64] fix(responses): allow pass-through body for specific channel settings. (close #1762) --- relay/responses_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index d1c5d2158..0c57a303f 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -41,7 +41,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } adaptor.Init(info) var requestBody io.Reader - if model_setting.GetGlobalSettings().PassThroughRequestEnabled { + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) From b6c547ae982e83e34a1182578d68e3a8a9e86cf6 Mon Sep 17 00:00:00 2001 From: Zhaokun Zhang Date: Thu, 11 Sep 2025 21:34:49 +0800 Subject: [PATCH 53/64] =?UTF-8?q?fix:=20UI=20=E6=9C=AA=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Setting/Operation/SettingsGeneral.jsx | 2 +- web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index c94c0dd5a..37b3dd984 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -194,7 +194,7 @@ export default function GeneralSettings(props) { /> - + From e68eed3d400785401c74f5bb3db21fd8b2f27b6a Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Sep 2025 14:06:09 +0800 Subject: [PATCH 54/64] feat(channel): add support for Vertex AI key type configuration in settings --- controller/channel.go | 7 +- dto/channel_settings.go | 10 +- model/channel.go | 3 +- relay/channel/vertex/adaptor.go | 116 ++++++++------- .../channels/modals/EditChannelModal.jsx | 133 +++++++++++------- 5 files changed, 166 insertions(+), 103 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 70be91d42..403eb04cc 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -6,6 +6,7 @@ import ( "net/http" "one-api/common" "one-api/constant" + "one-api/dto" "one-api/model" "strconv" "strings" @@ -560,7 +561,7 @@ func AddChannel(c *gin.Context) { case "multi_to_single": addChannelRequest.Channel.ChannelInfo.IsMultiKey = true addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -585,7 +586,7 @@ func AddChannel(c *gin.Context) { } keys = []string{addChannelRequest.Channel.Key} case "batch": - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // multi json keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { @@ -840,7 +841,7 @@ func UpdateChannel(c *gin.Context) { } // 处理 Vertex AI 的特殊情况 - if channel.Type == constant.ChannelTypeVertexAi { + if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // 尝试解析新密钥为JSON数组 if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { array, err := getVertexArrayKeys(channel.Key) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 2c58795cb..8791f516e 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -9,6 +9,14 @@ type ChannelSettings struct { SystemPromptOverride bool `json:"system_prompt_override,omitempty"` } +type VertexKeyType string + +const ( + VertexKeyTypeJSON VertexKeyType = "json" + VertexKeyTypeAPIKey VertexKeyType = "api_key" +) + type ChannelOtherSettings struct { - AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" } diff --git a/model/channel.go b/model/channel.go index a61b3eccf..534e2f3f2 100644 --- a/model/channel.go +++ b/model/channel.go @@ -42,7 +42,6 @@ type Channel struct { Priority *int64 `json:"priority" gorm:"bigint;default:0"` AutoBan *int `json:"auto_ban" gorm:"default:1"` OtherInfo string `json:"other_info"` - OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置 Tag *string `json:"tag" gorm:"index"` Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` @@ -51,6 +50,8 @@ type Channel struct { // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + // cache info Keys []string `json:"-" gorm:"-"` } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 0b6b26743..b6a78b7aa 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/claude" @@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } } -func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - adc := &Credentials{} - if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { - return "", fmt.Errorf("failed to decode credentials file: %w", err) - } +func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) { region := GetModelRegion(info.ApiVersion, info.OriginModelName) - a.AccountCredentials = *adc + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + adc := &Credentials{} + if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + a.AccountCredentials = *adc + + if a.RequestMode == RequestModeLlama { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", + region, + adc.ProjectID, + region, + ), nil + } + + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + modelName, + suffix, + info.ApiKey, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + region, + modelName, + suffix, + info.ApiKey, + ), nil + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix := "" if a.RequestMode == RequestModeGemini { - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { // 新增逻辑:处理 -thinking- 格式 if strings.Contains(info.UpstreamModelName, "-thinking-") { @@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix = "predict" } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - info.UpstreamModelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - info.UpstreamModelName, - suffix, - ), nil - } + return a.getRequestUrl(info, info.UpstreamModelName, suffix) } else if a.RequestMode == RequestModeClaude { if info.IsStream { suffix = "streamRawPredict?alt=sse" @@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { model = v } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", - adc.ProjectID, - model, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", - region, - adc.ProjectID, - region, - model, - suffix, - ), nil - } + return a.getRequestUrl(info, model, suffix) } else if a.RequestMode == RequestModeLlama { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", - region, - adc.ProjectID, - region, - ), nil + return a.getRequestUrl(info, "", "") } return "", errors.New("unsupported request mode") } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - accessToken, err := getAccessToken(a, info) - if err != nil { - return err + if info.ChannelOtherSettings.VertexKeyType == "json" { + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) } - req.Set("Authorization", "Bearer "+accessToken) return nil } diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 7a86fa114..c0a216246 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -142,6 +142,8 @@ const EditChannelModal = (props) => { system_prompt: '', system_prompt_override: false, settings: '', + // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type) + vertex_key_type: 'json', }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -409,11 +411,17 @@ const EditChannelModal = (props) => { const parsedSettings = JSON.parse(data.settings); data.azure_responses_version = parsedSettings.azure_responses_version || ''; + // 读取 Vertex 密钥格式 + data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; + data.vertex_key_type = 'json'; } + } else { + // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 + data.vertex_key_type = 'json'; } setInputs(data); @@ -745,59 +753,56 @@ const EditChannelModal = (props) => { let localInputs = { ...formValues }; if (localInputs.type === 41) { - if (useManualInput) { - // 手动输入模式 - if (localInputs.key && localInputs.key.trim() !== '') { - try { - // 验证 JSON 格式 - const parsedKey = JSON.parse(localInputs.key); - // 确保是有效的密钥格式 - localInputs.key = JSON.stringify(parsedKey); - } catch (err) { - showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); - return; - } - } else if (!isEdit) { + const keyType = localInputs.vertex_key_type || 'json'; + if (keyType === 'api_key') { + // 直接作为普通字符串密钥处理 + if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) { showInfo(t('请输入密钥!')); return; } } else { - // 文件上传模式 - let keys = vertexKeys; - - // 若当前未选择文件,尝试从已上传文件列表解析(异步读取) - if (keys.length === 0 && vertexFileList.length > 0) { - try { - const parsed = await Promise.all( - vertexFileList.map(async (item) => { - const fileObj = item.fileInstance; - if (!fileObj) return null; - const txt = await fileObj.text(); - return JSON.parse(txt); - }), - ); - keys = parsed.filter(Boolean); - } catch (err) { - showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + // JSON 服务账号密钥 + if (useManualInput) { + if (localInputs.key && localInputs.key.trim() !== '') { + try { + const parsedKey = JSON.parse(localInputs.key); + localInputs.key = JSON.stringify(parsedKey); + } catch (err) { + showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); + return; + } + } else if (!isEdit) { + showInfo(t('请输入密钥!')); return; } - } - - // 创建模式必须上传密钥;编辑模式可选 - if (keys.length === 0) { - if (!isEdit) { - showInfo(t('请上传密钥文件!')); - return; - } else { - // 编辑模式且未上传新密钥,不修改 key - delete localInputs.key; - } } else { - // 有新密钥,则覆盖 - if (batch) { - localInputs.key = JSON.stringify(keys); + // 文件上传模式 + let keys = vertexKeys; + if (keys.length === 0 && vertexFileList.length > 0) { + try { + const parsed = await Promise.all( + vertexFileList.map(async (item) => { + const fileObj = item.fileInstance; + if (!fileObj) return null; + const txt = await fileObj.text(); + return JSON.parse(txt); + }), + ); + keys = parsed.filter(Boolean); + } catch (err) { + showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + return; + } + } + if (keys.length === 0) { + if (!isEdit) { + showInfo(t('请上传密钥文件!')); + return; + } else { + delete localInputs.key; + } } else { - localInputs.key = JSON.stringify(keys[0]); + localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]); } } } @@ -853,6 +858,8 @@ const EditChannelModal = (props) => { delete localInputs.pass_through_body_enabled; delete localInputs.system_prompt; delete localInputs.system_prompt_override; + // 顶层的 vertex_key_type 不应发送给后端 + delete localInputs.vertex_key_type; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => { autoComplete='new-password' /> + {inputs.type === 41 && ( + { + // 更新设置中的 vertex_key_type + handleChannelOtherSettingsChange('vertex_key_type', value); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } + } + }} + extraText={ + inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') + } + /> + )} {batch ? ( - inputs.type === 41 ? ( + inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( { ) ) : ( <> - {inputs.type === 41 ? ( + {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
From d8410d2f11fdce79376531b1d752552efd17283f Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Sep 2025 19:11:17 +0800 Subject: [PATCH 55/64] feat(payment): add payment settings configuration and update payment methods handling --- controller/channel-billing.go | 4 +- controller/channel.go | 7 +- controller/misc.go | 12 +- controller/topup.go | 63 +++++- controller/topup_stripe.go | 11 +- dto/channel_settings.go | 10 +- model/channel.go | 3 +- model/option.go | 24 +-- relay/channel/vertex/adaptor.go | 116 ++++++----- router/api-router.go | 1 + service/epay.go | 5 +- setting/operation_setting/payment_setting.go | 23 +++ .../payment_setting_old.go} | 21 +- .../components/settings/PaymentSetting.jsx | 26 +++ .../channels/modals/EditChannelModal.jsx | 133 ++++++++----- web/src/components/topup/RechargeCard.jsx | 182 +++++++++++------- web/src/components/topup/index.jsx | 173 +++++++++++------ .../topup/modals/PaymentConfirmModal.jsx | 39 +++- web/src/helpers/data.js | 1 - .../Payment/SettingsPaymentGateway.jsx | 76 ++++++++ 20 files changed, 655 insertions(+), 275 deletions(-) create mode 100644 setting/operation_setting/payment_setting.go rename setting/{payment.go => operation_setting/payment_setting_old.go} (57%) diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 18acf2319..1082b9e73 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -10,7 +10,7 @@ import ( "one-api/constant" "one-api/model" "one-api/service" - "one-api/setting" + "one-api/setting/operation_setting" "one-api/types" "strconv" "time" @@ -342,7 +342,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) } availableBalanceCny := response.Data.AvailableBalance - availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64() channel.UpdateBalance(availableBalanceUsd) return availableBalanceUsd, nil } diff --git a/controller/channel.go b/controller/channel.go index 70be91d42..403eb04cc 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -6,6 +6,7 @@ import ( "net/http" "one-api/common" "one-api/constant" + "one-api/dto" "one-api/model" "strconv" "strings" @@ -560,7 +561,7 @@ func AddChannel(c *gin.Context) { case "multi_to_single": addChannelRequest.Channel.ChannelInfo.IsMultiKey = true addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -585,7 +586,7 @@ func AddChannel(c *gin.Context) { } keys = []string{addChannelRequest.Channel.Key} case "batch": - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // multi json keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { @@ -840,7 +841,7 @@ func UpdateChannel(c *gin.Context) { } // 处理 Vertex AI 的特殊情况 - if channel.Type == constant.ChannelTypeVertexAi { + if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // 尝试解析新密钥为JSON数组 if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { array, err := getVertexArrayKeys(channel.Key) diff --git a/controller/misc.go b/controller/misc.go index 897dad254..085829302 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -59,10 +59,6 @@ func GetStatus(c *gin.Context) { "wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_login": common.WeChatAuthEnabled, "server_address": setting.ServerAddress, - "price": setting.Price, - "stripe_unit_price": setting.StripeUnitPrice, - "min_topup": setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, "top_up_link": common.TopUpLink, @@ -75,15 +71,15 @@ func GetStatus(c *gin.Context) { "enable_data_export": common.DataExportEnabled, "data_export_default_time": common.DataExportDefaultTime, "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", - "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "mj_notify_enabled": setting.MjNotifyEnabled, "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, - "pay_methods": setting.PayMethods, - "usd_exchange_rate": setting.USDExchangeRate, + + "usd_exchange_rate": operation_setting.USDExchangeRate, + "price": operation_setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, // 面板启用开关 "api_info_enabled": cs.ApiInfoEnabled, diff --git a/controller/topup.go b/controller/topup.go index 3f3c86231..93f3e58e0 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -9,6 +9,7 @@ import ( "one-api/model" "one-api/service" "one-api/setting" + "one-api/setting/operation_setting" "strconv" "sync" "time" @@ -19,6 +20,44 @@ import ( "github.com/shopspring/decimal" ) +func GetTopUpInfo(c *gin.Context) { + // 获取支付方式 + payMethods := operation_setting.PayMethods + + // 如果启用了 Stripe 支付,添加到支付方法列表 + if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" { + // 检查是否已经包含 Stripe + hasStripe := false + for _, method := range payMethods { + if method["type"] == "stripe" { + hasStripe = true + break + } + } + + if !hasStripe { + stripeMethod := map[string]string{ + "name": "Stripe", + "type": "stripe", + "color": "rgba(var(--semi-purple-5), 1)", + "min_topup": strconv.Itoa(setting.StripeMinTopUp), + } + payMethods = append(payMethods, stripeMethod) + } + } + + data := gin.H{ + "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, + } + common.ApiSuccess(c, data) +} + type EpayRequest struct { Amount int64 `json:"amount"` PaymentMethod string `json:"payment_method"` @@ -31,13 +70,13 @@ type AmountRequest struct { } func GetEpayClient() *epay.Client { - if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" { + if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" { return nil } withUrl, err := epay.NewClient(&epay.Config{ - PartnerID: setting.EpayId, - Key: setting.EpayKey, - }, setting.PayAddress) + PartnerID: operation_setting.EpayId, + Key: operation_setting.EpayKey, + }, operation_setting.PayAddress) if err != nil { return nil } @@ -58,15 +97,23 @@ func getPayMoney(amount int64, group string) float64 { } dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio) - dPrice := decimal.NewFromFloat(setting.Price) + dPrice := decimal.NewFromFloat(operation_setting.Price) + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok { + if ds > 0 { + discount = ds + } + } + dDiscount := decimal.NewFromFloat(discount) - payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio) + payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount) return payMoney.InexactFloat64() } func getMinTopup() int64 { - minTopup := setting.MinTopUp + minTopup := operation_setting.MinTopUp if !common.DisplayInCurrencyEnabled { dMinTopup := decimal.NewFromInt(int64(minTopup)) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) @@ -99,7 +146,7 @@ func RequestEpay(c *gin.Context) { return } - if !setting.ContainsPayMethod(req.PaymentMethod) { + if !operation_setting.ContainsPayMethod(req.PaymentMethod) { c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) return } diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index eb3208092..bf0d7bf36 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -8,6 +8,7 @@ import ( "one-api/common" "one-api/model" "one-api/setting" + "one-api/setting/operation_setting" "strconv" "strings" "time" @@ -254,6 +255,7 @@ func GetChargedAmount(count float64, user model.User) float64 { } func getStripePayMoney(amount float64, group string) float64 { + originalAmount := amount if !common.DisplayInCurrencyEnabled { amount = amount / common.QuotaPerUnit } @@ -262,7 +264,14 @@ func getStripePayMoney(amount float64, group string) float64 { if topupGroupRatio == 0 { topupGroupRatio = 1 } - payMoney := amount * setting.StripeUnitPrice * topupGroupRatio + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount return payMoney } diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 2c58795cb..8791f516e 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -9,6 +9,14 @@ type ChannelSettings struct { SystemPromptOverride bool `json:"system_prompt_override,omitempty"` } +type VertexKeyType string + +const ( + VertexKeyTypeJSON VertexKeyType = "json" + VertexKeyTypeAPIKey VertexKeyType = "api_key" +) + type ChannelOtherSettings struct { - AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" } diff --git a/model/channel.go b/model/channel.go index a61b3eccf..534e2f3f2 100644 --- a/model/channel.go +++ b/model/channel.go @@ -42,7 +42,6 @@ type Channel struct { Priority *int64 `json:"priority" gorm:"bigint;default:0"` AutoBan *int `json:"auto_ban" gorm:"default:1"` OtherInfo string `json:"other_info"` - OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置 Tag *string `json:"tag" gorm:"index"` Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` @@ -51,6 +50,8 @@ type Channel struct { // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + // cache info Keys []string `json:"-" gorm:"-"` } diff --git a/model/option.go b/model/option.go index 2121710ce..73fe92ad1 100644 --- a/model/option.go +++ b/model/option.go @@ -73,9 +73,9 @@ func InitOptionMap() { common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" common.OptionMap["EpayKey"] = "" - common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64) - common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64) - common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) + common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) + common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret @@ -85,7 +85,7 @@ func InitOptionMap() { common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) - common.OptionMap["PayMethods"] = setting.PayMethods2JsonString() + common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["TelegramBotToken"] = "" @@ -299,23 +299,23 @@ func updateOptionMap(key string, value string) (err error) { case "WorkerValidKey": setting.WorkerValidKey = value case "PayAddress": - setting.PayAddress = value + operation_setting.PayAddress = value case "Chats": err = setting.UpdateChatsByJsonString(value) case "AutoGroups": err = setting.UpdateAutoGroupsByJsonString(value) case "CustomCallbackAddress": - setting.CustomCallbackAddress = value + operation_setting.CustomCallbackAddress = value case "EpayId": - setting.EpayId = value + operation_setting.EpayId = value case "EpayKey": - setting.EpayKey = value + operation_setting.EpayKey = value case "Price": - setting.Price, _ = strconv.ParseFloat(value, 64) + operation_setting.Price, _ = strconv.ParseFloat(value, 64) case "USDExchangeRate": - setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) case "MinTopUp": - setting.MinTopUp, _ = strconv.Atoi(value) + operation_setting.MinTopUp, _ = strconv.Atoi(value) case "StripeApiSecret": setting.StripeApiSecret = value case "StripeWebhookSecret": @@ -413,7 +413,7 @@ func updateOptionMap(key string, value string) (err error) { case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": - err = setting.UpdatePayMethodsByJsonString(value) + err = operation_setting.UpdatePayMethodsByJsonString(value) } return err } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 0b6b26743..b6a78b7aa 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/claude" @@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } } -func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - adc := &Credentials{} - if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { - return "", fmt.Errorf("failed to decode credentials file: %w", err) - } +func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) { region := GetModelRegion(info.ApiVersion, info.OriginModelName) - a.AccountCredentials = *adc + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + adc := &Credentials{} + if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + a.AccountCredentials = *adc + + if a.RequestMode == RequestModeLlama { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", + region, + adc.ProjectID, + region, + ), nil + } + + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + modelName, + suffix, + info.ApiKey, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + region, + modelName, + suffix, + info.ApiKey, + ), nil + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix := "" if a.RequestMode == RequestModeGemini { - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { // 新增逻辑:处理 -thinking- 格式 if strings.Contains(info.UpstreamModelName, "-thinking-") { @@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix = "predict" } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - info.UpstreamModelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - info.UpstreamModelName, - suffix, - ), nil - } + return a.getRequestUrl(info, info.UpstreamModelName, suffix) } else if a.RequestMode == RequestModeClaude { if info.IsStream { suffix = "streamRawPredict?alt=sse" @@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { model = v } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", - adc.ProjectID, - model, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", - region, - adc.ProjectID, - region, - model, - suffix, - ), nil - } + return a.getRequestUrl(info, model, suffix) } else if a.RequestMode == RequestModeLlama { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", - region, - adc.ProjectID, - region, - ), nil + return a.getRequestUrl(info, "", "") } return "", errors.New("unsupported request mode") } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - accessToken, err := getAccessToken(a, info) - if err != nil { - return err + if info.ChannelOtherSettings.VertexKeyType == "json" { + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) } - req.Set("Authorization", "Bearer "+accessToken) return nil } diff --git a/router/api-router.go b/router/api-router.go index 773857385..e16d06628 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -60,6 +60,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) selfRoute.POST("/amount", controller.RequestAmount) diff --git a/service/epay.go b/service/epay.go index a8259d21d..a1ff484e6 100644 --- a/service/epay.go +++ b/service/epay.go @@ -2,11 +2,12 @@ package service import ( "one-api/setting" + "one-api/setting/operation_setting" ) func GetCallbackAddress() string { - if setting.CustomCallbackAddress == "" { + if operation_setting.CustomCallbackAddress == "" { return setting.ServerAddress } - return setting.CustomCallbackAddress + return operation_setting.CustomCallbackAddress } diff --git a/setting/operation_setting/payment_setting.go b/setting/operation_setting/payment_setting.go new file mode 100644 index 000000000..c8df039cf --- /dev/null +++ b/setting/operation_setting/payment_setting.go @@ -0,0 +1,23 @@ +package operation_setting + +import "one-api/setting/config" + +type PaymentSetting struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠 +} + +// 默认配置 +var paymentSetting = PaymentSetting{ + AmountOptions: []int{10, 20, 50, 100, 200, 500}, + AmountDiscount: map[int]float64{}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("payment_setting", &paymentSetting) +} + +func GetPaymentSetting() *PaymentSetting { + return &paymentSetting +} diff --git a/setting/payment.go b/setting/operation_setting/payment_setting_old.go similarity index 57% rename from setting/payment.go rename to setting/operation_setting/payment_setting_old.go index 7fc5ad3fd..a6313179e 100644 --- a/setting/payment.go +++ b/setting/operation_setting/payment_setting_old.go @@ -1,6 +1,13 @@ -package setting +/** +此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加 +This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go +*/ -import "encoding/json" +package operation_setting + +import ( + "one-api/common" +) var PayAddress = "" var CustomCallbackAddress = "" @@ -21,15 +28,21 @@ var PayMethods = []map[string]string{ "color": "rgba(var(--semi-green-5), 1)", "type": "wxpay", }, + { + "name": "自定义1", + "color": "black", + "type": "custom1", + "min_topup": "50", + }, } func UpdatePayMethodsByJsonString(jsonString string) error { PayMethods = make([]map[string]string, 0) - return json.Unmarshal([]byte(jsonString), &PayMethods) + return common.Unmarshal([]byte(jsonString), &PayMethods) } func PayMethods2JsonString() string { - jsonBytes, err := json.Marshal(PayMethods) + jsonBytes, err := common.Marshal(PayMethods) if err != nil { return "[]" } diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index a632760aa..faaa9561b 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -37,6 +37,8 @@ const PaymentSetting = () => { TopupGroupRatio: '', CustomCallbackAddress: '', PayMethods: '', + AmountOptions: '', + AmountDiscount: '', StripeApiSecret: '', StripeWebhookSecret: '', @@ -66,6 +68,30 @@ const PaymentSetting = () => { newInputs[item.key] = item.value; } break; + case 'payment_setting.amount_options': + try { + newInputs['AmountOptions'] = JSON.stringify( + JSON.parse(item.value), + null, + 2, + ); + } catch (error) { + console.error('解析AmountOptions出错:', error); + newInputs['AmountOptions'] = item.value; + } + break; + case 'payment_setting.amount_discount': + try { + newInputs['AmountDiscount'] = JSON.stringify( + JSON.parse(item.value), + null, + 2, + ); + } catch (error) { + console.error('解析AmountDiscount出错:', error); + newInputs['AmountDiscount'] = item.value; + } + break; case 'Price': case 'MinTopUp': case 'StripeUnitPrice': diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 7a86fa114..c0a216246 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -142,6 +142,8 @@ const EditChannelModal = (props) => { system_prompt: '', system_prompt_override: false, settings: '', + // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type) + vertex_key_type: 'json', }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -409,11 +411,17 @@ const EditChannelModal = (props) => { const parsedSettings = JSON.parse(data.settings); data.azure_responses_version = parsedSettings.azure_responses_version || ''; + // 读取 Vertex 密钥格式 + data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; + data.vertex_key_type = 'json'; } + } else { + // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 + data.vertex_key_type = 'json'; } setInputs(data); @@ -745,59 +753,56 @@ const EditChannelModal = (props) => { let localInputs = { ...formValues }; if (localInputs.type === 41) { - if (useManualInput) { - // 手动输入模式 - if (localInputs.key && localInputs.key.trim() !== '') { - try { - // 验证 JSON 格式 - const parsedKey = JSON.parse(localInputs.key); - // 确保是有效的密钥格式 - localInputs.key = JSON.stringify(parsedKey); - } catch (err) { - showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); - return; - } - } else if (!isEdit) { + const keyType = localInputs.vertex_key_type || 'json'; + if (keyType === 'api_key') { + // 直接作为普通字符串密钥处理 + if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) { showInfo(t('请输入密钥!')); return; } } else { - // 文件上传模式 - let keys = vertexKeys; - - // 若当前未选择文件,尝试从已上传文件列表解析(异步读取) - if (keys.length === 0 && vertexFileList.length > 0) { - try { - const parsed = await Promise.all( - vertexFileList.map(async (item) => { - const fileObj = item.fileInstance; - if (!fileObj) return null; - const txt = await fileObj.text(); - return JSON.parse(txt); - }), - ); - keys = parsed.filter(Boolean); - } catch (err) { - showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + // JSON 服务账号密钥 + if (useManualInput) { + if (localInputs.key && localInputs.key.trim() !== '') { + try { + const parsedKey = JSON.parse(localInputs.key); + localInputs.key = JSON.stringify(parsedKey); + } catch (err) { + showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); + return; + } + } else if (!isEdit) { + showInfo(t('请输入密钥!')); return; } - } - - // 创建模式必须上传密钥;编辑模式可选 - if (keys.length === 0) { - if (!isEdit) { - showInfo(t('请上传密钥文件!')); - return; - } else { - // 编辑模式且未上传新密钥,不修改 key - delete localInputs.key; - } } else { - // 有新密钥,则覆盖 - if (batch) { - localInputs.key = JSON.stringify(keys); + // 文件上传模式 + let keys = vertexKeys; + if (keys.length === 0 && vertexFileList.length > 0) { + try { + const parsed = await Promise.all( + vertexFileList.map(async (item) => { + const fileObj = item.fileInstance; + if (!fileObj) return null; + const txt = await fileObj.text(); + return JSON.parse(txt); + }), + ); + keys = parsed.filter(Boolean); + } catch (err) { + showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + return; + } + } + if (keys.length === 0) { + if (!isEdit) { + showInfo(t('请上传密钥文件!')); + return; + } else { + delete localInputs.key; + } } else { - localInputs.key = JSON.stringify(keys[0]); + localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]); } } } @@ -853,6 +858,8 @@ const EditChannelModal = (props) => { delete localInputs.pass_through_body_enabled; delete localInputs.system_prompt; delete localInputs.system_prompt_override; + // 顶层的 vertex_key_type 不应发送给后端 + delete localInputs.vertex_key_type; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => { autoComplete='new-password' /> + {inputs.type === 41 && ( + { + // 更新设置中的 vertex_key_type + handleChannelOtherSettingsChange('vertex_key_type', value); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } + } + }} + extraText={ + inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') + } + /> + )} {batch ? ( - inputs.type === 41 ? ( + inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( { ) ) : ( <> - {inputs.type === 41 ? ( + {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 7fb06b0ca..f23381f40 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -21,6 +21,7 @@ import React, { useRef } from 'react'; import { Avatar, Typography, + Tag, Card, Button, Banner, @@ -29,7 +30,7 @@ import { Space, Row, Col, - Spin, + Spin, Tooltip } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -68,6 +69,7 @@ const RechargeCard = ({ userState, renderQuota, statusLoading, + topupInfo, }) => { const onlineFormApiRef = useRef(null); const redeemFormApiRef = useRef(null); @@ -261,44 +263,58 @@ const RechargeCard = ({
- - {payMethods.map((payMethod) => ( - - ))} - + {payMethods && payMethods.length > 0 ? ( + + {payMethods.map((payMethod) => { + const minTopupVal = Number(payMethod.min_topup) || 0; + const isStripe = payMethod.type === 'stripe'; + const disabled = + (!enableOnlineTopUp && !isStripe) || + (!enableStripeTopUp && isStripe) || + minTopupVal > Number(topUpCount || 0); + + const buttonEl = ( + + ); + + return disabled && minTopupVal > Number(topUpCount || 0) ? ( + + {buttonEl} + + ) : ( + {buttonEl} + ); + })} + + ) : ( +
+ {t('暂无可用的支付方式,请联系管理员配置')} +
+ )}
@@ -306,41 +322,59 @@ const RechargeCard = ({ {(enableOnlineTopUp || enableStripeTopUp) && ( - - {presetAmounts.map((preset, index) => ( - - ))} - +
+ {presetAmounts.map((preset, index) => { + const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const originalPrice = preset.value * priceRatio; + const discountedPrice = originalPrice * discount; + const hasDiscount = discount < 1.0; + const actualPay = discountedPrice; + const save = originalPrice - discountedPrice; + + return ( + { + selectPresetAmount(preset); + onlineFormApiRef.current?.setValue( + 'topUpCount', + preset.value, + ); + }} + > +
+ + {formatLargeNumber(preset.value)} {t('美元额度')} + {hasDiscount && ( + + {t('折').includes('off') ? + ((1 - discount) * 100).toFixed(1) : + (discount * 10).toFixed(1)}{t('折')} + + )} + +
+ {t('实付')} {actualPay.toFixed(2)}, + {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} +
+
+
+ ); + })} +
)} diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index a09244488..929a47e39 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,6 +80,12 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); + + // 充值配置信息 + const [topupInfo, setTopupInfo] = useState({ + amount_options: [], + discount: {} + }); const topUp = async () => { if (redemptionCode === '') { @@ -248,6 +254,99 @@ const TopUp = () => { } }; + // 获取充值配置信息 + const getTopupInfo = async () => { + try { + const res = await API.get('/api/user/topup/info'); + const { message, data, success } = res.data; + if (success) { + setTopupInfo({ + amount_options: data.amount_options || [], + discount: data.discount || {} + }); + + // 处理支付方式 + let payMethods = data.pay_methods || []; + try { + if (typeof payMethods === 'string') { + payMethods = JSON.parse(payMethods); + } + if (payMethods && payMethods.length > 0) { + // 检查name和type是否为空 + payMethods = payMethods.filter((method) => { + return method.name && method.type; + }); + // 如果没有color,则设置默认颜色 + payMethods = payMethods.map((method) => { + // 规范化最小充值数 + const normalizedMinTopup = Number(method.min_topup); + method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + + // Stripe 的最小充值从后端字段回填 + if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + const stripeMin = Number(data.stripe_min_topup); + if (Number.isFinite(stripeMin)) { + method.min_topup = stripeMin; + } + } + + if (!method.color) { + if (method.type === 'alipay') { + method.color = 'rgba(var(--semi-blue-5), 1)'; + } else if (method.type === 'wxpay') { + method.color = 'rgba(var(--semi-green-5), 1)'; + } else if (method.type === 'stripe') { + method.color = 'rgba(var(--semi-purple-5), 1)'; + } else { + method.color = 'rgba(var(--semi-primary-5), 1)'; + } + } + return method; + }); + } else { + payMethods = []; + } + + // 如果启用了 Stripe 支付,添加到支付方法列表 + // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它 + + setPayMethods(payMethods); + const enableStripeTopUp = data.enable_stripe_topup || false; + const enableOnlineTopUp = data.enable_online_topup || false; + const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + setEnableOnlineTopUp(enableOnlineTopUp); + setEnableStripeTopUp(enableStripeTopUp); + setMinTopUp(minTopUpValue); + setTopUpCount(minTopUpValue); + + // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项 + if (topupInfo.amount_options.length === 0) { + setPresetAmounts(generatePresetAmounts(minTopUpValue)); + } + + // 初始化显示实付金额 + getAmount(minTopUpValue); + } catch (e) { + console.log('解析支付方式失败:', e); + setPayMethods([]); + } + + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 + if (data.amount_options && data.amount_options.length > 0) { + const customPresets = data.amount_options.map(amount => ({ + value: amount, + discount: data.discount[amount] || 1.0 + })); + setPresetAmounts(customPresets); + } + } else { + console.error('获取充值配置失败:', data); + } + } catch (error) { + console.error('获取充值配置异常:', error); + } + }; + // 获取邀请链接 const getAffLink = async () => { const res = await API.get('/api/user/aff'); @@ -290,52 +389,7 @@ const TopUp = () => { getUserQuota().then(); } setTransferAmount(getQuotaPerUnit()); - - let payMethods = localStorage.getItem('pay_methods'); - try { - payMethods = JSON.parse(payMethods); - if (payMethods && payMethods.length > 0) { - // 检查name和type是否为空 - payMethods = payMethods.filter((method) => { - return method.name && method.type; - }); - // 如果没有color,则设置默认颜色 - payMethods = payMethods.map((method) => { - if (!method.color) { - if (method.type === 'alipay') { - method.color = 'rgba(var(--semi-blue-5), 1)'; - } else if (method.type === 'wxpay') { - method.color = 'rgba(var(--semi-green-5), 1)'; - } else if (method.type === 'stripe') { - method.color = 'rgba(var(--semi-purple-5), 1)'; - } else { - method.color = 'rgba(var(--semi-primary-5), 1)'; - } - } - return method; - }); - } else { - payMethods = []; - } - - // 如果启用了 Stripe 支付,添加到支付方法列表 - if (statusState?.status?.enable_stripe_topup) { - const hasStripe = payMethods.some((method) => method.type === 'stripe'); - if (!hasStripe) { - payMethods.push({ - name: 'Stripe', - type: 'stripe', - color: 'rgba(var(--semi-purple-5), 1)', - }); - } - } - - setPayMethods(payMethods); - } catch (e) { - console.log(e); - showError(t('支付方式配置错误, 请联系管理员')); - } - }, [statusState?.status?.enable_stripe_topup]); + }, []); useEffect(() => { if (affFetchedRef.current) return; @@ -343,20 +397,18 @@ const TopUp = () => { getAffLink().then(); }, []); + // 在 statusState 可用时获取充值信息 + useEffect(() => { + getTopupInfo().then(); + }, []); + useEffect(() => { if (statusState?.status) { - const minTopUpValue = statusState.status.min_topup || 1; - setMinTopUp(minTopUpValue); - setTopUpCount(minTopUpValue); + // const minTopUpValue = statusState.status.min_topup || 1; + // setMinTopUp(minTopUpValue); + // setTopUpCount(minTopUpValue); setTopUpLink(statusState.status.top_up_link || ''); - setEnableOnlineTopUp(statusState.status.enable_online_topup || false); setPriceRatio(statusState.status.price || 1); - setEnableStripeTopUp(statusState.status.enable_stripe_topup || false); - - // 根据最小充值金额生成预设充值额度选项 - setPresetAmounts(generatePresetAmounts(minTopUpValue)); - // 初始化显示实付金额 - getAmount(minTopUpValue); setStatusLoading(false); } @@ -431,7 +483,11 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - setAmount(preset.value * priceRatio); + + // 计算实际支付金额,考虑折扣 + const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; + const discountedAmount = preset.value * priceRatio * discount; + setAmount(discountedAmount); }; // 格式化大数字显示 @@ -475,6 +531,8 @@ const TopUp = () => { renderAmount={renderAmount} payWay={payWay} payMethods={payMethods} + amountNumber={amount} + discountRate={topupInfo?.discount?.[topUpCount] || 1.0} /> {/* 用户信息头部 */} @@ -512,6 +570,7 @@ const TopUp = () => { userState={userState} renderQuota={renderQuota} statusLoading={statusLoading} + topupInfo={topupInfo} /> diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 76ea5eb22..1bffbfed1 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -36,7 +36,13 @@ const PaymentConfirmModal = ({ renderAmount, payWay, payMethods, + // 新增:用于显示折扣明细 + amountNumber, + discountRate, }) => { + const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; + const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; return ( ) : ( - - {renderAmount()} - +
+ + {renderAmount()} + + {hasDiscount && ( + + {Math.round(discountRate * 100)}% + + )} +
)} + {hasDiscount && !amountLoading && ( + <> +
+ + {t('原价')}: + + + {`${originalAmount.toFixed(2)} ${t('元')}`} + +
+
+ + {t('优惠')}: + + + {`- ${discountAmount.toFixed(2)} ${t('元')}`} + +
+ + )}
{t('支付方式')}: diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index 62353327c..b894a953c 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.js @@ -28,7 +28,6 @@ export function setStatusData(data) { localStorage.setItem('enable_task', data.enable_task); localStorage.setItem('enable_data_export', data.enable_data_export); localStorage.setItem('chats', JSON.stringify(data.chats)); - localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods)); localStorage.setItem( 'data_export_default_time', data.data_export_default_time, diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index ce8958dca..d681b6a27 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -41,6 +41,8 @@ export default function SettingsPaymentGateway(props) { TopupGroupRatio: '', CustomCallbackAddress: '', PayMethods: '', + AmountOptions: '', + AmountDiscount: '', }); const [originInputs, setOriginInputs] = useState({}); const formApiRef = useRef(null); @@ -62,7 +64,30 @@ export default function SettingsPaymentGateway(props) { TopupGroupRatio: props.options.TopupGroupRatio || '', CustomCallbackAddress: props.options.CustomCallbackAddress || '', PayMethods: props.options.PayMethods || '', + AmountOptions: props.options.AmountOptions || '', + AmountDiscount: props.options.AmountDiscount || '', }; + + // 美化 JSON 展示 + try { + if (currentInputs.AmountOptions) { + currentInputs.AmountOptions = JSON.stringify( + JSON.parse(currentInputs.AmountOptions), + null, + 2, + ); + } + } catch {} + try { + if (currentInputs.AmountDiscount) { + currentInputs.AmountDiscount = JSON.stringify( + JSON.parse(currentInputs.AmountDiscount), + null, + 2, + ); + } + } catch {} + setInputs(currentInputs); setOriginInputs({ ...currentInputs }); formApiRef.current.setValues(currentInputs); @@ -93,6 +118,20 @@ export default function SettingsPaymentGateway(props) { } } + if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if (!verifyJSON(inputs.AmountOptions)) { + showError(t('自定义充值数量选项不是合法的 JSON 数组')); + return; + } + } + + if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if (!verifyJSON(inputs.AmountDiscount)) { + showError(t('充值金额折扣配置不是合法的 JSON 对象')); + return; + } + } + setLoading(true); try { const options = [ @@ -123,6 +162,12 @@ export default function SettingsPaymentGateway(props) { if (originInputs['PayMethods'] !== inputs.PayMethods) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } + if (originInputs['AmountOptions'] !== inputs.AmountOptions) { + options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + } + if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { + options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + } // 发送请求 const requestQueue = options.map((opt) => @@ -228,6 +273,37 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> + + +
+ + + + + + + + + + From 1bffe3081dde8b6c9c35d4bced59bb23f3b7d396 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Sep 2025 21:14:10 +0800 Subject: [PATCH 56/64] =?UTF-8?q?feat(settings):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E7=BE=8E=E5=85=83=E9=A2=9D=E5=BA=A6=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9=EF=BC=8C=E4=B8=BA=E5=90=8E=E7=BB=AD=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=BD=9C=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/Operation/SettingsGeneral.jsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 37b3dd984..5af750ec3 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -130,17 +130,19 @@ export default function GeneralSettings(props) { showClear /> - - setShowQuotaWarning(true)} - /> - + {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( + + setShowQuotaWarning(true)} + /> + + )} Date: Fri, 12 Sep 2025 21:53:21 +0800 Subject: [PATCH 57/64] feat(i18n): update TOTP verification message with configuration details --- web/src/components/common/modals/TwoFactorAuthModal.jsx | 2 +- web/src/i18n/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index b0fc28e2a..2a9a8b25b 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -135,7 +135,7 @@ const TwoFactorAuthModal = ({ autoFocus /> - {t('支持6位TOTP验证码或8位备用码')} + {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f47839f2e..73dfbebe7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1993,7 +1993,7 @@ "安全验证": "Security verification", "验证": "Verify", "为了保护账户安全,请验证您的两步验证码。": "To protect account security, please verify your two-factor authentication code.", - "支持6位TOTP验证码或8位备用码": "Supports 6-digit TOTP verification code or 8-digit backup code", + "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Supports 6-digit TOTP verification code or 8-digit backup code, can be configured or viewed in `Personal Settings - Security Settings - Two-Factor Authentication Settings`.", "获取密钥失败": "Failed to get key", "查看密钥": "View key", "查看渠道密钥": "View channel key", From 6ed775be8f55787f0af6eb98b60634c73be2d94d Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 12 Sep 2025 21:52:32 +0800 Subject: [PATCH 58/64] refactor: use common taskSubmitReq --- relay/channel/task/jimeng/adaptor.go | 18 +------ relay/channel/task/kling/adaptor.go | 34 ++----------- relay/channel/task/vidu/adaptor.go | 33 ++---------- relay/common/relay_info.go | 8 +++ relay/common/relay_utils.go | 75 ++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 76 deletions(-) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index 955e592a2..f838bdb16 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -18,7 +18,6 @@ import ( "github.com/gin-gonic/gin" "github.com/pkg/errors" - "one-api/common" "one-api/constant" "one-api/dto" "one-api/relay/channel" @@ -89,22 +88,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { // Accept only POST /v1/video/generations as "generate" action. - 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 + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } // BuildRequestURL constructs the upstream URL. diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index 3d6da253b..13f2af972 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -16,7 +16,6 @@ import ( "github.com/golang-jwt/jwt" "github.com/pkg/errors" - "one-api/common" "one-api/constant" "one-api/dto" "one-api/relay/channel" @@ -28,16 +27,6 @@ import ( // Request / Response structures // ============================ -type SubmitReq 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 TrajectoryPoint struct { X int `json:"x"` Y int `json:"y"` @@ -121,23 +110,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { - // Accept only POST /v1/video/generations as "generate" action. - action := constant.TaskActionGenerate - info.Action = action - - var req SubmitReq - 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 + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } // BuildRequestURL constructs the upstream URL. @@ -166,7 +140,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn if !exists { return nil, fmt.Errorf("request not found in context") } - req := v.(SubmitReq) + req := v.(relaycommon.TaskSubmitReq) body, err := a.convertToRequestPayload(&req) if err != nil { @@ -255,7 +229,7 @@ func (a *TaskAdaptor) GetChannelName() string { // helpers // ============================ -func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { r := requestPayload{ Prompt: req.Prompt, Image: req.Image, diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go index c82c1c0e8..a1140d1e7 100644 --- a/relay/channel/task/vidu/adaptor.go +++ b/relay/channel/task/vidu/adaptor.go @@ -23,16 +23,6 @@ import ( // Request / Response structures // ============================ -type SubmitReq 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 requestPayload struct { Model string `json:"model"` Images []string `json:"images"` @@ -90,23 +80,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { } func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { - var req SubmitReq - if err := c.ShouldBindJSON(&req); err != nil { - return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest) - } - - if req.Prompt == "" { - return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "missing_prompt", http.StatusBadRequest) - } - - if req.Image != "" { - info.Action = constant.TaskActionGenerate - } else { - info.Action = constant.TaskActionTextGenerate - } - - c.Set("task_request", req) - return nil + // Use the unified validation method for TaskSubmitReq with image-based action determination + return relaycommon.ValidateTaskRequestWithImageBinding(c, info) } func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { @@ -114,7 +89,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) if !exists { return nil, fmt.Errorf("request not found in context") } - req := v.(SubmitReq) + req := v.(relaycommon.TaskSubmitReq) body, err := a.convertToRequestPayload(&req) if err != nil { @@ -211,7 +186,7 @@ func (a *TaskAdaptor) GetChannelName() string { // helpers // ============================ -func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { var images []string if req.Image != "" { images = []string{req.Image} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index da572c070..eb292de23 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -486,6 +486,14 @@ type TaskSubmitReq struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } +func (t TaskSubmitReq) GetPrompt() string { + return t.Prompt +} + +func (t TaskSubmitReq) GetImage() string { + return t.Image +} + type TaskInfo struct { Code int `json:"code"` TaskID string `json:"task_id"` diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 3d5efcb6d..108395613 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -2,12 +2,23 @@ package common import ( "fmt" + "net/http" + "one-api/common" "one-api/constant" + "one-api/dto" "strings" "github.com/gin-gonic/gin" ) +type HasPrompt interface { + GetPrompt() string +} + +type HasImage interface { + GetImage() string +} + func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) @@ -30,3 +41,67 @@ func GetAPIVersion(c *gin.Context) string { } return apiVersion } + +func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError { + return &dto.TaskError{ + Code: code, + Message: err.Error(), + StatusCode: statusCode, + LocalError: localError, + Error: err, + } +} + +func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj interface{}) { + info.Action = action + c.Set("task_request", requestObj) +} + +func validatePrompt(prompt string) *dto.TaskError { + if strings.TrimSpace(prompt) == "" { + return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true) + } + return nil +} + +func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError { + var req TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return createTaskError(err, "invalid_request", http.StatusBadRequest, true) + } + + if taskErr := validatePrompt(req.Prompt); taskErr != nil { + return taskErr + } + + storeTaskRequest(c, info, action, req) + return nil +} + +func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError { + hasPrompt, ok := requestObj.(HasPrompt) + if !ok { + return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true) + } + + if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil { + return taskErr + } + + action := constant.TaskActionTextGenerate + if hasImage, ok := requestObj.(HasImage); ok && strings.TrimSpace(hasImage.GetImage()) != "" { + action = constant.TaskActionGenerate + } + + storeTaskRequest(c, info, action, requestObj) + return nil +} + +func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError { + var req TaskSubmitReq + if err := c.ShouldBindJSON(&req); err != nil { + return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false) + } + + return ValidateTaskRequestWithImage(c, info, req) +} From f14b06ec3a88023b3f4ef17f90e6e815bd4a75d2 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 12 Sep 2025 22:19:45 +0800 Subject: [PATCH 59/64] feat: jimeng video add images --- relay/channel/task/jimeng/adaptor.go | 8 ++++---- relay/common/relay_info.go | 5 +++-- relay/common/relay_utils.go | 9 +++++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index f838bdb16..2bc45c547 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -318,11 +318,11 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* } // Handle one-of image_urls or binary_data_base64 - if req.Image != "" { - if strings.HasPrefix(req.Image, "http") { - r.ImageUrls = []string{req.Image} + if req.HasImage() { + if strings.HasPrefix(req.Images[0], "http") { + r.ImageUrls = req.Images } else { - r.BinaryDataBase64 = []string{req.Image} + r.BinaryDataBase64 = req.Images } } metadata := req.Metadata diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index eb292de23..99925dc5d 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -481,6 +481,7 @@ type TaskSubmitReq struct { Model string `json:"model,omitempty"` Mode string `json:"mode,omitempty"` Image string `json:"image,omitempty"` + Images []string `json:"images,omitempty"` Size string `json:"size,omitempty"` Duration int `json:"duration,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -490,8 +491,8 @@ func (t TaskSubmitReq) GetPrompt() string { return t.Prompt } -func (t TaskSubmitReq) GetImage() string { - return t.Image +func (t TaskSubmitReq) HasImage() bool { + return len(t.Images) > 0 } type TaskInfo struct { diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 108395613..cf6d08dda 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -16,7 +16,7 @@ type HasPrompt interface { } type HasImage interface { - GetImage() string + HasImage() bool } func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { @@ -74,6 +74,11 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d return taskErr } + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + // 兼容单图上传 + req.Images = []string{req.Image} + } + storeTaskRequest(c, info, action, req) return nil } @@ -89,7 +94,7 @@ func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj in } action := constant.TaskActionTextGenerate - if hasImage, ok := requestObj.(HasImage); ok && strings.TrimSpace(hasImage.GetImage()) != "" { + if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() { action = constant.TaskActionGenerate } From 6451158680ac671e65f7691f1197b0f9f51c4637 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Sep 2025 12:53:28 +0800 Subject: [PATCH 60/64] =?UTF-8?q?Revert=20"feat:=20gemini-2.5-flash-image-?= =?UTF-8?q?preview=20=E6=96=87=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E8=AE=A1=E8=B4=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e732c5842675d2aeeb3faa2af633341fb9d9c1ac. --- dto/gemini.go | 16 ++++---- relay/channel/gemini/relay-gemini-native.go | 36 ------------------ relay/compatible_handler.go | 15 -------- service/token_counter.go | 2 +- setting/model_setting/gemini.go | 1 - setting/operation_setting/tools.go | 11 ------ setting/ratio_setting/model_ratio.go | 10 ++--- web/src/helpers/render.jsx | 38 ++++--------------- web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 - 9 files changed, 20 insertions(+), 111 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index cd5d74cdd..5df67ba0b 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -2,12 +2,11 @@ package dto import ( "encoding/json" + "github.com/gin-gonic/gin" "one-api/common" "one-api/logger" "one-api/types" "strings" - - "github.com/gin-gonic/gin" ) type GeminiChatRequest struct { @@ -269,15 +268,14 @@ type GeminiChatResponse struct { } type GeminiUsageMetadata struct { - PromptTokenCount int `json:"promptTokenCount"` - CandidatesTokenCount int `json:"candidatesTokenCount"` - TotalTokenCount int `json:"totalTokenCount"` - ThoughtsTokenCount int `json:"thoughtsTokenCount"` - PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"` - CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"` + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` } -type GeminiModalityTokenCount struct { +type GeminiPromptTokensDetails struct { Modality string `json:"modality"` TokenCount int `json:"tokenCount"` } diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 564b86908..974a22f50 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -46,32 +46,6 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount - if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { - imageOutputCounts := 0 - for _, candidate := range geminiResponse.Candidates { - for _, part := range candidate.Content.Parts { - if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") { - imageOutputCounts++ - } - } - } - if imageOutputCounts != 0 { - usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290 - usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290 - c.Set("gemini_image_tokens", imageOutputCounts*1290) - } - } - - // if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { - // for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { - // if detail.Modality == "IMAGE" { - // usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount - // usage.TotalTokens = usage.TotalTokens - detail.TokenCount - // c.Set("gemini_image_tokens", detail.TokenCount) - // } - // } - // } - for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { if detail.Modality == "AUDIO" { usage.PromptTokensDetails.AudioTokens = detail.TokenCount @@ -162,16 +136,6 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn usage.PromptTokensDetails.TextTokens = detail.TokenCount } } - - if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { - for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { - if detail.Modality == "IMAGE" { - usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount - usage.TotalTokens = usage.TotalTokens - detail.TokenCount - c.Set("gemini_image_tokens", detail.TokenCount) - } - } - } } // 直接发送 GeminiChatResponse 响应 diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 8f27fd60b..01ab1fff4 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -326,22 +326,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } else { quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) } - var dGeminiImageOutputQuota decimal.Decimal - var imageOutputPrice float64 - if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { - imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName) - if imageOutputPrice > 0 { - dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens"))) - dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) - } - } // 添加 responses tools call 调用的配额 quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) // 添加 audio input 独立计费 quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) - // 添加 Gemini image output 计费 - quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -440,10 +429,6 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage other["audio_input_token_count"] = audioTokens other["audio_input_price"] = audioInputPrice } - if !dGeminiImageOutputQuota.IsZero() { - other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens") - other["image_output_price"] = imageOutputPrice - } model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ ChannelId: relayInfo.ChannelId, PromptTokens: promptTokens, diff --git a/service/token_counter.go b/service/token_counter.go index da56523fe..be5c2e80c 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -336,7 +336,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco for i, file := range meta.Files { switch file.FileType { case types.FileTypeImage: - if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") { + if info.RelayFormat == types.RelayFormatGemini { tkm += 256 } else { token, err := getImageToken(file, model, info.IsStream) diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go index 5412155f1..f132fec88 100644 --- a/setting/model_setting/gemini.go +++ b/setting/model_setting/gemini.go @@ -26,7 +26,6 @@ var defaultGeminiSettings = GeminiSettings{ SupportedImagineModels: []string{ "gemini-2.0-flash-exp-image-generation", "gemini-2.0-flash-exp", - "gemini-2.5-flash-image-preview", }, ThinkingAdapterEnabled: false, ThinkingAdapterBudgetTokensPercentage: 0.6, diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index b87265ee1..549a1862e 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -24,10 +24,6 @@ const ( ClaudeWebSearchPrice = 10.00 ) -const ( - Gemini25FlashImagePreviewImageOutputPrice = 30.00 -) - func GetClaudeWebSearchPricePerThousand() float64 { return ClaudeWebSearchPrice } @@ -69,10 +65,3 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { } return 0 } - -func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 { - if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { - return Gemini25FlashImagePreviewImageOutputPrice - } - return 0 -} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 1a1b0afa8..f06cd71ef 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -178,7 +178,6 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, - "gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -294,11 +293,10 @@ var ( ) var defaultCompletionRatio = map[string]float64{ - "gpt-4-gizmo-*": 2, - "gpt-4o-gizmo-*": 3, - "gpt-4-all": 2, - "gpt-image-1": 8, - "gemini-2.5-flash-image-preview": 8.3333333333, + "gpt-4-gizmo-*": 2, + "gpt-4o-gizmo-*": 3, + "gpt-4-all": 2, + "gpt-image-1": 8, } // InitRatioSettings initializes all model related settings maps diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 3d9d8d710..65332701b 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1017,7 +1017,7 @@ export function renderModelPrice( cacheRatio = 1.0, image = false, imageRatio = 1.0, - imageInputTokens = 0, + imageOutputTokens = 0, webSearch = false, webSearchCallCount = 0, webSearchPrice = 0, @@ -1027,8 +1027,6 @@ export function renderModelPrice( audioInputSeperatePrice = false, audioInputTokens = 0, audioInputPrice = 0, - imageOutputTokens = 0, - imageOutputPrice = 0, ) { const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( groupRatio, @@ -1059,9 +1057,9 @@ export function renderModelPrice( let effectiveInputTokens = inputTokens - cacheTokens + cacheTokens * cacheRatio; // Handle image tokens if present - if (image && imageInputTokens > 0) { + if (image && imageOutputTokens > 0) { effectiveInputTokens = - inputTokens - imageInputTokens + imageInputTokens * imageRatio; + inputTokens - imageOutputTokens + imageOutputTokens * imageRatio; } if (audioInputTokens > 0) { effectiveInputTokens -= audioInputTokens; @@ -1071,8 +1069,7 @@ export function renderModelPrice( (audioInputTokens / 1000000) * audioInputPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + - (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageOutputTokens / 1000000) * imageOutputPrice * groupRatio; + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; return ( <> @@ -1107,7 +1104,7 @@ export function renderModelPrice( )}

)} - {image && imageInputTokens > 0 && ( + {image && imageOutputTokens > 0 && (

{i18next.t( '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})', @@ -1134,26 +1131,17 @@ export function renderModelPrice( })}

)} - {imageOutputPrice > 0 && imageOutputTokens > 0 && ( -

- {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', { - price: imageOutputPrice, - ratio: groupRatio, - total: imageOutputPrice * groupRatio, - })} -

- )}

{(() => { // 构建输入部分描述 let inputDesc = ''; - if (image && imageInputTokens > 0) { + if (image && imageOutputTokens > 0) { inputDesc = i18next.t( '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}', { - nonImageInput: inputTokens - imageInputTokens, - imageInput: imageInputTokens, + nonImageInput: inputTokens - imageOutputTokens, + imageInput: imageOutputTokens, imageRatio: imageRatio, price: inputRatioPrice, }, @@ -1223,16 +1211,6 @@ export function renderModelPrice( }, ) : '', - imageOutputPrice > 0 && imageOutputTokens > 0 - ? i18next.t( - ' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}', - { - tokenCounts: imageOutputTokens, - price: imageOutputPrice, - ratio: groupRatio, - }, - ) - : '', ].join(''); return i18next.t( diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 3584f1d9b..81f3f539a 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -447,8 +447,6 @@ export const useLogsData = () => { other?.audio_input_seperate_price || false, other?.audio_input_token_count || 0, other?.audio_input_price || 0, - other?.image_output_token_count || 0, - other?.image_output_price || 0, ); } expandDataLocal.push({ From c1d7ecdeec73ad5eaaad0626ee0262930ce67142 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Sep 2025 12:53:41 +0800 Subject: [PATCH 61/64] fix(adaptor): correct VertexKeyType condition in SetupRequestHeader --- relay/channel/vertex/adaptor.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index b6a78b7aa..7e2fdcad3 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -160,7 +160,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if strings.HasPrefix(info.UpstreamModelName, "imagen") { suffix = "predict" } - return a.getRequestUrl(info, info.UpstreamModelName, suffix) } else if a.RequestMode == RequestModeClaude { if info.IsStream { @@ -181,7 +180,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - if info.ChannelOtherSettings.VertexKeyType == "json" { + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { accessToken, err := getAccessToken(a, info) if err != nil { return err From 28ed42130c9e6397580be3172a12ebd5dc2da096 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Sep 2025 15:24:48 +0800 Subject: [PATCH 62/64] fix: update references from setting to system_setting for ServerAddress --- controller/midjourney.go | 5 +++-- controller/misc.go | 4 ++-- controller/oidc.go | 3 +-- controller/topup.go | 3 ++- controller/topup_stripe.go | 5 +++-- model/option.go | 15 ++++++++------- relay/mjproxy_handler.go | 3 ++- service/epay.go | 4 ++-- service/quota.go | 4 ++-- .../system_setting_old.go} | 2 +- 10 files changed, 26 insertions(+), 22 deletions(-) rename setting/{system_setting.go => system_setting/system_setting_old.go} (89%) diff --git a/controller/midjourney.go b/controller/midjourney.go index a67d39c23..3a7304419 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -13,6 +13,7 @@ import ( "one-api/model" "one-api/service" "one-api/setting" + "one-api/setting/system_setting" "time" "github.com/gin-gonic/gin" @@ -259,7 +260,7 @@ func GetAllMidjourney(c *gin.Context) { if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId items[i] = midjourney } } @@ -284,7 +285,7 @@ func GetUserMidjourney(c *gin.Context) { if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId items[i] = midjourney } } diff --git a/controller/misc.go b/controller/misc.go index 085829302..875142ffb 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -58,7 +58,7 @@ func GetStatus(c *gin.Context) { "footer_html": common.Footer, "wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_login": common.WeChatAuthEnabled, - "server_address": setting.ServerAddress, + "server_address": system_setting.ServerAddress, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, "top_up_link": common.TopUpLink, @@ -249,7 +249,7 @@ func SendPasswordResetEmail(c *gin.Context) { } code := common.GenerateVerificationCode(0) common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) - link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code) + link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) subject := fmt.Sprintf("%s密码重置", common.SystemName) content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ "

点击 此处 进行密码重置。

"+ diff --git a/controller/oidc.go b/controller/oidc.go index f3def0e34..8e254d38f 100644 --- a/controller/oidc.go +++ b/controller/oidc.go @@ -8,7 +8,6 @@ import ( "net/url" "one-api/common" "one-api/model" - "one-api/setting" "one-api/setting/system_setting" "strconv" "strings" @@ -45,7 +44,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) { values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret) values.Set("code", code) values.Set("grant_type", "authorization_code") - values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress)) + values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress)) formData := values.Encode() req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData)) if err != nil { diff --git a/controller/topup.go b/controller/topup.go index 93f3e58e0..243e67940 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -10,6 +10,7 @@ import ( "one-api/service" "one-api/setting" "one-api/setting/operation_setting" + "one-api/setting/system_setting" "strconv" "sync" "time" @@ -152,7 +153,7 @@ func RequestEpay(c *gin.Context) { } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log") + returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log") notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index bf0d7bf36..d462acb4b 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -9,6 +9,7 @@ import ( "one-api/model" "one-api/setting" "one-api/setting/operation_setting" + "one-api/setting/system_setting" "strconv" "strings" "time" @@ -216,8 +217,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i params := &stripe.CheckoutSessionParams{ ClientReferenceID: stripe.String(referenceId), - SuccessURL: stripe.String(setting.ServerAddress + "/log"), - CancelURL: stripe.String(setting.ServerAddress + "/topup"), + SuccessURL: stripe.String(system_setting.ServerAddress + "/log"), + CancelURL: stripe.String(system_setting.ServerAddress + "/topup"), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(setting.StripePriceId), diff --git a/model/option.go b/model/option.go index 73fe92ad1..fefee4e7d 100644 --- a/model/option.go +++ b/model/option.go @@ -6,6 +6,7 @@ import ( "one-api/setting/config" "one-api/setting/operation_setting" "one-api/setting/ratio_setting" + "one-api/setting/system_setting" "strconv" "strings" "time" @@ -66,9 +67,9 @@ func InitOptionMap() { common.OptionMap["SystemName"] = common.SystemName common.OptionMap["Logo"] = common.Logo common.OptionMap["ServerAddress"] = "" - common.OptionMap["WorkerUrl"] = setting.WorkerUrl - common.OptionMap["WorkerValidKey"] = setting.WorkerValidKey - common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(setting.WorkerAllowHttpImageRequestEnabled) + common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl + common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey + common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled) common.OptionMap["PayAddress"] = "" common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" @@ -271,7 +272,7 @@ func updateOptionMap(key string, value string) (err error) { case "SMTPSSLEnabled": common.SMTPSSLEnabled = boolValue case "WorkerAllowHttpImageRequestEnabled": - setting.WorkerAllowHttpImageRequestEnabled = boolValue + system_setting.WorkerAllowHttpImageRequestEnabled = boolValue case "DefaultUseAutoGroup": setting.DefaultUseAutoGroup = boolValue case "ExposeRatioEnabled": @@ -293,11 +294,11 @@ func updateOptionMap(key string, value string) (err error) { case "SMTPToken": common.SMTPToken = value case "ServerAddress": - setting.ServerAddress = value + system_setting.ServerAddress = value case "WorkerUrl": - setting.WorkerUrl = value + system_setting.WorkerUrl = value case "WorkerValidKey": - setting.WorkerValidKey = value + system_setting.WorkerValidKey = value case "PayAddress": operation_setting.PayAddress = value case "Chats": diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 7c52cb6be..ec8dfc6b2 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -16,6 +16,7 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting" + "one-api/setting/system_setting" "strconv" "strings" "time" @@ -131,7 +132,7 @@ func coverMidjourneyTaskDto(c *gin.Context, originTask *model.Midjourney) (midjo midjourneyTask.FinishTime = originTask.FinishTime midjourneyTask.ImageUrl = "" if originTask.ImageUrl != "" && setting.MjForwardUrlEnabled { - midjourneyTask.ImageUrl = setting.ServerAddress + "/mj/image/" + originTask.MjId + midjourneyTask.ImageUrl = system_setting.ServerAddress + "/mj/image/" + originTask.MjId if originTask.Status != "SUCCESS" { midjourneyTask.ImageUrl += "?rand=" + strconv.FormatInt(time.Now().UnixNano(), 10) } diff --git a/service/epay.go b/service/epay.go index a1ff484e6..48b84dd58 100644 --- a/service/epay.go +++ b/service/epay.go @@ -1,13 +1,13 @@ package service import ( - "one-api/setting" "one-api/setting/operation_setting" + "one-api/setting/system_setting" ) func GetCallbackAddress() string { if operation_setting.CustomCallbackAddress == "" { - return setting.ServerAddress + return system_setting.ServerAddress } return operation_setting.CustomCallbackAddress } diff --git a/service/quota.go b/service/quota.go index e078a1ad1..12017e11e 100644 --- a/service/quota.go +++ b/service/quota.go @@ -11,8 +11,8 @@ import ( "one-api/logger" "one-api/model" relaycommon "one-api/relay/common" - "one-api/setting" "one-api/setting/ratio_setting" + "one-api/setting/system_setting" "one-api/types" "strings" "time" @@ -534,7 +534,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon } if quotaTooLow { prompt := "您的额度即将用尽" - topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress) + topUpLink := fmt.Sprintf("%s/topup", system_setting.ServerAddress) // 根据通知方式生成不同的内容格式 var content string diff --git a/setting/system_setting.go b/setting/system_setting/system_setting_old.go similarity index 89% rename from setting/system_setting.go rename to setting/system_setting/system_setting_old.go index c37a61235..4e0f1a502 100644 --- a/setting/system_setting.go +++ b/setting/system_setting/system_setting_old.go @@ -1,4 +1,4 @@ -package setting +package system_setting var ServerAddress = "http://localhost:3000" var WorkerUrl = "" From da6f24a3d48c286e4509a4f0befcb263133ec41b Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 13 Sep 2025 16:26:14 +0800 Subject: [PATCH 63/64] fix veo3 adapter --- relay/channel/task/vertex/adaptor.go | 85 ++++++++++++++++------------ 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go index d2ab826d0..4a236b2f0 100644 --- a/relay/channel/task/vertex/adaptor.go +++ b/relay/channel/task/vertex/adaptor.go @@ -7,12 +7,12 @@ import ( "fmt" "io" "net/http" + "one-api/model" "regexp" "strings" "github.com/gin-gonic/gin" - "one-api/common" "one-api/constant" "one-api/dto" "one-api/relay/channel" @@ -21,6 +21,10 @@ import ( "one-api/service" ) +// ============================ +// Request / Response structures +// ============================ + type requestPayload struct { Instances []map[string]any `json:"instances"` Parameters map[string]any `json:"parameters,omitempty"` @@ -52,33 +56,35 @@ type operationResponse struct { } `json:"error"` } -type TaskAdaptor struct{} +// ============================ +// Adaptor implementation +// ============================ -func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {} - -func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { - info.Action = constant.TaskActionTextGenerate - - req := relaycommon.TaskSubmitReq{} - if err := common.UnmarshalBodyReusable(c, &req); err != nil { - return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) - } - if strings.TrimSpace(req.Prompt) == "" { - return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) - } - c.Set("task_request", req) - return nil +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string } -func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { adc := &vertexcore.Credentials{} - if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { return "", fmt.Errorf("failed to decode credentials: %w", err) } modelName := info.OriginModelName - if v, ok := getRequestModelFromContext(info); ok { - modelName = v - } if modelName == "" { modelName = "veo-3.0-generate-001" } @@ -103,16 +109,17 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, ), nil } -func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") adc := &vertexcore.Credentials{} - if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { return fmt.Errorf("failed to decode credentials: %w", err) } - token, err := vertexcore.AcquireAccessToken(*adc, info.ChannelSetting.Proxy) + token, err := vertexcore.AcquireAccessToken(*adc, "") if err != nil { return fmt.Errorf("failed to acquire access token: %w", err) } @@ -121,7 +128,8 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info return nil } -func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) { +// BuildRequestBody converts request into Vertex specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { v, ok := c.Get("task_request") if !ok { return nil, fmt.Errorf("request not found in context") @@ -151,11 +159,13 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayI return bytes.NewReader(data), nil } -func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { return channel.DoTaskApiRequest(a, c, info, requestBody) } -func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { responseBody, err := io.ReadAll(resp.Body) if err != nil { return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) @@ -177,6 +187,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relayco func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} } func (a *TaskAdaptor) GetChannelName() string { return "vertex" } +// 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 { @@ -191,15 +202,15 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http region = "us-central1" } project := extractProjectFromOperationName(upstreamName) - model := extractModelFromOperationName(upstreamName) - if project == "" || model == "" { + modelName := extractModelFromOperationName(upstreamName) + if project == "" || modelName == "" { return nil, fmt.Errorf("cannot extract project/model from operation name") } var url string if region == "global" { - url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, model) + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName) } else { - url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, model) + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName) } payload := map[string]string{"operationName": upstreamName} data, err := json.Marshal(payload) @@ -232,17 +243,17 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } ti := &relaycommon.TaskInfo{} if op.Error.Message != "" { - ti.Status = "FAILURE" + ti.Status = model.TaskStatusFailure ti.Reason = op.Error.Message ti.Progress = "100%" return ti, nil } if !op.Done { - ti.Status = "IN_PROGRESS" + ti.Status = model.TaskStatusInProgress ti.Progress = "50%" return ti, nil } - ti.Status = "SUCCESS" + ti.Status = model.TaskStatusSuccess ti.Progress = "100%" if len(op.Response.Videos) > 0 { v0 := op.Response.Videos[0] @@ -290,9 +301,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e return ti, nil } -func getRequestModelFromContext(info *relaycommon.TaskRelayInfo) (string, bool) { - return info.OriginModelName, info.OriginModelName != "" -} +// ============================ +// helpers +// ============================ func encodeLocalTaskID(name string) string { return base64.RawURLEncoding.EncodeToString([]byte(name)) From 9790e2c4f687053c4a10ab6c224819f3e8cc3080 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Mon, 15 Sep 2025 01:01:48 +0800 Subject: [PATCH 64/64] fix: gemini support webp file --- relay/channel/gemini/relay-gemini.go | 2 ++ service/pre_consume_quota.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index eb4afbae1..199c84664 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -23,6 +23,7 @@ import ( "github.com/gin-gonic/gin" ) +// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob var geminiSupportedMimeTypes = map[string]bool{ "application/pdf": true, "audio/mpeg": true, @@ -30,6 +31,7 @@ var geminiSupportedMimeTypes = map[string]bool{ "audio/wav": true, "image/png": true, "image/jpeg": true, + "image/webp": true, "text/plain": true, "video/mov": true, "video/mpeg": true, diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go index 3cfabc1a4..0cf53513b 100644 --- a/service/pre_consume_quota.go +++ b/service/pre_consume_quota.go @@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { gopool.Go(func() { relayInfoCopy := *relayInfo - err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false) + err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false) if err != nil { common.SysLog("error return pre-consumed quota: " + err.Error()) }