diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..34fc9495 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,69 @@ +name: Publish Docker image + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false +jobs: + push_to_registries: + name: Push Docker image to multiple registries + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Check repository URL + run: | + REPO_URL=$(git config --get remote.origin.url) + if [[ $REPO_URL == *"pro" ]]; then + exit 1 + fi + + - name: Save version info + run: | + git describe --tags > VERSION + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: | + justsong/one-api + ghcr.io/${{ github.repository }} + + - name: Build and push Docker images + uses: docker/build-push-action@v3 + with: + context: . + # platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 # TODO disable arm64 for now, because it cause error + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d85eafe3..5778910b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,44 +4,48 @@ WORKDIR /web COPY ./VERSION . COPY ./web . -WORKDIR /web/default -RUN npm install -RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build +RUN npm install --prefix /web/default & \ + npm install --prefix /web/berry & \ + npm install --prefix /web/air & \ + wait -WORKDIR /web/berry -RUN npm install -RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build - -WORKDIR /web/air -RUN npm install -RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build +RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/default/VERSION) npm run build --prefix /web/default & \ + DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/berry/VERSION) npm run build --prefix /web/berry & \ + DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/air/VERSION) npm run build --prefix /web/air & \ + wait FROM golang:1.23.5-bullseye AS builder2 -RUN apt-get update -RUN apt-get install -y --no-install-recommends g++ make gcc git build-essential ca-certificates \ - && update-ca-certificates 2>/dev/null || true \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + sqlite3 libsqlite3-dev \ && rm -rf /var/lib/apt/lists/* ENV GO111MODULE=on \ CGO_ENABLED=1 \ - GOOS=linux + GOOS=linux \ + CGO_CFLAGS="-I/usr/include" \ + CGO_LDFLAGS="-L/usr/lib" WORKDIR /build + ADD go.mod go.sum ./ RUN go mod download + COPY . . COPY --from=builder /web/build ./web/build -RUN go build -trimpath -ldflags "-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api +RUN go build -trimpath -ldflags "-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)'" -o one-api + +# Final runtime image FROM debian:bullseye -RUN apt-get update -RUN apt-get install -y --no-install-recommends ca-certificates haveged tzdata ffmpeg \ - && update-ca-certificates 2>/dev/null || true \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tzdata bash haveged ffmpeg \ && rm -rf /var/lib/apt/lists/* COPY --from=builder2 /build/one-api / + EXPOSE 3000 WORKDIR /data ENTRYPOINT ["/one-api"] diff --git a/common/config/config.go b/common/config/config.go index e98a1129..3114ee1b 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -178,3 +178,4 @@ var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30) // EnforceIncludeUsage is used to determine whether to include usage in the response var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false) +var TestPrompt = env.String("TEST_PROMPT", "Print your model name exactly and do not output without any other text.") diff --git a/common/helper/time.go b/common/helper/time.go index b74cc74a..c1cda8d1 100644 --- a/common/helper/time.go +++ b/common/helper/time.go @@ -14,3 +14,8 @@ func GetTimeString() string { now := time.Now() return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) } + +// CalcElapsedTime return the elapsed time in milliseconds (ms) +func CalcElapsedTime(start time.Time) int64 { + return time.Now().Sub(start).Milliseconds() +} diff --git a/controller/channel-test.go b/controller/channel-test.go index 6a5d4eef..fe5225f4 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -2,23 +2,9 @@ package controller import ( "bytes" + "context" "encoding/json" "fmt" - "github.com/gin-gonic/gin" - "github.com/pkg/errors" - "github.com/songquanpeng/one-api/common/config" - "github.com/songquanpeng/one-api/common/ctxkey" - "github.com/songquanpeng/one-api/common/logger" - "github.com/songquanpeng/one-api/common/message" - "github.com/songquanpeng/one-api/middleware" - "github.com/songquanpeng/one-api/model" - "github.com/songquanpeng/one-api/monitor" - relay "github.com/songquanpeng/one-api/relay" - "github.com/songquanpeng/one-api/relay/channeltype" - "github.com/songquanpeng/one-api/relay/controller" - "github.com/songquanpeng/one-api/relay/meta" - relaymodel "github.com/songquanpeng/one-api/relay/model" - "github.com/songquanpeng/one-api/relay/relaymode" "io" "net/http" "net/http/httptest" @@ -27,6 +13,24 @@ import ( "strings" "sync" "time" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "github.com/songquanpeng/one-api/common/config" + "github.com/songquanpeng/one-api/common/ctxkey" + "github.com/songquanpeng/one-api/common/helper" + "github.com/songquanpeng/one-api/common/logger" + "github.com/songquanpeng/one-api/common/message" + "github.com/songquanpeng/one-api/middleware" + "github.com/songquanpeng/one-api/model" + "github.com/songquanpeng/one-api/monitor" + "github.com/songquanpeng/one-api/relay" + "github.com/songquanpeng/one-api/relay/adaptor/openai" + "github.com/songquanpeng/one-api/relay/channeltype" + "github.com/songquanpeng/one-api/relay/controller" + "github.com/songquanpeng/one-api/relay/meta" + relaymodel "github.com/songquanpeng/one-api/relay/model" + "github.com/songquanpeng/one-api/relay/relaymode" ) func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest { @@ -34,18 +38,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest { model = "gpt-3.5-turbo" } testRequest := &relaymodel.GeneralOpenAIRequest{ - MaxTokens: 2, - Model: model, + Model: model, } testMessage := relaymodel.Message{ Role: "user", - Content: "hi", + Content: config.TestPrompt, } testRequest.Messages = append(testRequest.Messages, testMessage) return testRequest } -func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (err error, openaiErr *relaymodel.Error) { +func parseTestResponse(resp string) (*openai.TextResponse, string, error) { + var response openai.TextResponse + err := json.Unmarshal([]byte(resp), &response) + if err != nil { + return nil, "", err + } + if len(response.Choices) == 0 { + return nil, "", errors.New("response has no choices") + } + stringContent, ok := response.Choices[0].Content.(string) + if !ok { + return nil, "", errors.New("response content is not string") + } + return &response, stringContent, nil +} + +func testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) { + startTime := time.Now() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = &http.Request{ @@ -65,7 +85,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques apiType := channeltype.ToAPIType(channel.Type) adaptor := relay.GetAdaptor(apiType) if adaptor == nil { - return errors.Errorf("invalid api type: %d, adaptor is nil", apiType), nil + return "", fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil } adaptor.Init(meta) modelName := request.Model @@ -83,41 +103,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques request.Model = modelName convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request) if err != nil { - return err, nil + return "", err, nil } jsonData, err := json.Marshal(convertedRequest) if err != nil { - return err, nil + return "", err, nil } + defer func() { + logContent := fmt.Sprintf("渠道 %s 测试成功,响应:%s", channel.Name, responseMessage) + if err != nil || openaiErr != nil { + errorMessage := "" + if err != nil { + errorMessage = err.Error() + } else { + errorMessage = openaiErr.Message + } + logContent = fmt.Sprintf("渠道 %s 测试失败,错误:%s", channel.Name, errorMessage) + } + go model.RecordTestLog(ctx, &model.Log{ + ChannelId: channel.Id, + ModelName: modelName, + Content: logContent, + ElapsedTime: helper.CalcElapsedTime(startTime), + }) + }() logger.SysLog(string(jsonData)) requestBody := bytes.NewBuffer(jsonData) c.Request.Body = io.NopCloser(requestBody) resp, err := adaptor.DoRequest(c, meta, requestBody) if err != nil { - return err, nil + return "", err, nil } if resp != nil && resp.StatusCode != http.StatusOK { err := controller.RelayErrorHandler(resp) - return fmt.Errorf("status code %d: %s", resp.StatusCode, err.Error.Message), &err.Error + errorMessage := err.Error.Message + if errorMessage != "" { + errorMessage = ", error message: " + errorMessage + } + return "", fmt.Errorf("http status code: %d%s", resp.StatusCode, errorMessage), &err.Error } usage, respErr := adaptor.DoResponse(c, resp, meta) if respErr != nil { - return errors.Errorf("%s", respErr.Error.Message), &respErr.Error + return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error } if usage == nil { - return errors.New("usage is nil"), nil + return "", errors.New("usage is nil"), nil + } + rawResponse := w.Body.String() + _, responseMessage, err = parseTestResponse(rawResponse) + if err != nil { + return "", err, nil } result := w.Result() // print result.Body respBody, err := io.ReadAll(result.Body) if err != nil { - return err, nil + return "", err, nil } logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) - return nil, nil + return responseMessage, nil, nil } func TestChannel(c *gin.Context) { + ctx := c.Request.Context() id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -134,10 +182,10 @@ func TestChannel(c *gin.Context) { }) return } - model := c.Query("model") - testRequest := buildTestRequest(model) + modelName := c.Query("model") + testRequest := buildTestRequest(modelName) tik := time.Now() - err, _ = testChannel(channel, testRequest) + responseMessage, err, _ := testChannel(ctx, channel, testRequest) tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() if err != nil { @@ -147,18 +195,18 @@ func TestChannel(c *gin.Context) { consumedTime := float64(milliseconds) / 1000.0 if err != nil { c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - "time": consumedTime, - "model": model, + "success": false, + "message": err.Error(), + "time": consumedTime, + "modelName": modelName, }) return } c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "time": consumedTime, - "model": model, + "success": true, + "message": responseMessage, + "time": consumedTime, + "modelName": modelName, }) return } @@ -166,7 +214,7 @@ func TestChannel(c *gin.Context) { var testAllChannelsLock sync.Mutex var testAllChannelsRunning bool = false -func testChannels(notify bool, scope string) error { +func testChannels(ctx context.Context, notify bool, scope string) error { if config.RootUserEmail == "" { config.RootUserEmail = model.GetRootUserEmail() } @@ -190,7 +238,7 @@ func testChannels(notify bool, scope string) error { isChannelEnabled := channel.Status == model.ChannelStatusEnabled tik := time.Now() testRequest := buildTestRequest("") - err, openaiErr := testChannel(channel, testRequest) + _, err, openaiErr := testChannel(ctx, channel, testRequest) tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() if isChannelEnabled && milliseconds > disableThreshold { @@ -224,11 +272,12 @@ func testChannels(notify bool, scope string) error { } func TestChannels(c *gin.Context) { + ctx := c.Request.Context() scope := c.Query("scope") if scope == "" { scope = "all" } - err := testChannels(true, scope) + err := testChannels(ctx, true, scope) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -244,10 +293,11 @@ func TestChannels(c *gin.Context) { } func AutomaticallyTestChannels(frequency int) { + ctx := context.Background() for { time.Sleep(time.Duration(frequency) * time.Minute) logger.SysLog("testing all channels") - _ = testChannels(false, "all") + _ = testChannels(ctx, false, "all") logger.SysLog("channel test finished") } } diff --git a/controller/token.go b/controller/token.go index 19557fc7..1250949c 100644 --- a/controller/token.go +++ b/controller/token.go @@ -287,11 +287,14 @@ func ConsumeToken(c *gin.Context) { return } - model.RecordConsumeLog(c.Request.Context(), - userID, 0, 0, 0, tokenPatch.AddReason, cleanToken.Name, - int64(tokenPatch.AddUsedQuota), - fmt.Sprintf("External (%s) consumed %s", - tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota)))) + model.RecordConsumeLog(c.Request.Context(), &model.Log{ + UserId: userID, + ModelName: tokenPatch.AddReason, + TokenName: cleanToken.Name, + Quota: int(tokenPatch.AddUsedQuota), + Content: fmt.Sprintf("External (%s) consumed %s", + tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota))), + }) err = cleanToken.Update() if err != nil { diff --git a/go.mod b/go.mod index 5ce08ca4..6e7dbd40 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,5 @@ module github.com/songquanpeng/one-api -// +heroku goVersion go1.18 go 1.23 toolchain go1.23.0 @@ -93,7 +92,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index 6d01bb31..57064d15 100644 --- a/go.sum +++ b/go.sum @@ -167,8 +167,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/model/log.go b/model/log.go index 1fd7ee84..2c920652 100644 --- a/model/log.go +++ b/model/log.go @@ -13,19 +13,22 @@ import ( ) type Log struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"` - Type int `json:"type" gorm:"index:idx_created_at_type"` - Content string `json:"content"` - Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` - TokenName string `json:"token_name" gorm:"index;default:''"` - ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` - Quota int `json:"quota" gorm:"default:0"` - PromptTokens int `json:"prompt_tokens" gorm:"default:0"` - CompletionTokens int `json:"completion_tokens" gorm:"default:0"` - ChannelId int `json:"channel" gorm:"index"` - RequestId string `json:"request_id"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + ChannelId int `json:"channel" gorm:"index"` + RequestId string `json:"request_id" gorm:"default:''"` + ElapsedTime int64 `json:"elapsed_time" gorm:"default:0"` // unit is ms + IsStream bool `json:"is_stream" gorm:"default:false"` + SystemPromptReset bool `json:"system_prompt_reset" gorm:"default:false"` } const ( @@ -34,6 +37,7 @@ const ( LogTypeConsume LogTypeManage LogTypeSystem + LogTypeTest ) func recordLogHelper(ctx context.Context, log *Log) { @@ -73,23 +77,19 @@ func RecordTopupLog(ctx context.Context, userId int, content string, quota int) recordLogHelper(ctx, log) } -func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int64, content string) { +func RecordConsumeLog(ctx context.Context, log *Log) { if !config.LogConsumeEnabled { return } - log := &Log{ - UserId: userId, - Username: GetUsernameById(userId), - CreatedAt: helper.GetTimestamp(), - Type: LogTypeConsume, - Content: content, - PromptTokens: promptTokens, - CompletionTokens: completionTokens, - TokenName: tokenName, - ModelName: modelName, - Quota: int(quota), - ChannelId: channelId, - } + log.Username = GetUsernameById(log.UserId) + log.CreatedAt = helper.GetTimestamp() + log.Type = LogTypeConsume + recordLogHelper(ctx, log) +} + +func RecordTestLog(ctx context.Context, log *Log) { + log.CreatedAt = helper.GetTimestamp() + log.Type = LogTypeTest recordLogHelper(ctx, log) } diff --git a/model/user.go b/model/user.go index 31816d85..de0837c8 100644 --- a/model/user.go +++ b/model/user.go @@ -94,7 +94,7 @@ func GetUserById(id int, selectAll bool) (*User, error) { if selectAll { err = DB.First(&user, "id = ?", id).Error } else { - err = DB.Omit("password").First(&user, "id = ?", id).Error + err = DB.Omit("password", "access_token").First(&user, "id = ?", id).Error } return &user, err } diff --git a/relay/billing/billing.go b/relay/billing/billing.go index a97a3415..a90da4e2 100644 --- a/relay/billing/billing.go +++ b/relay/billing/billing.go @@ -33,7 +33,16 @@ func PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQ // totalQuota is total quota consumed if totalQuota != 0 { logContent := fmt.Sprintf("model rate %.2f, group rate %.2f", modelRatio, groupRatio) - model.RecordConsumeLog(ctx, userId, channelId, int(totalQuota), 0, modelName, tokenName, totalQuota, logContent) + model.RecordConsumeLog(ctx, &model.Log{ + UserId: userId, + ChannelId: channelId, + PromptTokens: int(totalQuota), + CompletionTokens: 0, + ModelName: modelName, + TokenName: tokenName, + Quota: int(totalQuota), + Content: logContent, + }) model.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota) model.UpdateChannelUsedQuota(channelId, totalQuota) } diff --git a/relay/controller/helper.go b/relay/controller/helper.go index 834487dc..63ac8e96 100644 --- a/relay/controller/helper.go +++ b/relay/controller/helper.go @@ -12,6 +12,7 @@ import ( "github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/ctxkey" + "github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/relay/adaptor/openai" @@ -123,12 +124,20 @@ func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.M if err != nil { logger.Error(ctx, "error update user quota cache: "+err.Error()) } - var extraLog string - if systemPromptReset { - extraLog = " (Note: System prompt has been reset)" - } - logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f%s", modelRatio, groupRatio, completionRatio, extraLog) - model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, promptTokens, completionTokens, textRequest.Model, meta.TokenName, quota, logContent) + logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f", modelRatio, groupRatio, completionRatio) + model.RecordConsumeLog(ctx, &model.Log{ + UserId: meta.UserId, + ChannelId: meta.ChannelId, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + ModelName: textRequest.Model, + TokenName: meta.TokenName, + Quota: int(quota), + Content: logContent, + IsStream: meta.IsStream, + ElapsedTime: helper.CalcElapsedTime(meta.StartTime), + SystemPromptReset: systemPromptReset, + }) model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) model.UpdateChannelUsedQuota(meta.ChannelId, quota) diff --git a/relay/controller/image.go b/relay/controller/image.go index 4987f30d..923c6383 100644 --- a/relay/controller/image.go +++ b/relay/controller/image.go @@ -227,7 +227,16 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus if quota >= 0 { tokenName := c.GetString(ctxkey.TokenName) logContent := fmt.Sprintf("model rate %.2f, group rate %.2f", modelRatio, groupRatio) - model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, 0, 0, imageRequest.Model, tokenName, quota, logContent) + model.RecordConsumeLog(ctx, &model.Log{ + UserId: meta.UserId, + ChannelId: meta.ChannelId, + PromptTokens: 0, + CompletionTokens: 0, + ModelName: imageRequest.Model, + TokenName: tokenName, + Quota: int(quota), + Content: logContent, + }) model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) channelId := c.GetInt(ctxkey.ChannelId) model.UpdateChannelUsedQuota(channelId, quota) diff --git a/relay/meta/relay_meta.go b/relay/meta/relay_meta.go index 9ec5ce9a..0eac79ad 100644 --- a/relay/meta/relay_meta.go +++ b/relay/meta/relay_meta.go @@ -2,6 +2,7 @@ package meta import ( "strings" + "time" "github.com/gin-gonic/gin" "github.com/songquanpeng/one-api/common/ctxkey" @@ -33,6 +34,7 @@ type Meta struct { PromptTokens int // only for DoResponse ChannelRatio float64 SystemPrompt string + StartTime time.Time } // GetMappedModelName returns the mapped model name and a bool indicating if the model name is mapped @@ -70,6 +72,7 @@ func GetByContext(c *gin.Context) *Meta { RequestURLPath: c.Request.URL.String(), ChannelRatio: c.GetFloat64(ctxkey.ChannelRatio), // add by Laisky SystemPrompt: c.GetString(ctxkey.SystemPrompt), + StartTime: time.Now(), } cfg, ok := c.Get(ctxkey.Config) if ok { diff --git a/web/air/src/components/LogsTable.js b/web/air/src/components/LogsTable.js index 004188c3..7d372d49 100644 --- a/web/air/src/components/LogsTable.js +++ b/web/air/src/components/LogsTable.js @@ -28,6 +28,8 @@ function renderType(type) { return 管理 ; case 4: return 系统 ; + case 5: + return 测试 ; default: return 未知 ; } diff --git a/web/berry/src/views/Log/type/LogType.js b/web/berry/src/views/Log/type/LogType.js index 4891b66b..23db2766 100644 --- a/web/berry/src/views/Log/type/LogType.js +++ b/web/berry/src/views/Log/type/LogType.js @@ -3,7 +3,8 @@ const LOG_TYPE = { 1: { value: '1', text: '充值', color: 'primary' }, 2: { value: '2', text: '消费', color: 'orange' }, 3: { value: '3', text: '管理', color: 'default' }, - 4: { value: '4', text: '系统', color: 'secondary' } + 4: { value: '4', text: '系统', color: 'secondary' }, + 5: { value: '5', text: '测试', color: 'secondary' }, }; export default LOG_TYPE; diff --git a/web/default/package.json b/web/default/package.json index ba45011f..a0bdb267 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -5,16 +5,22 @@ "dependencies": { "axios": "^0.27.2", "history": "^5.3.0", + "i18next": "23.2.3", + "i18next-browser-languagedetector": "^8.0.2", + "i18next-http-backend": "^3.0.2", "marked": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-i18next": "^13.0.0", "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", "react-toastify": "^9.0.8", "react-turnstile": "^1.0.5", + "recharts": "^2.15.1", "semantic-ui-css": "^2.5.0", - "semantic-ui-react": "^2.1.3" + "semantic-ui-react": "^2.1.3", + "typescript": "4.9.5" }, "scripts": { "start": "react-scripts start", diff --git a/web/default/public/locales/en/translation.json b/web/default/public/locales/en/translation.json new file mode 100644 index 00000000..baab810b --- /dev/null +++ b/web/default/public/locales/en/translation.json @@ -0,0 +1,156 @@ +{ + "header": { + "home": "Home", + "channel": "Channel", + "token": "Token", + "redemption": "Redemption", + "topup": "Top Up", + "user": "User", + "dashboard": "Dashboard", + "log": "Log", + "setting": "Settings", + "about": "About", + "chat": "Chat", + "login": "Login", + "logout": "Logout", + "register": "Register" + }, + "topup": { + "title": "Top Up Center", + "get_code": { + "title": "Get Redemption Code", + "current_quota": "Current Available Quota", + "button": "Get Code Now" + }, + "redeem_code": { + "title": "Redeem Code", + "placeholder": "Please enter redemption code", + "paste": "Paste", + "paste_error": "Cannot access clipboard, please paste manually", + "submit": "Redeem Now", + "submitting": "Redeeming...", + "empty_code": "Please enter the redemption code!", + "success": "Top up successful!", + "request_failed": "Request failed", + "no_link": "Admin has not set up the top-up link!" + } + }, + "channel": { + "title": "Channel Management", + "search": "Search channels by ID, name and key...", + "balance_notice": "OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.", + "test_notice": "Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.", + "detail_notice": "Click the detail button below to show balance and set additional test models.", + "table": { + "id": "ID", + "name": "Name", + "group": "Group", + "type": "Type", + "status": "Status", + "response_time": "Response Time", + "balance": "Balance", + "priority": "Priority", + "test_model": "Test Model", + "actions": "Actions", + "no_name": "None", + "status_enabled": "Enabled", + "status_disabled": "Disabled", + "status_auto_disabled": "Disabled", + "status_disabled_tip": "This channel is manually disabled", + "status_auto_disabled_tip": "This channel is automatically disabled", + "status_unknown": "Unknown Status", + "not_tested": "Not Tested", + "priority_tip": "Channel selection priority, higher is preferred", + "select_test_model": "Please select test model", + "click_to_update": "Click to update" + }, + "buttons": { + "test": "Test", + "delete": "Delete", + "confirm_delete": "Delete Channel", + "enable": "Enable", + "disable": "Disable", + "edit": "Edit", + "add": "Add New Channel", + "test_all": "Test All Channels", + "test_disabled": "Test Disabled Channels", + "delete_disabled": "Delete Disabled Channels", + "confirm_delete_disabled": "Confirm Delete", + "refresh": "Refresh", + "show_detail": "Details", + "hide_detail": "Hide Details" + }, + "messages": { + "test_success": "Channel ${name} test successful, model ${model}, time ${time}s, output: ${message}", + "test_all_started": "Channel testing started successfully, please refresh page to see results.", + "delete_disabled_success": "Deleted all disabled channels, total: ${count}", + "balance_update_success": "Channel ${name} balance updated successfully!", + "all_balance_updated": "All enabled channel balances have been updated!" + }, + "edit": { + "title_edit": "Update Channel Information", + "title_create": "Create New Channel", + "type": "Type", + "name": "Name", + "name_placeholder": "Please enter name", + "group": "Group", + "group_placeholder": "Please select groups that can use this channel", + "group_addition": "Please edit group multipliers in system settings to add new group:", + "models": "Models", + "models_placeholder": "Please select models supported by this channel", + "model_mapping": "Model Mapping", + "model_mapping_placeholder": "Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names", + "system_prompt": "System Prompt", + "system_prompt_placeholder": "Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model", + "base_url": "Proxy", + "base_url_placeholder": "Optional, used for API calls through proxy. Enter proxy address in format: https://domain.com", + "key": "Key", + "key_placeholder": "Please enter key", + "batch": "Batch Create", + "batch_placeholder": "Please enter keys, one per line", + "buttons": { + "cancel": "Cancel", + "submit": "Submit", + "fill_models": "Fill Related Models", + "fill_all": "Fill All Models", + "clear": "Clear All Models", + "add_custom": "Add", + "custom_placeholder": "Enter custom model name" + }, + "messages": { + "name_required": "Please enter channel name and key!", + "models_required": "Please select at least one model!", + "model_mapping_invalid": "Model mapping must be valid JSON format!", + "update_success": "Channel updated successfully!", + "create_success": "Channel created successfully!" + }, + "spark_version": "Model Version", + "spark_version_placeholder": "Please enter Spark model version from API URL, e.g.: v2.1", + "knowledge_id": "Knowledge Base ID", + "knowledge_id_placeholder": "Please enter knowledge base ID, e.g.: 123456", + "plugin_param": "Plugin Parameter", + "plugin_param_placeholder": "Please enter plugin parameter (X-DashScope-Plugin header value)", + "coze_notice": "For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.", + "douban_notice": "For Douban, you need to go to", + "douban_notice_link": "Model Inference Page", + "douban_notice_2": "to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.", + "aws_region_placeholder": "region, e.g.: us-west-2", + "aws_ak_placeholder": "AWS IAM Access Key", + "aws_sk_placeholder": "AWS IAM Secret Key", + "vertex_region_placeholder": "Vertex AI Region, e.g.: us-east5", + "vertex_project_id": "Vertex AI Project ID", + "vertex_project_id_placeholder": "Vertex AI Project ID", + "vertex_credentials": "Google Cloud Application Default Credentials JSON", + "vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON", + "user_id": "User ID", + "user_id_placeholder": "User ID who generated this key", + "key_prompts": { + "default": "Please enter the authentication key for this channel", + "zhipu": "Enter in format: APIKey|SecretKey", + "spark": "Enter in format: APPID|APISecret|APIKey", + "fastgpt": "Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041", + "tencent": "Enter in format: AppId|SecretId|SecretKey" + } + } + } +} diff --git a/web/default/public/locales/zh/translation.json b/web/default/public/locales/zh/translation.json new file mode 100644 index 00000000..8b53e005 --- /dev/null +++ b/web/default/public/locales/zh/translation.json @@ -0,0 +1,156 @@ +{ + "header": { + "home": "首页", + "channel": "渠道", + "token": "令牌", + "redemption": "兑换", + "topup": "充值", + "user": "用户", + "dashboard": "总览", + "log": "日志", + "setting": "设置", + "about": "关于", + "chat": "聊天", + "login": "登录", + "logout": "注销", + "register": "注册" + }, + "topup": { + "title": "充值中心", + "get_code": { + "title": "获取兑换码", + "current_quota": "当前可用额度", + "button": "立即获取兑换码" + }, + "redeem_code": { + "title": "兑换码充值", + "placeholder": "请输入兑换码", + "paste": "粘贴", + "paste_error": "无法访问剪贴板,请手动粘贴", + "submit": "立即兑换", + "submitting": "兑换中...", + "empty_code": "请输入兑换码!", + "success": "充值成功!", + "request_failed": "请求失败", + "no_link": "超级管理员未设置充值链接!" + } + }, + "channel": { + "title": "管理渠道", + "search": "搜索渠道的 ID,名称和密钥 ...", + "balance_notice": "OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。", + "test_notice": "渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo,如果该模型不可用则使用你所配置的模型列表中的第一个模型。", + "detail_notice": "点击下方详情按钮可以显示余额以及设置额外的测试模型。", + "table": { + "id": "ID", + "name": "名称", + "group": "分组", + "type": "类型", + "status": "状态", + "response_time": "响应时间", + "balance": "余额", + "priority": "优先级", + "test_model": "测试模型", + "actions": "操作", + "no_name": "无", + "status_enabled": "已启用", + "status_disabled": "已禁用", + "status_auto_disabled": "已禁用", + "status_disabled_tip": "本渠道被手动禁用", + "status_auto_disabled_tip": "本渠道被程序自动禁用", + "status_unknown": "未知状态", + "not_tested": "未测试", + "priority_tip": "渠道选择优先级,越高越优先", + "select_test_model": "请选择测试模型", + "click_to_update": "点击更新" + }, + "buttons": { + "test": "测试", + "delete": "删除", + "confirm_delete": "删除渠道", + "enable": "启用", + "disable": "禁用", + "edit": "编辑", + "add": "添加新的渠道", + "test_all": "测试所有渠道", + "test_disabled": "测试禁用渠道", + "delete_disabled": "删除禁用渠道", + "confirm_delete_disabled": "确认删除", + "refresh": "刷新", + "show_detail": "详情", + "hide_detail": "隐藏详情" + }, + "messages": { + "test_success": "渠道 ${name} 测试成功,模型 ${model},耗时 ${time} 秒,模型输出:${message}", + "test_all_started": "已成功开始测试渠道,请刷新页面查看结果。", + "delete_disabled_success": "已删除所有禁用渠道,共计 ${count} 个", + "balance_update_success": "渠道 ${name} 余额更新成功!", + "all_balance_updated": "已更新完毕所有已启用渠道余额!" + }, + "edit": { + "title_edit": "更新渠道信息", + "title_create": "创建新的渠道", + "type": "类型", + "name": "名称", + "name_placeholder": "请输入名称", + "group": "分组", + "group_placeholder": "请选择可以使用该渠道的分组", + "group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:", + "models": "模型", + "models_placeholder": "请选择该渠道所支持的模型", + "model_mapping": "模型重定向", + "model_mapping_placeholder": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称", + "system_prompt": "系统提示词", + "system_prompt_placeholder": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型", + "base_url": "代理", + "base_url_placeholder": "此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com", + "key": "密钥", + "key_placeholder": "请输入密钥", + "batch": "批量创建", + "batch_placeholder": "请输入密钥,一行一个", + "buttons": { + "cancel": "取消", + "submit": "提交", + "fill_models": "填入相关模型", + "fill_all": "填入所有模型", + "clear": "清除所有模型", + "add_custom": "填入", + "custom_placeholder": "输入自定义模型名称" + }, + "messages": { + "name_required": "请填写渠道名称和渠道密钥!", + "models_required": "请至少选择一个模型!", + "model_mapping_invalid": "模型映射必须是合法的 JSON 格式!", + "update_success": "渠道更新成功!", + "create_success": "渠道创建成功!" + }, + "spark_version": "模型版本", + "spark_version_placeholder": "请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1", + "knowledge_id": "知识库 ID", + "knowledge_id_placeholder": "请输入知识库 ID,例如:123456", + "plugin_param": "插件参数", + "plugin_param_placeholder": "请输入插件参数,即 X-DashScope-Plugin 请求头的取值", + "coze_notice": "对于 Coze 而言,模型名称即 Bot ID,你可以添加一个前缀 `bot-`,例如:`bot-123456`。", + "douban_notice": "对于豆包而言,需要手动去", + "douban_notice_link": "模型推理页面", + "douban_notice_2": "创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。", + "aws_region_placeholder": "region,例如:us-west-2", + "aws_ak_placeholder": "AWS IAM Access Key", + "aws_sk_placeholder": "AWS IAM Secret Key", + "vertex_region_placeholder": "Vertex AI Region,例如:us-east5", + "vertex_project_id": "Vertex AI Project ID", + "vertex_project_id_placeholder": "Vertex AI Project ID", + "vertex_credentials": "Google Cloud Application Default Credentials JSON", + "vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON", + "user_id": "User ID", + "user_id_placeholder": "生成该密钥的用户 ID", + "key_prompts": { + "default": "请输入渠道对应的鉴权密钥", + "zhipu": "按照如下格式输入:APIKey|SecretKey", + "spark": "按照如下格式输入:APPID|APISecret|APIKey", + "fastgpt": "按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041", + "tencent": "按照如下格式输入:AppId|SecretId|SecretKey" + } + } + } +} diff --git a/web/default/src/App.js b/web/default/src/App.js index bd1b920a..4db38c82 100644 --- a/web/default/src/App.js +++ b/web/default/src/App.js @@ -25,6 +25,7 @@ import TopUp from './pages/TopUp'; import Log from './pages/Log'; import Chat from './pages/Chat'; import LarkOAuth from './components/LarkOAuth'; +import Dashboard from './pages/Dashboard'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); @@ -261,11 +262,11 @@ function App() { - }> - - - + + }> + + + } /> } /> - - } /> + + + + } + /> + } /> ); } diff --git a/web/default/src/components/ChannelsTable.js b/web/default/src/components/ChannelsTable.js index 9986e2d5..7f667858 100644 --- a/web/default/src/components/ChannelsTable.js +++ b/web/default/src/components/ChannelsTable.js @@ -1,5 +1,16 @@ import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Dropdown, + Form, + Input, + Label, + Message, + Pagination, + Popup, + Table, +} from 'semantic-ui-react'; import { Link } from 'react-router-dom'; import { API, @@ -9,34 +20,38 @@ import { showError, showInfo, showSuccess, - timestamp2string + timestamp2string, } from '../helpers'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; import { renderGroup, renderNumber } from '../helpers/render'; function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); + return <>{timestamp2string(timestamp)}; } let type2label = undefined; -function renderType(type) { +function renderType(type, t) { if (!type2label) { - type2label = new Map; + type2label = new Map(); for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; } - type2label[0] = { value: 0, text: 'Unknown type', color: 'grey' }; + type2label[0] = { + value: 0, + text: t('channel.table.status_unknown'), + color: 'grey', + }; } - return ; + return ( + + ); } -function renderBalance(type, balance) { +function renderBalance(type, balance, t) { switch (type) { case 1: // OpenAI return ${balance.toFixed(2)}; @@ -57,17 +72,18 @@ function renderBalance(type, balance) { case 44: // SiliconFlow return ¥{balance.toFixed(2)}; default: - return Not supported; + return {t('channel.table.balance_not_supported')}; } } function isShowDetail() { - return localStorage.getItem("show_detail") === "true"; + return localStorage.getItem('show_detail') === 'true'; } -const promptID = "detail" +const promptID = 'detail'; const ChannelsTable = () => { + const { t } = useTranslation(); const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); @@ -81,33 +97,37 @@ const ChannelsTable = () => { const res = await API.get(`/api/channel/?p=${startIdx}`); const { success, message, data } = res.data; if (success) { - let localChannels = data.map((channel) => { - if (channel.models === '') { - channel.models = []; - channel.test_model = ""; - } else { - channel.models = channel.models.split(','); - if (channel.models.length > 0) { - channel.test_model = channel.models[0]; - } - channel.model_options = channel.models.map((model) => { - return { - key: model, - text: model, - value: model, - } - }) - console.log('channel', channel) - } - return channel; - }); - if (startIdx === 0) { - setChannels(localChannels); + let localChannels = data.map((channel) => { + if (channel.models === '') { + channel.models = []; + channel.test_model = ''; } else { - let newChannels = [...channels]; - newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels); - setChannels(newChannels); + channel.models = channel.models.split(','); + if (channel.models.length > 0) { + channel.test_model = channel.models[0]; + } + channel.model_options = channel.models.map((model) => { + return { + key: model, + text: model, + value: model, + }; + }); + console.log('channel', channel); } + return channel; + }); + if (startIdx === 0) { + setChannels(localChannels); + } else { + let newChannels = [...channels]; + newChannels.splice( + startIdx * ITEMS_PER_PAGE, + data.length, + ...localChannels + ); + setChannels(newChannels); + } } else { showError(message); } @@ -131,8 +151,8 @@ const ChannelsTable = () => { const toggleShowDetail = () => { setShowDetail(!showDetail); - localStorage.setItem("show_detail", (!showDetail).toString()); - } + localStorage.setItem('show_detail', (!showDetail).toString()); + }; useEffect(() => { loadChannels(0) @@ -193,52 +213,80 @@ const ChannelsTable = () => { } }; - const renderStatus = (status) => { + const renderStatus = (status, t) => { switch (status) { case 1: - return ; + return ( + + ); case 2: return ( - Disabled - } - content='This channel has been manually disabled' + trigger={ + + } + content={t('channel.table.status_disabled_tip')} basic /> ); case 3: return ( - Disabled - } - content='This channel has been automatically disabled by the program' + trigger={ + + } + content={t('channel.table.status_auto_disabled_tip')} basic /> ); default: return ( ); } }; - const renderResponseTime = (responseTime) => { + const renderResponseTime = (responseTime, t) => { let time = responseTime / 1000; time = time.toFixed(2) + 's'; if (responseTime === 0) { - return ; + return ( + + ); } else if (responseTime <= 1000) { - return ; + return ( + + ); } else if (responseTime <= 3000) { - return ; + return ( + + ); } else if (responseTime <= 5000) { - return ; + return ( + + ); } else { - return ; + return ( + + ); } }; @@ -277,7 +325,14 @@ const ChannelsTable = () => { newChannels[realIdx].response_time = time * 1000; newChannels[realIdx].test_time = Date.now() / 1000; setChannels(newChannels); - showInfo(`Channel ${name} tested successfully with model ${model}, taking ${time.toFixed(2)} seconds.`); + showInfo( + t('channel.messages.test_success', { + name: name, + model: model, + time: time.toFixed(2), + message: message, + }) + ); } else { showError(message); } @@ -292,7 +347,7 @@ const ChannelsTable = () => { const res = await API.get(`/api/channel/test?scope=${scope}`); const { success, message } = res.data; if (success) { - showInfo('Successfully started testing channels, please refresh the page to see the results.'); + showInfo(t('channel.messages.test_all_started')); } else { showError(message); } @@ -302,7 +357,9 @@ const ChannelsTable = () => { const res = await API.delete(`/api/channel/disabled`); const { success, message, data } = res.data; if (success) { - showSuccess(`Successfully deleted all disabled channels, total ${data} channels`); + showSuccess( + t('channel.messages.delete_disabled_success', { count: data }) + ); await refresh(); } else { showError(message); @@ -318,7 +375,7 @@ const ChannelsTable = () => { newChannels[realIdx].balance = balance; newChannels[realIdx].balance_updated_time = Date.now() / 1000; setChannels(newChannels); - showInfo(`Channel ${name} balance updated successfully!`); + showInfo(t('channel.messages.balance_update_success', { name: name })); } else { showError(message); } @@ -329,7 +386,7 @@ const ChannelsTable = () => { const res = await API.get(`/api/channel/update_balance`); const { success, message } = res.data; if (success) { - showInfo('The balance of all enabled channels has been updated!'); + showInfo(t('channel.messages.all_balance_updated')); } else { showError(message); } @@ -360,7 +417,6 @@ const ChannelsTable = () => { setLoading(false); }; - return ( <>
@@ -368,27 +424,27 @@ const ChannelsTable = () => { icon='search' fluid iconPosition='left' - placeholder='Search for channel ID, name and key ...' + placeholder={t('channel.search')} value={searchKeyword} loading={searching} onChange={handleKeywordChange} />
- { - showPrompt && ( - { + {showPrompt && ( + { setShowPrompt(false); setPromptShown(promptID); - }}> - OpenAI Channel no longer supports getting Balance via key, so Balance is shown as 0. For supported ChannelTypes, please click Balance to Refresh. -
- ChannelTest only supports chat Models, preferring gpt-3.5-turbo. If this Model is not available, it will use the first Model in your configured Model list. -
- Click the Details button below to display Balance and additional TestModel Settings. -
- ) - } - + }} + > + {t('channel.balance_notice')} +
+ {t('channel.test_notice')} +
+ {t('channel.detail_notice')} + + )} +
{ sortChannel('id'); }} > - ID + {t('channel.table.id')} { sortChannel('name'); }} > - Name + {t('channel.table.name')} { sortChannel('group'); }} > - Group + {t('channel.table.group')} { sortChannel('type'); }} > - Type + {t('channel.table.type')} { sortChannel('status'); }} > - Status + {t('channel.table.status')} { sortChannel('response_time'); }} > - Response time + {t('channel.table.response_time')} { }} hidden={!showDetail} > - Balance + {t('channel.table.balance')} { sortChannel('priority'); }} > - Priority + {t('channel.table.priority')} - - Operation + + {t('channel.table.actions')} @@ -472,51 +530,69 @@ const ChannelsTable = () => { return ( {channel.id} - {channel.name ? channel.name : 'None'} + + {channel.name ? channel.name : t('channel.table.no_name')} + {renderGroup(channel.group)} - {renderType(channel.type)} - {renderStatus(channel.status)} + {renderType(channel.type, t)} + {renderStatus(channel.status, t)} { - manageChannel( - channel.id, - 'priority', - idx, - event.target.value - ); - }}> - - } - content='Channel priority - higher value means higher priority' - /> - - + @@ -589,30 +662,50 @@ const ChannelsTable = () => { - - - - - {/**/} - Delete disabled channels + {t('channel.buttons.delete_disabled')} } on='click' flowing hoverable > - { (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) } /> - - + + diff --git a/web/default/src/components/Footer.js b/web/default/src/components/Footer.js index 4913e01c..b40924ef 100644 --- a/web/default/src/components/Footer.js +++ b/web/default/src/components/Footer.js @@ -29,7 +29,7 @@ const Footer = () => { return ( - + {footer ? (
{ >
) : ( diff --git a/web/default/src/components/Header.js b/web/default/src/components/Header.js index 091a4b5e..b2f5308c 100644 --- a/web/default/src/components/Header.js +++ b/web/default/src/components/Header.js @@ -1,72 +1,93 @@ import React, { useContext, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { UserContext } from '../context/User'; +import { useTranslation } from 'react-i18next'; -import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; -import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; +import { + Button, + Container, + Dropdown, + Icon, + Menu, + Segment, +} from 'semantic-ui-react'; +import { + API, + getLogo, + getSystemName, + isAdmin, + isMobile, + showSuccess, +} from '../helpers'; import '../index.css'; // Header Buttons let headerButtons = [ { - name: 'Home', + name: 'header.home', to: '/', - icon: 'home' + icon: 'home', }, { - name: 'Channel', + name: 'header.channel', to: '/channel', icon: 'sitemap', - admin: true + admin: true, }, { - name: 'API Keys', + name: 'header.token', to: '/token', - icon: 'key' + icon: 'key', }, { - name: 'Redeem', + name: 'header.redemption', to: '/redemption', icon: 'dollar sign', - admin: true + admin: true, }, { - name: 'Recharge', + name: 'header.topup', to: '/topup', - icon: 'cart' + icon: 'cart', }, { - name: 'Users', + name: 'header.user', to: '/user', icon: 'user', - admin: true + admin: true, }, { - name: 'Logs', + name: 'header.dashboard', + to: '/dashboard', + icon: 'chart bar', + }, + { + name: 'header.log', to: '/log', - icon: 'book' + icon: 'book', }, { - name: 'Settings', + name: 'header.setting', to: '/setting', - icon: 'setting' + icon: 'setting', }, { - name: 'About', + name: 'header.about', to: '/about', - icon: 'info circle' - } + icon: 'info circle', + }, ]; if (localStorage.getItem('chat_link')) { headerButtons.splice(1, 0, { - name: 'Chat', + name: 'header.chat', to: '/chat', - icon: 'comments' + icon: 'comments', }); } const Header = () => { + const { t, i18n } = useTranslation(); const [userState, userDispatch] = useContext(UserContext); let navigate = useNavigate(); @@ -93,24 +114,45 @@ const Header = () => { if (isMobile) { return ( { navigate(button.to); setShowSidebar(false); }} + style={{ fontSize: '15px' }} > - {button.name} + {t(button.name)} ); } return ( - - - {button.name} + + + {t(button.name)} ); }); }; + // Add language switcher dropdown + const languageOptions = [ + { key: 'zh', text: '中文', value: 'zh' }, + { key: 'en', text: 'English', value: 'en' }, + ]; + + const changeLanguage = (language) => { + i18n.changeLanguage(language); + }; + if (isMobile()) { return ( <> @@ -120,21 +162,17 @@ const Header = () => { style={ showSidebar ? { - borderBottom: 'none', - marginBottom: '0', - borderTop: 'none', - height: '51px' - } + borderBottom: 'none', + marginBottom: '0', + borderTop: 'none', + height: '51px', + } : { borderTop: 'none', height: '52px' } } > - logo + logo
{systemName}
@@ -150,9 +188,19 @@ const Header = () => { {renderButtons(true)} + + changeLanguage(value)} + /> + {userState.user ? ( - + ) : ( <> )} @@ -185,32 +233,75 @@ const Header = () => { return ( <> - + logo -
- {systemName} +
+ {systemName}
{renderButtons(false)} + changeLanguage(value)} + style={{ + fontSize: '15px', + fontWeight: '400', + color: '#666', + }} + /> {userState.user ? ( - Log out + + {t('header.logout')} + ) : ( )} diff --git a/web/default/src/components/LoginForm.js b/web/default/src/components/LoginForm.js index 68a19581..f6bd6733 100644 --- a/web/default/src/components/LoginForm.js +++ b/web/default/src/components/LoginForm.js @@ -1,5 +1,16 @@ import React, { useContext, useEffect, useState } from 'react'; -import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; +import { + Button, + Divider, + Form, + Grid, + Header, + Image, + Message, + Modal, + Segment, + Card, +} from 'semantic-ui-react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { UserContext } from '../context/User'; import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; @@ -10,7 +21,7 @@ const LoginForm = () => { const [inputs, setInputs] = useState({ username: '', password: '', - wechat_verification_code: '' + wechat_verification_code: '', }); const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); @@ -63,7 +74,7 @@ const LoginForm = () => { if (username && password) { const res = await API.post(`/api/user/login`, { username, - password + password, }); const { success, message, data } = res.data; if (success) { @@ -86,95 +97,149 @@ const LoginForm = () => { return ( -
- User login -
-
- - - - - - - - Forget password? - - Click to reset - - ; No account? - - Click to register - - - {status.github_oauth || status.wechat_login || status.lark_client_id ? ( - <> - Or -
- {status.github_oauth ? ( - + + + + +
+
+ Forgot password? + + Click to reset +
- ) : ( - <> - )} -
- - ) : ( - <> - )} +
+ No account? + + Click to register + +
+
+ + + {(status.github_oauth || + status.wechat_login || + status.lark_client_id) && ( + <> + + Log in with other methods + +
+ {status.github_oauth && ( +
+ + )} + + setShowWeChatLoginModal(false)} onOpen={() => setShowWeChatLoginModal(true)} @@ -198,9 +263,13 @@ const LoginForm = () => { onChange={handleChange} />
+
{ > Time - { - isAdminUser && { sortLog('channel'); @@ -255,27 +410,7 @@ const LogsTable = () => { > Channel - } - { - isAdminUser && { - sortLog('username'); - }} - width={1} - > - Users - - } - { - sortLog('token_name'); - }} - width={1} - > - API Keys - + )} { @@ -294,33 +429,57 @@ const LogsTable = () => { > Model - { - sortLog('prompt_tokens'); - }} - width={1} - > - Prompt - - { - sortLog('completion_tokens'); - }} - width={1} - > - Completion - - { - sortLog('quota'); - }} - width={1} - > - Cost - + {showUserTokenQuota() && ( + <> + {isAdminUser && ( + { + sortLog('username'); + }} + width={1} + > + User + + )} + { + sortLog('token_name'); + }} + width={1} + > + Token + + { + sortLog('prompt_tokens'); + }} + width={1} + > + Prompt + + { + sortLog('completion_tokens'); + }} + width={1} + > + Completion + + { + sortLog('quota'); + }} + width={1} + > + Quota + + + )} { @@ -343,24 +502,64 @@ const LogsTable = () => { if (log.deleted) return <>; return ( - {renderTimestamp(log.created_at)} - { - isAdminUser && ( - {log.channel ? : ''} - ) - } - { - isAdminUser && ( - {log.username ? : ''} - ) - } - {log.token_name ? : ''} + + {renderTimestamp(log.created_at, log.request_id)} + + {isAdminUser && ( + + {log.channel ? ( + + ) : ( + '' + )} + + )} {renderType(log.type)} - {log.model_name ? : ''} - {log.prompt_tokens ? log.prompt_tokens : ''} - {log.completion_tokens ? log.completion_tokens : ''} - {log.quota ? renderQuota(log.quota, 6) : 'free'} - {log.content} + + {log.model_name ? renderColorLabel(log.model_name) : ''} + + {showUserTokenQuota() && ( + <> + {isAdminUser && ( + + {log.username ? ( + + ) : ( + '' + )} + + )} + + {log.token_name + ? renderColorLabel(log.token_name) + : ''} + + + + {log.prompt_tokens ? log.prompt_tokens : ''} + + + {log.completion_tokens ? log.completion_tokens : ''} + + + {log.quota ? renderQuota(log.quota, 6) : ''} + + + )} + + {renderDetail(log)} ); })} @@ -379,7 +578,9 @@ const LogsTable = () => { setLogType(value); }} /> - + {
- + ); }; diff --git a/web/default/src/components/PasswordResetConfirm.js b/web/default/src/components/PasswordResetConfirm.js index 76889f63..04ef6aac 100644 --- a/web/default/src/components/PasswordResetConfirm.js +++ b/web/default/src/components/PasswordResetConfirm.js @@ -1,6 +1,21 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; +import { + Button, + Form, + Grid, + Header, + Image, + Card, + Message, +} from 'semantic-ui-react'; +import { + API, + copy, + showError, + showInfo, + showNotice, + showSuccess, +} from '../helpers'; import { useSearchParams } from 'react-router-dom'; const PasswordResetConfirm = () => { @@ -37,7 +52,7 @@ const PasswordResetConfirm = () => { setDisableButton(false); setCountdown(30); } - return () => clearInterval(countdownInterval); + return () => clearInterval(countdownInterval); }, [disableButton, countdown]); async function handleSubmit(e) { @@ -59,55 +74,86 @@ const PasswordResetConfirm = () => { } setLoading(false); } - + return ( -
- Password reset confirmation -
-
- - - {newPassword && ( + + + +
+ + Password Reset Confirmation +
+
+ { - e.target.select(); - navigator.clipboard.writeText(newPassword); - showNotice(`Password has been copied to the clipboard:${newPassword}`); - }} - /> + fluid + icon='mail' + iconPosition='left' + placeholder='Email Address' + name='email' + value={email} + readOnly + style={{ marginBottom: '1em' }} + /> + {newPassword && ( + { + e.target.select(); + navigator.clipboard.writeText(newPassword); + showNotice(`Password has been copied to the clipboard: ${newPassword}`); + }} + /> + )} + + + {newPassword && ( + +

+ A new password has been generated. Please click the password box or the button above to copy it. Please log in and change your password promptly! +

+
)} - -
- + +
- ); + ); }; export default PasswordResetConfirm; diff --git a/web/default/src/components/PasswordResetForm.js b/web/default/src/components/PasswordResetForm.js index b03209f2..cbf82875 100644 --- a/web/default/src/components/PasswordResetForm.js +++ b/web/default/src/components/PasswordResetForm.js @@ -1,11 +1,19 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; +import { + Button, + Form, + Grid, + Header, + Image, + Card, + Message, +} from 'semantic-ui-react'; import { API, showError, showInfo, showSuccess } from '../helpers'; import Turnstile from 'react-turnstile'; const PasswordResetForm = () => { const [inputs, setInputs] = useState({ - email: '' + email: '', }); const { email } = inputs; @@ -42,7 +50,7 @@ const PasswordResetForm = () => { function handleChange(e) { const { name, value } = e.target; - setInputs(inputs => ({ ...inputs, [name]: value })); + setInputs((inputs) => ({ ...inputs, [name]: value })); } async function handleSubmit(e) { @@ -69,42 +77,72 @@ const PasswordResetForm = () => { return ( -
- Password reset -
-
- - - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} + + + +
+ + Password Reset +
+
+ + - ) : ( - <> - )} - -
-
+ {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} + + + +

+ The system will send an email with a reset link to your email address. Please check your inbox. +

+
+ +
); diff --git a/web/default/src/components/RedemptionsTable.js b/web/default/src/components/RedemptionsTable.js index 297ba73f..44282d97 100644 --- a/web/default/src/components/RedemptionsTable.js +++ b/web/default/src/components/RedemptionsTable.js @@ -1,29 +1,56 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; +import { + Button, + Form, + Label, + Popup, + Pagination, + Table, +} from 'semantic-ui-react'; import { Link } from 'react-router-dom'; -import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; +import { + API, + copy, + showError, + showInfo, + showSuccess, + showWarning, + timestamp2string, +} from '../helpers'; import { ITEMS_PER_PAGE } from '../constants'; import { renderQuota } from '../helpers/render'; function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); + return <>{timestamp2string(timestamp)}; } function renderStatus(status) { switch (status) { case 1: - return ; + return ( + + ); case 2: - return ; + return ( + + ); case 3: - return ; + return ( + + ); default: - return ; + return ( + + ); } } @@ -110,7 +137,9 @@ const RedemptionsTable = () => { return; } setSearching(true); - const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`); + const res = await API.get( + `/api/redemption/search?keyword=${searchKeyword}` + ); const { success, message, data } = res.data; if (success) { setRedemptions(data); @@ -159,7 +188,7 @@ const RedemptionsTable = () => { /> - +
{ ) .map((redemption, idx) => { if (redemption.deleted) return <>; - return ( + return ( {redemption.id} - {redemption.name ? redemption.name : 'None'} + + {redemption.name ? redemption.name : 'None'} + {renderStatus(redemption.status)} {renderQuota(redemption.quota)} - {renderTimestamp(redemption.created_time)} - {redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "Not yet redeemed"} -
- + + Delete - - Delete - - } - on='click' - flowing - hoverable - > - - - - -
+ } + on='click' + flowing + hoverable + > + + + + +
- ); + ); })} - { password: '', password2: '', email: '', - verification_code: '' + verification_code: '', }); const { username, password, password2 } = inputs; const [showEmailVerification, setShowEmailVerification] = useState(false); @@ -100,92 +110,134 @@ const RegisterForm = () => { return ( -
- New User Registration -
-
- - - - - {showEmailVerification ? ( - <> - - Get verification code - - } - /> - - - ) : ( - <> - )} - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} + + + +
+ + New User Registration +
+
+ + - ) : ( - <> - )} - -
- - - Already have an account? - - Click to log in - - + + + + {showEmailVerification && ( + <> + + Get Verification Code + + } + style={{ marginBottom: '1em' }} + /> + + + )} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} + + + + + + +
+ Already have an account? + + Click to login + +
+
+ +
); diff --git a/web/default/src/components/TokensTable.js b/web/default/src/components/TokensTable.js index e1fb79dc..8b2d6ded 100644 --- a/web/default/src/components/TokensTable.js +++ b/web/default/src/components/TokensTable.js @@ -1,7 +1,22 @@ import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; +import { + Button, + Dropdown, + Form, + Label, + Pagination, + Popup, + Table, +} from 'semantic-ui-react'; import { Link } from 'react-router-dom'; -import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; +import { + API, + copy, + showError, + showSuccess, + showWarning, + timestamp2string, +} from '../helpers'; import { ITEMS_PER_PAGE } from '../constants'; import { renderQuota } from '../helpers/render'; @@ -20,25 +35,41 @@ const OPEN_LINK_OPTIONS = [ ]; function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); + return <>{timestamp2string(timestamp)}; } function renderStatus(status) { switch (status) { case 1: - return ; + return ( + + ); case 2: - return ; + return ( + + ); case 3: - return ; + return ( + + ); case 4: - return ; + return ( + + ); default: - return ; + return ( + + ); } } @@ -95,14 +126,15 @@ const TokensTable = () => { serverAddress = window.location.origin; } let encodedServerAddress = encodeURIComponent(serverAddress); - // const nextLink = localStorage.getItem('chat_link'); - // let nextUrl; + const nextLink = localStorage.getItem('chat_link'); + let nextUrl; - // if (nextLink) { - // nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - // } else { - // nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; - // } + if (nextLink) { + nextUrl = + nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } else { + nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } let url; switch (type) { @@ -116,7 +148,9 @@ const TokensTable = () => { url = `https://chat.laisky.com?apikey=sk-${key}`; break; case 'lobechat': - url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; + url = + nextLink + + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; break; default: url = `sk-${key}`; @@ -144,7 +178,8 @@ const TokensTable = () => { let defaultUrl; if (chatLink) { - defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + defaultUrl = + chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; } else { defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; } @@ -159,7 +194,9 @@ const TokensTable = () => { break; case 'lobechat': - url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; + url = + chatLink + + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; break; default: @@ -167,7 +204,7 @@ const TokensTable = () => { } window.open(url, '_blank'); - } + }; useEffect(() => { loadTokens(0, orderBy) @@ -273,7 +310,7 @@ const TokensTable = () => { /> -
+
{ ) .map((token, idx) => { if (token.deleted) return <>; - return ( + return ( {token.name ? token.name : 'None'} {renderStatus(token.status)} {renderQuota(token.used_quota)} - {token.unlimited_quota ? 'Unlimited' : renderQuota(token.remain_quota, 2)} - {renderTimestamp(token.created_time)} - {token.expired_time === -1 ? 'Never expires' : renderTimestamp(token.expired_time)} -
+ {token.unlimited_quota + ? 'Unlimited' + : renderQuota(token.remain_quota, 2)} + + {renderTimestamp(token.created_time)} + + {token.expired_time === -1 + ? 'Never Expires' + : renderTimestamp(token.expired_time)} + + +
- - ({ - ...option, - onClick: async () => { - await onCopy(option.value, token.key); - } - }))} - trigger={<>} - /> - - {' '} - - Delete - - } - on='click' - flowing - hoverable - > - - - + ({ + ...option, + onClick: async () => { + await onCopy(option.value, token.key); + }, + }))} + trigger={<>} + /> + {' '} + + + ({ + ...option, + onClick: async () => { + await onOpenLink(option.value, token.key); + }, + }))} + trigger={<>} + /> + {' '} + + Delete - -
+ } + on='click' + flowing + hoverable + > + + + + +
- ); + ); })} @@ -420,14 +486,24 @@ const TokensTable = () => { - + { (async () => { const res = await API.post('/api/user/manage', { username, - action + action, }); const { success, message } = res.data; if (success) { @@ -169,7 +182,7 @@ const UsersTable = () => { /> -
+
{ {renderText(user.username, 15)}} hoverable /> @@ -249,9 +264,22 @@ const UsersTable = () => { {/* {user.email ? {renderText(user.email, 24)}} /> : 'None'}*/} {/**/} - {renderQuota(user.quota)}} /> - {renderQuota(user.used_quota)}} /> - {renderNumber(user.request_count)}} /> + {renderQuota(user.quota)}} + /> + {renderQuota(user.used_quota)} + } + /> + {renderNumber(user.request_count)} + } + /> {renderRole(user.role)} {renderStatus(user.status)} @@ -279,7 +307,11 @@ const UsersTable = () => { + } @@ -335,8 +367,16 @@ const UsersTable = () => { options={[ { key: '', text: 'Default Order', value: '' }, { key: 'quota', text: 'Sort by Remaining Quota', value: 'quota' }, - { key: 'used_quota', text: 'Sort by Used Quota', value: 'used_quota' }, - { key: 'request_count', text: 'Sort by Number of Requests', value: 'request_count' }, + { + key: 'used_quota', + text: 'Sort by Used Quota', + value: 'used_quota', + }, + { + key: 'request_count', + text: 'Sort by Request Count', + value: 'request_count', + }, ]} value={orderBy} onChange={handleOrderByChange} diff --git a/web/default/src/helpers/render.js b/web/default/src/helpers/render.js index f9d03cb6..707c37a8 100644 --- a/web/default/src/helpers/render.js +++ b/web/default/src/helpers/render.js @@ -13,16 +13,18 @@ export function renderGroup(group) { } let groups = group.split(','); groups.sort(); - return <> - {groups.map((group) => { - if (group === 'vip' || group === 'pro') { - return ; - } else if (group === 'svip' || group === 'premium') { - return ; - } - return ; - })} - ; + return ( + <> + {groups.map((group) => { + if (group === 'vip' || group === 'pro') { + return ; + } else if (group === 'svip' || group === 'premium') { + return ; + } + return ; + })} + + ); } export function renderNumber(num) { @@ -56,3 +58,32 @@ export function renderQuotaWithPrompt(quota, digits) { } return ''; } + +const colors = [ + 'red', + 'orange', + 'yellow', + 'olive', + 'green', + 'teal', + 'blue', + 'violet', + 'purple', + 'pink', + 'brown', + 'grey', + 'black', +]; + +export function renderColorLabel(text) { + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + } + let index = Math.abs(hash % colors.length); + return ( + + ); +} diff --git a/web/default/src/i18n.js b/web/default/src/i18n.js new file mode 100644 index 00000000..639c7e94 --- /dev/null +++ b/web/default/src/i18n.js @@ -0,0 +1,23 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'zh', + debug: process.env.NODE_ENV === 'development', + + interpolation: { + escapeValue: false, + }, + + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + }); + +export default i18n; diff --git a/web/default/src/index.js b/web/default/src/index.js index eca5c3c0..688d3a6d 100644 --- a/web/default/src/index.js +++ b/web/default/src/index.js @@ -11,6 +11,7 @@ import { UserProvider } from './context/User'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { StatusProvider } from './context/Status'; +import './i18n'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/web/default/src/pages/About/index.js b/web/default/src/pages/About/index.js index 54d88ff5..10b271b8 100644 --- a/web/default/src/pages/About/index.js +++ b/web/default/src/pages/About/index.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Header, Segment } from 'semantic-ui-react'; +import { Card, Header, Segment } from 'semantic-ui-react'; import { API, showError } from '../../helpers'; import { marked } from 'marked'; @@ -28,31 +28,38 @@ const About = () => { useEffect(() => { displayAbout().then(); }, []); - return ( <> - { - aboutLoaded && about === '' ? <> - -
About
-

You can set the content about in the settings page, support HTML & Markdown

- Project Repository Address: - - https://github.com/Laisky/one-api - -
- : <> - { - about.startsWith('https://') ?