Compare commits

...

58 Commits
main ... 0.5.0

Author SHA1 Message Date
ckt1031
f5f21dffd8 fix: remove printing invalid stream response 2023-07-15 21:51:28 +08:00
ckt1031
4e94c85a9a feat: move to vite for faster builld 2023-07-15 21:41:23 +08:00
ckt1031
caabdd1e21 fix: run prettier 2023-07-15 21:14:40 +08:00
ckt1031
0424baef6a fix: merge 2 2023-07-15 21:13:26 +08:00
ckt1031
256d290507 fix: merge latest change from remote 2023-07-15 21:12:55 +08:00
ckt1031
8f0799d909 feat: support reverse proxy of Chanzhaoyu/chatgpt-web 2023-07-15 21:03:27 +08:00
ckt1031
349e3a3661 feat: add default models for token creation 2023-07-15 11:47:09 +08:00
ckt1031
8cc7f983e1 fix: model creation issue 2023-07-14 23:53:23 +08:00
ckt1031
455643e317 fix: model token creation issue 2023-07-14 23:29:11 +08:00
ckt1031
1c7bad7b87 fix: token model list 2023-07-14 23:07:22 +08:00
ckt1031
3141292026 fix: i18n 2023-07-14 22:42:27 +08:00
ckt1031
e4500bf8bf featL add token-side model selection 2023-07-14 22:41:22 +08:00
ckt1031
4043fccedb feat: support ip randomize in http header 2023-07-14 21:30:13 +08:00
ckt1031
164df4e708 fix: resp body when error 2023-07-14 20:21:25 +08:00
ckt1031
d850f465cd Merge remote-tracking branch 'upstream/main' 2023-07-13 22:27:29 +08:00
ckt1031
e2f5c1eb8c fix: channel testing for reverse proxy 2023-07-13 22:07:07 +08:00
ckt1031
d68aa4c96f fix: removing maxtokens 2023-07-13 21:28:14 +08:00
ckt1031
47cb77de53 fix: better text phrasing 2023-07-13 20:49:57 +08:00
ckt1031
61912f5e2c fix: patch testing 2023-07-13 19:40:36 +08:00
ckt1031
379d03798c fix: add user edit discord 2023-07-12 21:14:30 +08:00
ckt1031
520eb34b72 fix: json i18n 2023-07-12 18:05:25 +08:00
ckt1031
855bb82ae7 feat: improve i18n 2023-07-12 17:58:09 +08:00
ckt1031
8c91bd9c97 feat: enforce streaming in channel testing 2023-07-12 17:43:43 +08:00
ckt1031
7c7a45a4f5 feat: support account deletion 2023-07-12 15:57:40 +08:00
ckt1031
0ac0214c41 fix: billing date json issue 2023-07-12 15:15:07 +08:00
ckt1031
b63400ebe2 feat: add Discord Oauth2 support (1) 2023-07-12 15:11:02 +08:00
ckt1031
b17d9bc649 fix: add stream body if not exist 2023-07-11 23:05:01 +08:00
ckt1031
9ef8167e5d feat: strict testing 2023-07-11 23:01:36 +08:00
ckt1031
3baad1d926 chore: update readme 2023-07-11 17:51:41 +08:00
ckt1031
80d5d6edfb feat: support return date for billing 2023-07-11 17:40:52 +08:00
ckt1031
12365ccf69 feat: optimized channel testing (1) 2023-07-11 17:11:55 +08:00
ckt1031
4928319494 fix: docekrfile 2023-07-11 17:06:03 +08:00
ckt1031
839dcc3ab2 feat: better dockerfile 2023-07-11 17:03:22 +08:00
ckt1031
270e366cd9 fix: add @babel/plugin-proposal-private-property-in-object 2023-07-11 16:59:44 +08:00
ckt1031
67b8e82457 fix: billing status code check 2023-07-10 23:15:30 +08:00
ckt1031
4b2cb573b6 fix: testing channel reject if not 200 2023-07-10 23:09:15 +08:00
ckt1031
bde43cc358 feat: support dotenv 2023-07-10 23:05:21 +08:00
ckt
6e06dcfcf8
Update english.dockerfile 2023-07-10 20:27:42 +08:00
ckt
b617599211
Update english.dockerfile 2023-07-10 20:24:46 +08:00
ckt
1656b9a1de
Update english.dockerfile 2023-07-10 20:21:30 +08:00
ckt
eb5f8f2d75
Create english.dockerfile 2023-07-10 20:17:32 +08:00
ckt1031
2ae5741214 fix: move back to react-scripts 2023-07-09 21:18:22 +08:00
ckt1031
28d58849a0 fix: environmental issue 2023-07-09 21:12:56 +08:00
ckt1031
adc9679d56 feat: optimized env for docker 2023-07-09 20:43:18 +08:00
ckt1031
07589ae305 fix: bump dependencies 2023-07-09 20:37:51 +08:00
ckt1031
95bc32c555 Merge branch 'dall-e-image-creation' 2023-07-09 20:28:57 +08:00
ckt1031
d61dc4a9ca feat: initial support of Dall-E 2023-07-09 19:15:15 +08:00
ckt1031
b29acb0c89 chore: use personal docker 2023-07-09 17:26:26 +08:00
ckt1031
a8e418275d fix: about word 2023-07-09 17:16:26 +08:00
ckt1031
d7ab9b0935 fix: turnstil should show in login form 2023-07-09 17:12:31 +08:00
ckt1031
ebd62c3bfc fix: vite process 2023-07-09 17:01:14 +08:00
ckt1031
4e6f9f67b3 feat: better chn translation 2023-07-09 16:43:39 +08:00
ckt1031
77d295bbf5 fix: removed local update notice 2023-07-08 16:43:57 +08:00
ckt1031
7e3e25fbd9 Fix faulty outdir 2023-07-08 07:56:31 +00:00
ckt1031
9bf98ab53a Fix vite react variable issue 2023-07-08 07:52:37 +00:00
ckt
3ed9a219c7
Update package.json 2023-07-08 15:08:46 +08:00
ckt1031
37d7afcedc feat: use vite 2023-07-08 14:58:42 +08:00
ckt1031
2756554f7c fix: channel testing issue 2023-07-08 14:24:51 +08:00
100 changed files with 4655 additions and 932 deletions

View File

@ -38,7 +38,7 @@ jobs:
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
justsong/one-api-en ckt1031/one-api-en
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3

View File

@ -42,7 +42,7 @@ jobs:
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
justsong/one-api ckt1031/one-api
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker images - name: Build and push Docker images

View File

@ -49,7 +49,7 @@ jobs:
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
justsong/one-api ckt1031/one-api
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker images - name: Build and push Docker images

View File

@ -24,7 +24,7 @@ jobs:
run: | run: |
cd web cd web
npm install npm install
REACT_APP_VERSION=$(git describe --tags) npm run build VITE_REACT_APP_VERSION=$(git describe --tags) npm run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3

View File

@ -24,7 +24,7 @@ jobs:
run: | run: |
cd web cd web
npm install npm install
REACT_APP_VERSION=$(git describe --tags) npm run build VITE_REACT_APP_VERSION=$(git describe --tags) npm run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3

View File

@ -27,7 +27,7 @@ jobs:
run: | run: |
cd web cd web
npm install npm install
REACT_APP_VERSION=$(git describe --tags) npm run build VITE_REACT_APP_VERSION=$(git describe --tags) npm run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3

3
.gitignore vendored
View File

@ -4,4 +4,5 @@ upload
*.exe *.exe
*.db *.db
build build
*.db-journal *.db-journal
.env*

View File

@ -1,31 +1,29 @@
FROM node:16 as builder # Node build stage
FROM node:18 as builder
WORKDIR /build WORKDIR /build
COPY ./web/package*.json ./
RUN npm ci
COPY ./web . COPY ./web .
COPY ./VERSION . COPY ./VERSION .
RUN npm install RUN VITE_REACT_APP_VERSION=$(cat VERSION) npm run build
RUN REACT_APP_VERSION=$(cat VERSION) npm run build
# Go build stage
FROM golang AS builder2 FROM golang AS builder2
ENV GO111MODULE=on \ ENV GO111MODULE=on \
CGO_ENABLED=1 \ CGO_ENABLED=1 \
GOOS=linux GOOS=linux
WORKDIR /build WORKDIR /build
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . . COPY . .
COPY --from=builder /build/build ./web/build COPY --from=builder /build/build ./web/build
RUN go mod download
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api
# Final stage
FROM alpine FROM alpine
RUN apk update && apk upgrade && apk add --no-cache ca-certificates tzdata && update-ca-certificates 2>/dev/null || true
RUN apk update \ WORKDIR /data
&& apk upgrade \
&& apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
COPY --from=builder2 /build/one-api / COPY --from=builder2 /build/one-api /
EXPOSE 3000 EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/one-api"] ENTRYPOINT ["/one-api"]

View File

@ -81,16 +81,19 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
12. 支持以美元为单位显示额度。 12. 支持以美元为单位显示额度。
13. 支持发布公告,设置充值链接,设置新用户初始额度。 13. 支持发布公告,设置充值链接,设置新用户初始额度。
14. 支持模型映射,重定向用户的请求模型。 14. 支持模型映射,重定向用户的请求模型。
15. 支持丰富的**自定义**设置, 15. 支持失败自动重试。
16. 支持绘图接口。
17. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚。 1. 支持自定义系统名称logo 以及页脚。
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
16. 支持通过系统访问令牌访问管理 API。 18. 支持通过系统访问令牌访问管理 API。
17. 支持 Cloudflare Turnstile 用户校验。 19. 支持 Cloudflare Turnstile 用户校验。
18. 支持用户管理,支持**多种用户登录注册方式** 20. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。 + 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。 + [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
19. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 21. 支持 [ChatGLM](https://github.com/THUDM/ChatGLM2-6B)。
22. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
## 部署 ## 部署
### 基于 Docker 进行部署 ### 基于 Docker 进行部署

View File

@ -38,6 +38,7 @@ var PasswordLoginEnabled = true
var PasswordRegisterEnabled = true var PasswordRegisterEnabled = true
var EmailVerificationEnabled = false var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false var GitHubOAuthEnabled = false
var DiscordOAuthEnabled = false
var WeChatAuthEnabled = false var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false var TurnstileCheckEnabled = false
var RegisterEnabled = true var RegisterEnabled = true
@ -53,6 +54,9 @@ var SMTPToken = ""
var GitHubClientId = "" var GitHubClientId = ""
var GitHubClientSecret = "" var GitHubClientSecret = ""
var DiscordClientId = ""
var DiscordClientSecret = ""
var WeChatServerAddress = "" var WeChatServerAddress = ""
var WeChatServerToken = "" var WeChatServerToken = ""
var WeChatAccountQRCodeImageURL = "" var WeChatAccountQRCodeImageURL = ""
@ -68,6 +72,7 @@ var AutomaticDisableChannelEnabled = false
var QuotaRemindThreshold = 1000 var QuotaRemindThreshold = 1000
var PreConsumedQuota = 500 var PreConsumedQuota = 500
var ApproximateTokenEnabled = false var ApproximateTokenEnabled = false
var RetryTimes = 0
var RootUserEmail = "" var RootUserEmail = ""
@ -150,6 +155,9 @@ const (
ChannelTypePaLM = 11 ChannelTypePaLM = 11
ChannelTypeAPI2GPT = 12 ChannelTypeAPI2GPT = 12
ChannelTypeAIGC2D = 13 ChannelTypeAIGC2D = 13
// Reserve engineering for public projects
ChannelTypeChatGPTWeb = 14 // Chanzhaoyu/chatgpt-web
) )
var ChannelBaseURLs = []string{ var ChannelBaseURLs = []string{
@ -167,4 +175,7 @@ var ChannelBaseURLs = []string{
"", // 11 "", // 11
"https://api.api2gpt.com", // 12 "https://api.api2gpt.com", // 12
"https://api.aigc2d.com", // 13 "https://api.aigc2d.com", // 13
// Reserve engineering for public projects
"", // 14 // Chanzhaoyu/chatgpt-web
} }

16
common/ip-gen.go Normal file
View File

@ -0,0 +1,16 @@
package common
import (
"fmt"
"math/rand"
)
func GenerateIP() string {
// Generate a random number between 20 and 240
segment2 := rand.Intn(221) + 20
segment3 := rand.Intn(256)
segment4 := rand.Intn(256)
ipAddress := fmt.Sprintf("104.%d.%d.%d", segment2, segment3, segment4)
return ipAddress
}

View File

@ -35,6 +35,7 @@ var ModelRatio = map[string]float64{
"text-search-ada-doc-001": 10, "text-search-ada-doc-001": 10,
"text-moderation-stable": 0.1, "text-moderation-stable": 0.1,
"text-moderation-latest": 0.1, "text-moderation-latest": 0.1,
"dall-e": 8,
} }
func ModelRatio2JSONString() string { func ModelRatio2JSONString() string {

View File

@ -1,18 +1,23 @@
package controller package controller
import ( import (
"github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"github.com/gin-gonic/gin"
) )
func GetSubscription(c *gin.Context) { func GetSubscription(c *gin.Context) {
var quota int var quota int
var err error var err error
var token *model.Token var expirationDate int64
tokenId := c.GetInt("token_id")
token, err := model.GetTokenById(tokenId)
expirationDate = token.ExpiredTime
if common.DisplayTokenStatEnabled { if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
quota = token.RemainQuota quota = token.RemainQuota
} else { } else {
userId := c.GetInt("id") userId := c.GetInt("id")
@ -41,6 +46,7 @@ func GetSubscription(c *gin.Context) {
SoftLimitUSD: amount, SoftLimitUSD: amount,
HardLimitUSD: amount, HardLimitUSD: amount,
SystemHardLimitUSD: amount, SystemHardLimitUSD: amount,
AccessUntil: expirationDate,
} }
c.JSON(200, subscription) c.JSON(200, subscription)
return return

View File

@ -22,6 +22,7 @@ type OpenAISubscriptionResponse struct {
SoftLimitUSD float64 `json:"soft_limit_usd"` SoftLimitUSD float64 `json:"soft_limit_usd"`
HardLimitUSD float64 `json:"hard_limit_usd"` HardLimitUSD float64 `json:"hard_limit_usd"`
SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` SystemHardLimitUSD float64 `json:"system_hard_limit_usd"`
AccessUntil int64 `json:"access_until"`
} }
type OpenAIUsageDailyCost struct { type OpenAIUsageDailyCost struct {
@ -96,6 +97,9 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
if err != nil { if err != nil {
return nil, err return nil, err
} }
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -1,17 +1,21 @@
package controller package controller
import ( import (
"bufio"
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "log"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin"
) )
func testChannel(channel *model.Channel, request ChatRequest) error { func testChannel(channel *model.Channel, request ChatRequest) error {
@ -24,6 +28,11 @@ func testChannel(channel *model.Channel, request ChatRequest) error {
requestURL := common.ChannelBaseURLs[channel.Type] requestURL := common.ChannelBaseURLs[channel.Type]
if channel.Type == common.ChannelTypeAzure { if channel.Type == common.ChannelTypeAzure {
requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.BaseURL, request.Model) requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.BaseURL, request.Model)
} else if channel.Type == common.ChannelTypeChatGPTWeb {
if channel.BaseURL != "" {
requestURL = channel.BaseURL
}
requestURL += "/api/chat-process"
} else { } else {
if channel.BaseURL != "" { if channel.BaseURL != "" {
requestURL = channel.BaseURL requestURL = channel.BaseURL
@ -32,6 +41,41 @@ func testChannel(channel *model.Channel, request ChatRequest) error {
} }
jsonData, err := json.Marshal(request) jsonData, err := json.Marshal(request)
if channel.Type == common.ChannelTypeChatGPTWeb {
// Get system message from Message json, Role == "system"
var systemMessage Message
for _, message := range request.Messages {
if message.Role == "system" {
systemMessage = message
break
}
}
var prompt string
// Get all the Message, Roles from request.Messages, and format it into string by
// ||> role: content
for _, message := range request.Messages {
// Exclude system message
if message.Role == "system" {
continue
}
prompt += "||> " + message.Role + ": " + message.Content + "\n"
}
// Construct json data without adding escape character
map1 := map[string]string{
"prompt": prompt,
"systemMessage": systemMessage.Content,
"temperature": strconv.FormatFloat(request.Temperature, 'f', 2, 64),
"top_p": strconv.FormatFloat(request.TopP, 'f', 2, 64),
}
// Convert map to json string
jsonData, err = json.Marshal(map1)
}
if err != nil { if err != nil {
return err return err
} }
@ -45,31 +89,153 @@ func testChannel(channel *model.Channel, request ChatRequest) error {
req.Header.Set("Authorization", "Bearer "+channel.Key) req.Header.Set("Authorization", "Bearer "+channel.Key)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if channel.EnableIpRandomization {
// Generate random IP
ip := common.GenerateIP()
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("X-Real-IP", ip)
req.Header.Set("X-Client-IP", ip)
req.Header.Set("X-Forwarded-Host", ip)
req.Header.Set("X-Originating-IP", ip)
req.RemoteAddr = ip
req.Header.Set("X-Remote-IP", ip)
req.Header.Set("X-Remote-Addr", ip)
}
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
} }
if resp.StatusCode != http.StatusOK {
// Print the body in string
if resp.Body != nil {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
return errors.New("error response: " + strconv.Itoa(resp.StatusCode) + " " + buf.String())
}
return errors.New("error response: " + strconv.Itoa(resp.StatusCode))
}
var done = false
var streamResponseText = ""
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n\n"); i >= 0 {
return i + 2, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // must be something wrong!
common.SysError("invalid stream response: " + data)
continue
}
if channel.Type != common.ChannelTypeChatGPTWeb {
// If data has event: event content inside, remove it, it can be prefix or inside the data
if strings.HasPrefix(data, "event:") || strings.Contains(data, "event:") {
// Remove event: event in the front or back
data = strings.TrimPrefix(data, "event: event")
data = strings.TrimSuffix(data, "event: event")
// Remove everything, only keep `data: {...}` <--- this is the json
// Find the start and end indices of `data: {...}` substring
startIndex := strings.Index(data, "data:")
endIndex := strings.LastIndex(data, "}")
// If both indices are found and end index is greater than start index
if startIndex != -1 && endIndex != -1 && endIndex > startIndex {
// Extract the `data: {...}` substring
data = data[startIndex : endIndex+1]
}
// Trim whitespace and newlines from the modified data string
data = strings.TrimSpace(data)
}
if !strings.HasPrefix(data, "data:") {
continue
}
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
var streamResponse ChatCompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
// Prinnt the body in string
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
common.SysError("error unmarshalling stream response: " + err.Error() + " " + buf.String())
return err
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content
}
} else {
done = true
break
}
} else if channel.Type == common.ChannelTypeChatGPTWeb {
// data may contain multiple json objects, so we need to split them
// they are "{....}{....}{....}" or "{....}\n{....}\n{....}" or "{....}"
// remove all spaces and newlines outside of json objects
jsonObjs := strings.Split(data, "\n") // Split the data into multiple JSON objects
for _, jsonObj := range jsonObjs {
if jsonObj == "" {
continue
}
var chatResponse ChatGptWebChatResponse
err = json.Unmarshal([]byte(jsonObj), &chatResponse)
if err != nil {
// Print the body in string
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
common.SysError("error unmarshalling chat response: " + err.Error() + " " + buf.String())
return err
}
// if response role is assistant and contains delta, append the content to streamResponseText
if chatResponse.Role == "assistant" && chatResponse.Detail != nil {
for _, choice := range chatResponse.Detail.Choices {
log.Print(choice.Delta.Content)
streamResponseText += choice.Delta.Content
}
}
}
}
}
defer resp.Body.Close() defer resp.Body.Close()
var response TextResponse
err = json.NewDecoder(resp.Body).Decode(&response) // Check if streaming is complete and streamResponseText is populated
if err != nil { if streamResponseText == "" || !done && channel.Type != common.ChannelTypeChatGPTWeb {
return err return errors.New("Streaming not complete")
}
if response.Usage.CompletionTokens == 0 {
return errors.New(fmt.Sprintf("type %s, code %v, message %s", response.Error.Type, response.Error.Code, response.Error.Message))
} }
return nil return nil
} }
func buildTestRequest() *ChatRequest { func buildTestRequest() *ChatRequest {
testRequest := &ChatRequest{ testRequest := &ChatRequest{
Model: "", // this will be set later Model: "", // this will be set later
MaxTokens: 1, Stream: true,
} }
testMessage := Message{ testMessage := Message{
Role: "user", Role: "user",
Content: "hi", Content: "say hi word only",
} }
testRequest.Messages = append(testRequest.Messages, testMessage) testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest return testRequest

195
controller/discord.go Normal file
View File

@ -0,0 +1,195 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
disgoauth "github.com/realTristan/disgoauth"
)
type DiscordOAuthResponse struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type DiscordUser struct {
Id string `json:"id"`
Username string `json:"username"`
}
func getDiscordUserInfoByCode(codeFromURLParamaters string, host string) (*DiscordUser, error) {
if codeFromURLParamaters == "" {
return nil, errors.New("Invalid parameter")
}
// Establish a new discord client
var dc *disgoauth.Client = disgoauth.Init(&disgoauth.Client{
ClientID: common.DiscordClientId,
ClientSecret: common.DiscordClientSecret,
RedirectURI: fmt.Sprintf("https://%s/oauth/discord", host),
Scopes: []string{disgoauth.ScopeIdentify, disgoauth.ScopeEmail},
})
accessToken, _ := dc.GetOnlyAccessToken(codeFromURLParamaters)
// Get the authorized user's data using the above accessToken
userData, _ := disgoauth.GetUserData(accessToken)
// Create a new DiscordUser
var discordUser DiscordUser
// Decode the userData map[string]interface{} into the discordUser
// Convert the map to JSON
jsonData, _ := json.Marshal(userData)
// Convert the JSON to a struct
err := json.Unmarshal(jsonData, &discordUser)
if err != nil {
return nil, err
}
if discordUser.Username == "" {
return nil, errors.New("Invalid return value, user field is empty, please try again later!")
}
return &discordUser, nil
}
func DiscordOAuth(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("username")
if username != nil {
DiscordBind(c)
return
}
if !common.DiscordOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 Discord 登录以及注册",
})
return
}
code := c.Query("code")
host := c.Request.Host
discordUser, err := getDiscordUserInfoByCode(code, host)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user := model.User{
DiscordId: discordUser.Id,
}
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
err := user.FillUserByDiscordId()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
if common.RegisterEnabled {
user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
if discordUser.Username != "" {
user.DisplayName = discordUser.Username
} else {
user.DisplayName = "Discord User"
}
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员关闭了新用户注册",
})
return
}
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "用户已被封禁",
"success": false,
})
return
}
setupLogin(&user, c)
}
func DiscordBind(c *gin.Context) {
if !common.DiscordOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 Discord 登录以及注册",
})
return
}
code := c.Query("code")
discordUser, err := getDiscordUserInfoByCode(code, c.Request.Host)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user := model.User{
DiscordId: discordUser.Id,
}
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 Discord 账户已被绑定",
})
return
}
session := sessions.Default(c)
id := session.Get("id")
// id := c.GetInt("id") // critical bug!
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user.DiscordId = discordUser.Id
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "bind",
})
return
}

View File

@ -5,13 +5,14 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"time" "time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
) )
type GitHubOAuthResponse struct { type GitHubOAuthResponse struct {

View File

@ -3,10 +3,11 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"github.com/gin-gonic/gin"
) )
func GetStatus(c *gin.Context) { func GetStatus(c *gin.Context) {
@ -19,6 +20,8 @@ func GetStatus(c *gin.Context) {
"email_verification": common.EmailVerificationEnabled, "email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled, "github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId, "github_client_id": common.GitHubClientId,
"discord_oauth": common.DiscordOAuthEnabled,
"discord_client_id": common.DiscordClientId,
"system_name": common.SystemName, "system_name": common.SystemName,
"logo": common.Logo, "logo": common.Logo,
"footer_html": common.Footer, "footer_html": common.Footer,

View File

@ -2,6 +2,7 @@ package controller
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -53,6 +54,15 @@ func init() {
}) })
// https://platform.openai.com/docs/models/model-endpoint-compatibility // https://platform.openai.com/docs/models/model-endpoint-compatibility
openAIModels = []OpenAIModels{ openAIModels = []OpenAIModels{
{
Id: "dall-e",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "dall-e",
Parent: nil,
},
{ {
Id: "gpt-3.5-turbo", Id: "gpt-3.5-turbo",
Object: "model", Object: "model",
@ -242,6 +252,24 @@ func init() {
Root: "code-davinci-edit-001", Root: "code-davinci-edit-001",
Parent: nil, Parent: nil,
}, },
{
Id: "ChatGLM",
Object: "model",
Created: 1677649963,
OwnedBy: "thudm",
Permission: permission,
Root: "ChatGLM",
Parent: nil,
},
{
Id: "ChatGLM2",
Object: "model",
Created: 1677649963,
OwnedBy: "thudm",
Permission: permission,
Root: "ChatGLM2",
Parent: nil,
},
} }
openAIModelsMap = make(map[string]OpenAIModels) openAIModelsMap = make(map[string]OpenAIModels)
for _, model := range openAIModels { for _, model := range openAIModels {

View File

@ -2,11 +2,12 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
func GetOptions(c *gin.Context) { func GetOptions(c *gin.Context) {
@ -41,6 +42,14 @@ func UpdateOption(c *gin.Context) {
return return
} }
switch option.Key { switch option.Key {
case "DiscordOAuthEnabled":
if option.Value == "true" && common.DiscordClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Discord OAuth请先填入 Discord Client ID 以及 Discord Client Secret",
})
return
}
case "GitHubOAuthEnabled": case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" { if option.Value == "true" && common.GitHubClientId == "" {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

View File

@ -1,34 +1,181 @@
package controller package controller
import ( import (
"github.com/gin-gonic/gin" "bytes"
"encoding/json"
"errors"
"fmt"
"io" "io"
"net/http" "net/http"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
) )
func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
// TODO: this part is not finished imageModel := "dall-e"
req, err := http.NewRequest(c.Request.Method, c.Request.RequestURI, c.Request.Body)
tokenId := c.GetInt("token_id")
channelType := c.GetInt("channel")
userId := c.GetInt("id")
consumeQuota := c.GetBool("consume_quota")
group := c.GetString("group")
var imageRequest ImageRequest
if consumeQuota {
err := common.UnmarshalBodyReusable(c, &imageRequest)
if err != nil {
return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest)
}
}
// Prompt validation
if imageRequest.Prompt == "" {
return errorWrapper(errors.New("prompt is required"), "required_field_missing", http.StatusBadRequest)
}
// Not "256x256", "512x512", or "1024x1024"
if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" {
return errorWrapper(errors.New("size must be one of 256x256, 512x512, or 1024x1024"), "invalid_field_value", http.StatusBadRequest)
}
// N should between 1 and 10
if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) {
return errorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest)
}
// map model name
modelMapping := c.GetString("model_mapping")
isModelMapped := false
if modelMapping != "" {
modelMap := make(map[string]string)
err := json.Unmarshal([]byte(modelMapping), &modelMap)
if err != nil {
return errorWrapper(err, "unmarshal_model_mapping_failed", http.StatusInternalServerError)
}
if modelMap[imageModel] != "" {
imageModel = modelMap[imageModel]
isModelMapped = true
}
}
baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String()
if c.GetString("base_url") != "" {
baseURL = c.GetString("base_url")
}
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
var requestBody io.Reader
if isModelMapped {
jsonStr, err := json.Marshal(imageRequest)
if err != nil {
return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError)
}
requestBody = bytes.NewBuffer(jsonStr)
} else {
requestBody = c.Request.Body
}
modelRatio := common.GetModelRatio(imageModel)
groupRatio := common.GetGroupRatio(group)
ratio := modelRatio * groupRatio
userQuota, err := model.CacheGetUserQuota(userId)
sizeRatio := 1.0
// Size
if imageRequest.Size == "256x256" {
sizeRatio = 1
} else if imageRequest.Size == "512x512" {
sizeRatio = 1.125
} else if imageRequest.Size == "1024x1024" {
sizeRatio = 1.25
}
quota := int(ratio*sizeRatio*1000) * imageRequest.N
if consumeQuota && userQuota-quota < 0 {
return errorWrapper(err, "insufficient_user_quota", http.StatusForbidden)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
return errorWrapper(err, "new_request_failed", http.StatusInternalServerError)
}
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusOK) return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
} }
err = req.Body.Close() err = req.Body.Close()
if err != nil { if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusOK) return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
} }
err = c.Request.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
}
var textResponse ImageResponse
defer func() {
if consumeQuota {
err := model.PostConsumeTokenQuota(tokenId, quota)
if err != nil {
common.SysError("error consuming token remain quota: " + err.Error())
}
err = model.CacheUpdateUserQuota(userId)
if err != nil {
common.SysError("error update user quota cache: " + err.Error())
}
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(userId, 0, 0, imageModel, tokenName, quota, logContent)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)
}
}
}()
if consumeQuota {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
}
err = json.Unmarshal(responseBody, &textResponse)
if err != nil {
return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
}
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
}
for k, v := range resp.Header { for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0]) c.Writer.Header().Set(k, v[0])
} }
c.Writer.WriteHeader(resp.StatusCode) c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body) _, err = io.Copy(c.Writer, resp.Body)
if err != nil { if err != nil {
return errorWrapper(err, "copy_response_body_failed", http.StatusOK) return errorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError)
} }
err = resp.Body.Close() err = resp.Body.Close()
if err != nil { if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusOK) return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
} }
return nil return nil
} }

View File

@ -6,12 +6,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"io" "io"
"log"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
@ -30,6 +33,9 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if relayMode == RelayModeModerations && textRequest.Model == "" { if relayMode == RelayModeModerations && textRequest.Model == "" {
textRequest.Model = "text-moderation-latest" textRequest.Model = "text-moderation-latest"
} }
if relayMode == RelayModeEmbeddings && textRequest.Model == "" {
textRequest.Model = c.Param("model")
}
// request validation // request validation
if textRequest.Model == "" { if textRequest.Model == "" {
return errorWrapper(errors.New("model is required"), "required_field_missing", http.StatusBadRequest) return errorWrapper(errors.New("model is required"), "required_field_missing", http.StatusBadRequest)
@ -67,6 +73,27 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
isModelMapped = true isModelMapped = true
} }
} }
// Get token info
tokenInfo, err := model.GetTokenById(tokenId)
if err != nil {
return errorWrapper(err, "get_token_info_failed", http.StatusInternalServerError)
}
hasModelAvailable := func() bool {
for _, token := range strings.Split(tokenInfo.Models, ",") {
if token == textRequest.Model {
return true
}
}
return false
}()
if !hasModelAvailable {
return errorWrapper(errors.New("model not available for use"), "model_not_available_for_use", http.StatusBadRequest)
}
baseURL := common.ChannelBaseURLs[channelType] baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String() requestURL := c.Request.URL.String()
if c.GetString("base_url") != "" { if c.GetString("base_url") != "" {
@ -91,6 +118,12 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
model_ = strings.TrimSuffix(model_, "-0314") model_ = strings.TrimSuffix(model_, "-0314")
model_ = strings.TrimSuffix(model_, "-0613") model_ = strings.TrimSuffix(model_, "-0613")
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task) fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
} else if channelType == common.ChannelTypeChatGPTWeb {
// remove /v1/chat/completions from request url
requestURL := strings.Split(requestURL, "/v1/chat/completions")[0]
requestURL += "/api/chat-process"
fullRequestURL = fmt.Sprintf("%s%s", baseURL, requestURL)
} else if channelType == common.ChannelTypePaLM { } else if channelType == common.ChannelTypePaLM {
err := relayPaLM(textRequest, c) err := relayPaLM(textRequest, c)
return err return err
@ -136,8 +169,80 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
} }
requestBody = bytes.NewBuffer(jsonStr) requestBody = bytes.NewBuffer(jsonStr)
} else { } else {
requestBody = c.Request.Body bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return errorWrapper(err, "read_request_body_failed", http.StatusInternalServerError)
}
var bodyMap map[string]interface{}
err = json.Unmarshal(bodyBytes, &bodyMap)
if err != nil {
return errorWrapper(err, "unmarshal_request_body_failed", http.StatusInternalServerError)
}
// Add "stream":true to body map if it doesn't exist
if _, exists := bodyMap["stream"]; !exists {
bodyMap["stream"] = true
}
// Marshal the body map back into JSON
bodyBytes, err = json.Marshal(bodyMap)
if err != nil {
return errorWrapper(err, "marshal_request_body_failed", http.StatusInternalServerError)
}
requestBody = bytes.NewBuffer(bodyBytes)
} }
if channelType == common.ChannelTypeChatGPTWeb {
// Get system message from Message json, Role == "system"
var reqBody ChatRequest
var systemMessage Message
// Parse requestBody into systemMessage
err := json.NewDecoder(requestBody).Decode(&reqBody)
if err != nil {
return errorWrapper(err, "decode_request_body_failed", http.StatusInternalServerError)
}
for _, message := range reqBody.Messages {
if message.Role == "system" {
systemMessage = message
break
}
}
var prompt string
// Get all the Message, Roles from request.Messages, and format it into string by
// ||> role: content
for _, message := range reqBody.Messages {
// Exclude system message
if message.Role == "system" {
continue
}
prompt += "||> " + message.Role + ": " + message.Content + "\n"
}
// Construct json data without adding escape character
map1 := map[string]string{
"prompt": prompt,
"systemMessage": systemMessage.Content,
"temperature": strconv.FormatFloat(reqBody.Temperature, 'f', 2, 64),
"top_p": strconv.FormatFloat(reqBody.TopP, 'f', 2, 64),
}
// Convert map to json string
jsonData, err := json.Marshal(map1)
if err != nil {
return errorWrapper(err, "marshal_json_failed", http.StatusInternalServerError)
}
// Convert json string to io.Reader
requestBody = bytes.NewReader(jsonData)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil { if err != nil {
return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) return errorWrapper(err, "new_request_failed", http.StatusInternalServerError)
@ -152,11 +257,36 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
//req.Header.Set("Connection", c.Request.Header.Get("Connection")) //req.Header.Set("Connection", c.Request.Header.Get("Connection"))
if c.GetBool("enable_ip_randomization") == true {
// Generate random IP
ip := common.GenerateIP()
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("X-Real-IP", ip)
req.Header.Set("X-Client-IP", ip)
req.Header.Set("X-Forwarded-Host", ip)
req.Header.Set("X-Originating-IP", ip)
req.RemoteAddr = ip
req.Header.Set("X-Remote-IP", ip)
req.Header.Set("X-Remote-Addr", ip)
}
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
} }
if resp.StatusCode != http.StatusOK {
// Print the body in string
if resp.Body != nil {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
log.Printf("Error Channel (%s): %s", baseURL, buf.String())
return errorWrapper(err, "request_failed", resp.StatusCode)
}
return errorWrapper(err, "request_failed", resp.StatusCode)
}
err = req.Body.Close() err = req.Body.Close()
if err != nil { if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError) return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
@ -166,7 +296,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError) return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
} }
var textResponse TextResponse var textResponse TextResponse
isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") || strings.HasPrefix(resp.Header.Get("Content-Type"), "application/octet-stream")
var streamResponseText string var streamResponseText string
defer func() { defer func() {
@ -217,60 +347,129 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
}() }()
if isStream { if isStream {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n\n"); i >= 0 {
return i + 2, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
dataChan := make(chan string) dataChan := make(chan string)
stopChan := make(chan bool) stopChan := make(chan bool)
go func() {
for scanner.Scan() { if channelType == common.ChannelTypeChatGPTWeb {
data := scanner.Text() scanner := bufio.NewScanner(resp.Body)
if len(data) < 6 { // must be something wrong! go func() {
common.SysError("invalid stream response: " + data) for scanner.Scan() {
continue var chatResponse ChatGptWebChatResponse
} err = json.Unmarshal(scanner.Bytes(), &chatResponse)
dataChan <- data
data = data[6:] if err != nil {
if !strings.HasPrefix(data, "[DONE]") { log.Println("error unmarshal chat response: " + err.Error())
switch relayMode { continue
case RelayModeChatCompletions: }
var streamResponse ChatCompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse) // if response role is assistant and contains delta, append the content to streamResponseText
if err != nil { if chatResponse.Role == "assistant" && chatResponse.Detail != nil {
common.SysError("error unmarshalling stream response: " + err.Error()) for _, choice := range chatResponse.Detail.Choices {
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content streamResponseText += choice.Delta.Content
}
case RelayModeCompletions: returnObj := map[string]interface{}{
var streamResponse CompletionsStreamResponse "id": chatResponse.ID,
err = json.Unmarshal([]byte(data), &streamResponse) "object": chatResponse.Detail.Object,
if err != nil { "created": chatResponse.Detail.Created,
common.SysError("error unmarshalling stream response: " + err.Error()) "model": chatResponse.Detail.Model,
return "choices": []map[string]interface{}{
} // set finish_reason to null in json
for _, choice := range streamResponse.Choices { {
streamResponseText += choice.Text "finish_reason": nil,
"index": 0,
"delta": map[string]interface{}{
"content": choice.Delta.Content,
},
},
},
}
jsonData, _ := json.Marshal(returnObj)
dataChan <- "data: " + string(jsonData)
} }
} }
} }
} stopChan <- true
stopChan <- true }()
}() } else {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n"); i >= 0 {
return i + 1, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
go func() {
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // must be something wrong!
// common.SysError("invalid stream response: " + data)
continue
}
// If data has event: event content inside, remove it, it can be prefix or inside the data
if strings.HasPrefix(data, "event:") || strings.Contains(data, "event:") {
// Remove event: event in the front or back
data = strings.TrimPrefix(data, "event: event")
data = strings.TrimSuffix(data, "event: event")
// Remove everything, only keep `data: {...}` <--- this is the json
// Find the start and end indices of `data: {...}` substring
startIndex := strings.Index(data, "data:")
endIndex := strings.LastIndex(data, "}")
// If both indices are found and end index is greater than start index
if startIndex != -1 && endIndex != -1 && endIndex > startIndex {
// Extract the `data: {...}` substring
data = data[startIndex : endIndex+1]
}
// Trim whitespace and newlines from the modified data string
data = strings.TrimSpace(data)
}
if !strings.HasPrefix(data, "data:") {
continue
}
dataChan <- data
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
switch relayMode {
case RelayModeChatCompletions:
var streamResponse ChatCompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content
}
case RelayModeCompletions:
var streamResponse CompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Text
}
}
}
}
stopChan <- true
}()
}
c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("Connection", "keep-alive")
@ -282,6 +481,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if strings.HasPrefix(data, "data: [DONE]") { if strings.HasPrefix(data, "data: [DONE]") {
data = data[:12] data = data[:12]
} }
// some implementations may add \r at the end of data
data = strings.TrimSuffix(data, "\r")
c.Render(-1, common.CustomEvent{Data: data}) c.Render(-1, common.CustomEvent{Data: data})
return true return true
case <-stopChan: case <-stopChan:

View File

@ -2,10 +2,12 @@ package controller
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type Message struct { type Message struct {
@ -37,12 +39,17 @@ type GeneralOpenAIRequest struct {
N int `json:"n,omitempty"` N int `json:"n,omitempty"`
Input any `json:"input,omitempty"` Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"` Instruction string `json:"instruction,omitempty"`
Size string `json:"size,omitempty"`
} }
type ChatRequest struct { type ChatRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"` MaxTokens *int `json:"max_tokens,omitempty"`
Stream bool `json:"stream"`
// -1.0 to 1.0
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
} }
type TextRequest struct { type TextRequest struct {
@ -53,6 +60,12 @@ type TextRequest struct {
//Stream bool `json:"stream"` //Stream bool `json:"stream"`
} }
type ImageRequest struct {
Prompt string `json:"prompt"`
N int `json:"n"`
Size string `json:"size"`
}
type Usage struct { type Usage struct {
PromptTokens int `json:"prompt_tokens"` PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"` CompletionTokens int `json:"completion_tokens"`
@ -76,6 +89,13 @@ type TextResponse struct {
Error OpenAIError `json:"error"` Error OpenAIError `json:"error"`
} }
type ImageResponse struct {
Created int `json:"created"`
Data []struct {
Url string `json:"url"`
}
}
type ChatCompletionsStreamResponse struct { type ChatCompletionsStreamResponse struct {
Choices []struct { Choices []struct {
Delta struct { Delta struct {
@ -92,6 +112,32 @@ type CompletionsStreamResponse struct {
} `json:"choices"` } `json:"choices"`
} }
type ChatGptWebDetail struct {
ID string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
Model string `json:"model"`
Choices []ChatGptWebChoice `json:"choices"`
}
type ChatGptWebChoice struct {
Delta struct {
Content string `json:"content"`
Role string `json:"role"`
} `json:"delta"`
Index int `json:"index"`
Finish_Reason string `json:"finish_reason"`
}
type ChatGptWebChatResponse struct {
Role string `json:"role"`
ID string `json:"id"`
ParentMessageID string `json:"parentMessageId"`
Text string `json:"text"`
Delta string `json:"delta"`
Detail *ChatGptWebDetail `json:"detail"`
}
func Relay(c *gin.Context) { func Relay(c *gin.Context) {
relayMode := RelayModeUnknown relayMode := RelayModeUnknown
if strings.HasPrefix(c.Request.URL.Path, "/v1/chat/completions") { if strings.HasPrefix(c.Request.URL.Path, "/v1/chat/completions") {
@ -100,6 +146,8 @@ func Relay(c *gin.Context) {
relayMode = RelayModeCompletions relayMode = RelayModeCompletions
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") {
relayMode = RelayModeEmbeddings relayMode = RelayModeEmbeddings
} else if strings.HasSuffix(c.Request.URL.Path, "embeddings") {
relayMode = RelayModeEmbeddings
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") {
relayMode = RelayModeModerations relayMode = RelayModeModerations
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
@ -115,16 +163,25 @@ func Relay(c *gin.Context) {
err = relayTextHelper(c, relayMode) err = relayTextHelper(c, relayMode)
} }
if err != nil { if err != nil {
if err.StatusCode == http.StatusTooManyRequests { retryTimesStr := c.Query("retry")
err.OpenAIError.Message = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。" retryTimes, _ := strconv.Atoi(retryTimesStr)
if retryTimesStr == "" {
retryTimes = common.RetryTimes
}
if retryTimes > 0 {
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s?retry=%d", c.Request.URL.Path, retryTimes-1))
} else {
if err.StatusCode == http.StatusTooManyRequests {
err.OpenAIError.Message = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
}
c.JSON(err.StatusCode, gin.H{
"error": err.OpenAIError,
})
} }
c.JSON(err.StatusCode, gin.H{
"error": err.OpenAIError,
})
channelId := c.GetInt("channel_id") channelId := c.GetInt("channel_id")
common.SysError(fmt.Sprintf("relay error (channel #%d): %s", channelId, err.Message)) common.SysError(fmt.Sprintf("relay error (channel #%d): %s", channelId, err.Message))
// https://platform.openai.com/docs/guides/error-codes/api-errors // https://platform.openai.com/docs/guides/error-codes/api-errors
if common.AutomaticDisableChannelEnabled && (err.Type == "insufficient_quota" || err.Code == "invalid_api_key") { if common.AutomaticDisableChannelEnabled && (err.Type == "insufficient_quota" || err.Code == "invalid_api_key" || err.Code == "account_deactivated") {
channelId := c.GetInt("channel_id") channelId := c.GetInt("channel_id")
channelName := c.GetString("channel_name") channelName := c.GetString("channel_name")
disableChannel(channelId, channelName, err.Message) disableChannel(channelId, channelName, err.Message)

View File

@ -1,11 +1,12 @@
package controller package controller
import ( import (
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"github.com/gin-gonic/gin"
) )
func GetAllTokens(c *gin.Context) { func GetAllTokens(c *gin.Context) {
@ -125,6 +126,7 @@ func AddToken(c *gin.Context) {
ExpiredTime: token.ExpiredTime, ExpiredTime: token.ExpiredTime,
RemainQuota: token.RemainQuota, RemainQuota: token.RemainQuota,
UnlimitedQuota: token.UnlimitedQuota, UnlimitedQuota: token.UnlimitedQuota,
Models: token.Models,
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {
@ -203,6 +205,7 @@ func UpdateToken(c *gin.Context) {
cleanToken.ExpiredTime = token.ExpiredTime cleanToken.ExpiredTime = token.ExpiredTime
cleanToken.RemainQuota = token.RemainQuota cleanToken.RemainQuota = token.RemainQuota
cleanToken.UnlimitedQuota = token.UnlimitedQuota cleanToken.UnlimitedQuota = token.UnlimitedQuota
cleanToken.Models = token.Models
} }
err = cleanToken.Update() err = cleanToken.Update()
if err != nil { if err != nil {

View File

@ -3,12 +3,13 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
) )
type LoginRequest struct { type LoginRequest struct {
@ -477,6 +478,16 @@ func DeleteUser(c *gin.Context) {
func DeleteSelf(c *gin.Context) { func DeleteSelf(c *gin.Context) {
id := c.GetInt("id") id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权删除超级管理员",
})
return
}
err := model.DeleteUserById(id) err := model.DeleteUserById(id)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

34
english.dockerfile Normal file
View File

@ -0,0 +1,34 @@
# Initial stage
FROM python:3.11 as translator
WORKDIR /app
COPY . .
RUN python ./i18n/translate.py --repository_path . --json_file_path ./i18n/en.json
# Node build stage
FROM node:18-alpine as nodeBuilder
WORKDIR /build
COPY ./web/package*.json ./
RUN npm ci
COPY --from=translator /app .
RUN cd web && VITE_REACT_APP_VERSION=$(cat VERSION) npm run build
# Go build stage
FROM golang:1.20.5 AS goBuilder
ENV GO111MODULE=on \
CGO_ENABLED=1 \
GOOS=linux
WORKDIR /build
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY --from=translator /app .
COPY --from=nodeBuilder /build/web/build ./web/build
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api
# Final stage
FROM alpine:latest
RUN apk update && apk upgrade && apk add --no-cache ca-certificates tzdata && update-ca-certificates 2>/dev/null || true
WORKDIR /data
COPY --from=goBuilder /build/one-api /
EXPOSE 3000
ENTRYPOINT ["/one-api"]

36
go.mod
View File

@ -9,28 +9,28 @@ require (
github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1 github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.14.0 github.com/go-playground/validator/v10 v10.14.1
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/pkoukk/tiktoken-go v0.1.1 github.com/pkoukk/tiktoken-go v0.1.5
golang.org/x/crypto v0.9.0 golang.org/x/crypto v0.11.0
gorm.io/driver/mysql v1.4.3 gorm.io/driver/mysql v1.5.1
gorm.io/driver/sqlite v1.4.3 gorm.io/driver/sqlite v1.5.2
gorm.io/gorm v1.24.0 gorm.io/gorm v1.25.2
) )
require ( require (
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.2 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.8.1 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
@ -38,20 +38,22 @@ require (
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/realTristan/disgoauth v1.0.2
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.4.0 // indirect
golang.org/x/net v0.10.0 // indirect golang.org/x/net v0.12.0 // indirect
golang.org/x/sys v0.8.0 // indirect golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect golang.org/x/text v0.11.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

58
go.sum
View File

@ -3,8 +3,12 @@ github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
@ -16,6 +20,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0= github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0=
github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
@ -47,15 +53,23 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
@ -75,12 +89,16 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -112,11 +130,21 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkoukk/tiktoken-go v0.1.1 h1:jtkYlIECjyM9OW1w4rjPmTohK4arORP9V25y6TM6nXo= github.com/pkoukk/tiktoken-go v0.1.1 h1:jtkYlIECjyM9OW1w4rjPmTohK4arORP9V25y6TM6nXo=
github.com/pkoukk/tiktoken-go v0.1.1/go.mod h1:boMWvk9pQCOTx11pgu0DrIdrAKgQzzJKUP6vLXaz7Rw= github.com/pkoukk/tiktoken-go v0.1.1/go.mod h1:boMWvk9pQCOTx11pgu0DrIdrAKgQzzJKUP6vLXaz7Rw=
github.com/pkoukk/tiktoken-go v0.1.4 h1:bniMzWdUvNO6YkRbASo2x5qJf2LAG/TIJojqz+Igm8E=
github.com/pkoukk/tiktoken-go v0.1.4/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pkoukk/tiktoken-go v0.1.5 h1:hAlT4dCf6Uk50x8E7HQrddhH3EWMKUN+LArExQQsQx4=
github.com/pkoukk/tiktoken-go v0.1.5/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ravener/discord-oauth2 v0.0.0-20230514095040-ae65713199b3 h1:x3LgcvujjG+mx8PUMfPmwn3tcu2aA95uCB6ilGGObWk=
github.com/ravener/discord-oauth2 v0.0.0-20230514095040-ae65713199b3/go.mod h1:P/mZMYLZ87lqRSECEWsOqywGrO1hlZkk9RTwEw35IP4=
github.com/realTristan/disgoauth v1.0.2 h1:dfto2Kf1gFlZsf8XuwRNoemLgk+hGn/TJpSdtMrEh8E=
github.com/realTristan/disgoauth v1.0.2/go.mod h1:t72aRaWMq2gknUZcKONReJlEYFod5sHC86WCJ0X9GxA=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
@ -133,6 +161,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
@ -144,34 +173,56 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -188,9 +239,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -443,6 +443,7 @@
"显示名称": "Display Name", "显示名称": "Display Name",
"请输入新的显示名称": "Please enter a new display name", "请输入新的显示名称": "Please enter a new display name",
"已绑定的 GitHub 账户": "GitHub Account Bound", "已绑定的 GitHub 账户": "GitHub Account Bound",
"已绑定的 Discord 账户": "Discord Account Bound",
"此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly", "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"已绑定的微信账户": "WeChat Account Bound", "已绑定的微信账户": "WeChat Account Bound",
"已绑定的邮箱账户": "Email Account Bound", "已绑定的邮箱账户": "Email Account Bound",
@ -503,5 +504,26 @@
"请输入 AZURE_OPENAI_ENDPOINT": "Please enter AZURE_OPENAI_ENDPOINT", "请输入 AZURE_OPENAI_ENDPOINT": "Please enter AZURE_OPENAI_ENDPOINT",
"请输入自定义渠道的 Base URL": "Please enter the Base URL of the custom channel", "请输入自定义渠道的 Base URL": "Please enter the Base URL of the custom channel",
"Homepage URL 填": "Fill in the Homepage URL", "Homepage URL 填": "Fill in the Homepage URL",
"Authorization callback URL 填": "Fill in the Authorization callback URL" "Authorization callback URL 填": "Fill in the Authorization callback URL",
"允许通过 Discord 账户登录和注册": "Allow login and registration via Discord account",
"Discord 身份验证": "Discord Authentication",
"确认文字": "Confirmation Text",
"请输入 \"CONFIRM\" 以删除您的帐户。": "Please enter \"CONFIRM\" to delete your account.",
"请确认您要删除账户!": "Please confirm that you want to delete the account!",
"账户已删除!": "Account deleted!",
"您是否确认删除自己的帐户?": "Are you sure you want to delete your account?",
"配置 Discord OAuth App": "Configure Discord OAuth App",
"管理你的 Discord OAuth App": "Manage your Discord OAuth App",
"输入你注册的 Discord OAuth APP 的 ID": "Enter the ID of your registered Discord OAuth APP",
"保存 Discord OAuth 设置": "Save Discord OAuth Settings",
"删除个人账户": "Delete personal account",
"绑定 Discord 账号": "Bind Discord account",
"无权将其他用户权限等级提升到大于等于自己的权限等级": "You are not allowed to upgrade the permission level of other users to greater than or equal to your own permission level",
"无权删除超级管理员": "You are not allowed to delete super administrators",
"该 Discord 账户已被绑定": "The Discord account has been bound",
"管理员未开启通过 Discord 登录以及注册": "The administrator has not enabled login and registration via Discord",
"无法启用 Discord OAuth请先填入 Discord Client ID 以及 Discord Client Secret": "Unable to enable Discord OAuth, please fill in the Discord Client ID and Discord Client Secret first!",
"兑换失败,": "Redemption failed, ",
"请选择此密钥支持的模型": "Please select the models supported by this key",
"将IP随机地址传递给HTTP头": "Pass the IP random address to the HTTP header"
} }

10
main.go
View File

@ -2,9 +2,6 @@ package main
import ( import (
"embed" "embed"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/controller" "one-api/controller"
"one-api/middleware" "one-api/middleware"
@ -12,6 +9,11 @@ import (
"one-api/router" "one-api/router"
"os" "os"
"strconv" "strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
) )
//go:embed web/build //go:embed web/build
@ -21,6 +23,8 @@ var buildFS embed.FS
var indexPage []byte var indexPage []byte
func main() { func main() {
godotenv.Load(".env")
common.SetupGinLog() common.SetupGinLog()
common.SysLog("One API " + common.Version + " started") common.SysLog("One API " + common.Version + " started")
if os.Getenv("GIN_MODE") != "debug" { if os.Getenv("GIN_MODE") != "debug" {

View File

@ -2,12 +2,13 @@ package middleware
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type ModelRequest struct { type ModelRequest struct {
@ -73,6 +74,16 @@ func Distribute() func(c *gin.Context) {
modelRequest.Model = "text-moderation-stable" modelRequest.Model = "text-moderation-stable"
} }
} }
if strings.HasSuffix(c.Request.URL.Path, "embeddings") {
if modelRequest.Model == "" {
modelRequest.Model = c.Param("model")
}
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
if modelRequest.Model == "" {
modelRequest.Model = "dall-e"
}
}
channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model) channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model)
if err != nil { if err != nil {
message := "无可用渠道" message := "无可用渠道"
@ -94,6 +105,7 @@ func Distribute() func(c *gin.Context) {
c.Set("channel_id", channel.Id) c.Set("channel_id", channel.Id)
c.Set("channel_name", channel.Name) c.Set("channel_name", channel.Name)
c.Set("model_mapping", channel.ModelMapping) c.Set("model_mapping", channel.ModelMapping)
c.Set("enable_ip_randomization", channel.EnableIpRandomization)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
c.Set("base_url", channel.BaseURL) c.Set("base_url", channel.BaseURL)
if channel.Type == common.ChannelTypeAzure { if channel.Type == common.ChannelTypeAzure {

View File

@ -1,8 +1,9 @@
package model package model
import ( import (
"gorm.io/gorm"
"one-api/common" "one-api/common"
"gorm.io/gorm"
) )
type Channel struct { type Channel struct {
@ -23,6 +24,9 @@ type Channel struct {
Group string `json:"group" gorm:"type:varchar(32);default:'default'"` Group string `json:"group" gorm:"type:varchar(32);default:'default'"`
UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"`
ModelMapping string `json:"model_mapping" gorm:"type:varchar(1024);default:''"` ModelMapping string `json:"model_mapping" gorm:"type:varchar(1024);default:''"`
// Additional fields, default value is false
EnableIpRandomization bool `json:"enable_ip_randomization"`
} }
func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {

View File

@ -30,6 +30,7 @@ func InitOptionMap() {
common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
common.OptionMap["DiscordOAuthEnabled"] = strconv.FormatBool(common.DiscordOAuthEnabled)
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
@ -53,6 +54,8 @@ func InitOptionMap() {
common.OptionMap["ServerAddress"] = "" common.OptionMap["ServerAddress"] = ""
common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["DiscordClientId"] = ""
common.OptionMap["DiscordClientSecret"] = ""
common.OptionMap["WeChatServerAddress"] = "" common.OptionMap["WeChatServerAddress"] = ""
common.OptionMap["WeChatServerToken"] = "" common.OptionMap["WeChatServerToken"] = ""
common.OptionMap["WeChatAccountQRCodeImageURL"] = "" common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
@ -68,6 +71,7 @@ func InitOptionMap() {
common.OptionMap["TopUpLink"] = common.TopUpLink common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMap["ChatLink"] = common.ChatLink common.OptionMap["ChatLink"] = common.ChatLink
common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64) common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64)
common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes)
common.OptionMapRWMutex.Unlock() common.OptionMapRWMutex.Unlock()
loadOptionsFromDatabase() loadOptionsFromDatabase()
} }
@ -132,6 +136,8 @@ func updateOptionMap(key string, value string) (err error) {
common.PasswordLoginEnabled = boolValue common.PasswordLoginEnabled = boolValue
case "EmailVerificationEnabled": case "EmailVerificationEnabled":
common.EmailVerificationEnabled = boolValue common.EmailVerificationEnabled = boolValue
case "DiscordOAuthEnabled":
common.DiscordOAuthEnabled = boolValue
case "GitHubOAuthEnabled": case "GitHubOAuthEnabled":
common.GitHubOAuthEnabled = boolValue common.GitHubOAuthEnabled = boolValue
case "WeChatAuthEnabled": case "WeChatAuthEnabled":
@ -170,6 +176,10 @@ func updateOptionMap(key string, value string) (err error) {
common.GitHubClientId = value common.GitHubClientId = value
case "GitHubClientSecret": case "GitHubClientSecret":
common.GitHubClientSecret = value common.GitHubClientSecret = value
case "DiscordClientId":
common.DiscordClientId = value
case "DiscordClientSecret":
common.DiscordClientSecret = value
case "Footer": case "Footer":
common.Footer = value common.Footer = value
case "SystemName": case "SystemName":
@ -196,6 +206,8 @@ func updateOptionMap(key string, value string) (err error) {
common.QuotaRemindThreshold, _ = strconv.Atoi(value) common.QuotaRemindThreshold, _ = strconv.Atoi(value)
case "PreConsumedQuota": case "PreConsumedQuota":
common.PreConsumedQuota, _ = strconv.Atoi(value) common.PreConsumedQuota, _ = strconv.Atoi(value)
case "RetryTimes":
common.RetryTimes, _ = strconv.Atoi(value)
case "ModelRatio": case "ModelRatio":
err = common.UpdateModelRatioByJSONString(value) err = common.UpdateModelRatioByJSONString(value)
case "GroupRatio": case "GroupRatio":

View File

@ -3,8 +3,9 @@ package model
import ( import (
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm"
"one-api/common" "one-api/common"
"gorm.io/gorm"
) )
type Token struct { type Token struct {
@ -19,6 +20,7 @@ type Token struct {
RemainQuota int `json:"remain_quota" gorm:"default:0"` RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"` UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Models string `json:"models"`
} }
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
@ -99,7 +101,7 @@ func (token *Token) Insert() error {
// Update Make sure your token's fields is completed, because this will update non-zero values // Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() error { func (token *Token) Update() error {
var err error var err error
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota").Updates(token).Error err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", "models").Updates(token).Error
return err return err
} }

View File

@ -3,9 +3,10 @@ package model
import ( import (
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm"
"one-api/common" "one-api/common"
"strings" "strings"
"gorm.io/gorm"
) )
// User if you add sensitive fields, don't forget to clean them in setupLogin function. // User if you add sensitive fields, don't forget to clean them in setupLogin function.
@ -19,6 +20,7 @@ type User struct {
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Email string `json:"email" gorm:"index" validate:"max=50"` Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"` GitHubId string `json:"github_id" gorm:"column:github_id;index"`
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
@ -169,6 +171,14 @@ func (user *User) FillUserByGitHubId() error {
return nil return nil
} }
func (user *User) FillUserByDiscordId() error {
if user.DiscordId == "" {
return errors.New("Discord id 为空!")
}
DB.Where(User{DiscordId: user.DiscordId}).First(user)
return nil
}
func (user *User) FillUserByWeChatId() error { func (user *User) FillUserByWeChatId() error {
if user.WeChatId == "" { if user.WeChatId == "" {
return errors.New("WeChat id 为空!") return errors.New("WeChat id 为空!")
@ -197,6 +207,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
} }
func IsDiscordIdAlreadyTaken(discordId string) bool {
return DB.Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1
}
func IsUsernameAlreadyTaken(username string) bool { func IsUsernameAlreadyTaken(username string) bool {
return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1 return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
} }

View File

@ -1,10 +1,11 @@
package router package router
import ( import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"one-api/controller" "one-api/controller"
"one-api/middleware" "one-api/middleware"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
) )
func SetApiRouter(router *gin.Engine) { func SetApiRouter(router *gin.Engine) {
@ -20,6 +21,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth)
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind) apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
@ -27,7 +29,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute := apiRouter.Group("/user") userRoute := apiRouter.Group("/user")
{ {
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.GET("/logout", controller.Logout) userRoute.GET("/logout", controller.Logout)
selfRoute := userRoute.Group("/") selfRoute := userRoute.Group("/")
@ -35,7 +37,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.DELETE("/self", middleware.TurnstileCheck(), controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/token", controller.GenerateAccessToken)
selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.POST("/topup", controller.TopUp) selfRoute.POST("/topup", controller.TopUp)
@ -60,11 +62,13 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.PUT("/", controller.UpdateOption) optionRoute.PUT("/", controller.UpdateOption)
} }
channelRoute := apiRouter.Group("/channel") channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.UserAuth()).GET("/models", controller.ListModels)
channelRoute.Use(middleware.AdminAuth()) channelRoute.Use(middleware.AdminAuth())
{ {
channelRoute.GET("/", controller.GetAllChannels) channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels) channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/models", controller.ListModels)
channelRoute.GET("/:id", controller.GetChannel) channelRoute.GET("/:id", controller.GetChannel)
channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/test/:id", controller.TestChannel)
@ -74,6 +78,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.PUT("/", controller.UpdateChannel) channelRoute.PUT("/", controller.UpdateChannel)
channelRoute.DELETE("/:id", controller.DeleteChannel) channelRoute.DELETE("/:id", controller.DeleteChannel)
} }
tokenRoute := apiRouter.Group("/token") tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth()) tokenRoute.Use(middleware.UserAuth())
{ {

View File

@ -1,9 +1,10 @@
package router package router
import ( import (
"github.com/gin-gonic/gin"
"one-api/controller" "one-api/controller"
"one-api/middleware" "one-api/middleware"
"github.com/gin-gonic/gin"
) )
func SetRelayRouter(router *gin.Engine) { func SetRelayRouter(router *gin.Engine) {
@ -20,10 +21,11 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router.POST("/completions", controller.Relay) relayV1Router.POST("/completions", controller.Relay)
relayV1Router.POST("/chat/completions", controller.Relay) relayV1Router.POST("/chat/completions", controller.Relay)
relayV1Router.POST("/edits", controller.Relay) relayV1Router.POST("/edits", controller.Relay)
relayV1Router.POST("/images/generations", controller.RelayNotImplemented) relayV1Router.POST("/images/generations", controller.Relay)
relayV1Router.POST("/images/edits", controller.RelayNotImplemented) relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
relayV1Router.POST("/images/variations", controller.RelayNotImplemented) relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
relayV1Router.POST("/embeddings", controller.Relay) relayV1Router.POST("/embeddings", controller.Relay)
relayV1Router.POST("/engines/:model/embeddings", controller.Relay)
relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented) relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented)
relayV1Router.POST("/audio/translations", controller.RelayNotImplemented) relayV1Router.POST("/audio/translations", controller.RelayNotImplemented)
relayV1Router.GET("/files", controller.RelayNotImplemented) relayV1Router.GET("/files", controller.RelayNotImplemented)

3
web/.gitignore vendored
View File

@ -22,5 +22,4 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.idea .idea
package-lock.json yarn.lock
yarn.lock

View File

@ -10,12 +10,12 @@ npm start
npm run build npm run build
``` ```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build, If you want to change the default server, please set `VITE_REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`. for example: `VITE_REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled. Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference ## Reference
1. https://github.com/OIerDb-ng/OIerDb 1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example 2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -14,5 +14,6 @@
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./src/index.jsx"></script>
</body> </body>
</html> </html>

1799
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,24 +3,21 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^1.4.0",
"history": "^5.3.0", "history": "^5.3.0",
"marked": "^4.1.1", "marked": "^5.1.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.14.1",
"react-scripts": "5.0.1", "react-toastify": "^9.1.3",
"react-toastify": "^9.0.8", "react-turnstile": "^1.1.1",
"react-turnstile": "^1.0.5",
"semantic-ui-css": "^2.5.0", "semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3" "semantic-ui-react": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite preview",
"build": "react-scripts build", "build": "vite build"
"test": "react-scripts test",
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -41,7 +38,10 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"prettier": "^2.7.1" "@vitejs/plugin-react": "^4.0.3",
"prettier": "3.0.0",
"terser": "^5.19.0",
"vite": "^4.4.4"
}, },
"prettier": { "prettier": {
"singleQuote": true, "singleQuote": true,

View File

@ -12,6 +12,7 @@ import AddUser from './pages/User/AddUser';
import { API, getLogo, getSystemName, showError, showNotice } from './helpers'; import { API, getLogo, getSystemName, showError, showNotice } from './helpers';
import PasswordResetForm from './components/PasswordResetForm'; import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth'; import GitHubOAuth from './components/GitHubOAuth';
import DiscordOAuth from './components/DiscordOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm'; import PasswordResetConfirm from './components/PasswordResetConfirm';
import { UserContext } from './context/User'; import { UserContext } from './context/User';
import { StatusContext } from './context/Status'; import { StatusContext } from './context/Status';
@ -55,15 +56,6 @@ function App() {
} else { } else {
localStorage.removeItem('chat_link'); localStorage.removeItem('chat_link');
} }
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else { } else {
showError('无法正常连接至服务器!'); showError('无法正常连接至服务器!');
} }
@ -239,6 +231,14 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route
path='/oauth/discord'
element={
<Suspense fallback={<Loading></Loading>}>
<DiscordOAuth />
</Suspense>
}
/>
<Route <Route
path='/setting' path='/setting'
element={ element={
@ -252,11 +252,11 @@ function App() {
<Route <Route
path='/topup' path='/topup'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>}> <Suspense fallback={<Loading></Loading>}>
<TopUp /> <TopUp />
</Suspense> </Suspense>
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route <Route

View File

@ -1,30 +1,43 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showInfo, showSuccess, timestamp2string } from '../helpers'; import {
API,
showError,
showInfo,
showSuccess,
timestamp2string,
} from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber } from '../helpers/render'; import { renderGroup, renderNumber } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
let type2label = undefined; let type2label = undefined;
function renderType(type) { function renderType(type) {
if (!type2label) { if (!type2label) {
type2label = new Map; type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
} }
type2label[0] = { value: 0, text: '未知类型', color: 'grey' }; type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
} }
return <Label basic color={type2label[type].color}>{type2label[type].text}</Label>; return (
<Label basic color={type2label[type].color}>
{type2label[type].text}
</Label>
);
} }
function renderBalance(type, balance) { function renderBalance(type, balance) {
@ -132,7 +145,11 @@ const ChannelsTable = () => {
const renderStatus = (status) => { const renderStatus = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>已启用</Label>; return (
<Label basic color='green'>
已启用
</Label>
);
case 2: case 2:
return ( return (
<Label basic color='red'> <Label basic color='red'>
@ -152,15 +169,35 @@ const ChannelsTable = () => {
let time = responseTime / 1000; let time = responseTime / 1000;
time = time.toFixed(2) + ' 秒'; time = time.toFixed(2) + ' 秒';
if (responseTime === 0) { if (responseTime === 0) {
return <Label basic color='grey'>未测试</Label>; return (
<Label basic color='grey'>
未测试
</Label>
);
} else if (responseTime <= 1000) { } else if (responseTime <= 1000) {
return <Label basic color='green'>{time}</Label>; return (
<Label basic color='green'>
{time}
</Label>
);
} else if (responseTime <= 3000) { } else if (responseTime <= 3000) {
return <Label basic color='olive'>{time}</Label>; return (
<Label basic color='olive'>
{time}
</Label>
);
} else if (responseTime <= 5000) { } else if (responseTime <= 5000) {
return <Label basic color='yellow'>{time}</Label>; return (
<Label basic color='yellow'>
{time}
</Label>
);
} else { } else {
return <Label basic color='red'>{time}</Label>; return (
<Label basic color='red'>
{time}
</Label>
);
} }
}; };
@ -342,7 +379,7 @@ const ChannelsTable = () => {
{channels {channels
.slice( .slice(
(activePage - 1) * ITEMS_PER_PAGE, (activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE activePage * ITEMS_PER_PAGE,
) )
.map((channel, idx) => { .map((channel, idx) => {
if (channel.deleted) return <></>; if (channel.deleted) return <></>;
@ -355,7 +392,11 @@ const ChannelsTable = () => {
<Table.Cell>{renderStatus(channel.status)}</Table.Cell> <Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell> <Table.Cell>
<Popup <Popup
content={channel.test_time ? renderTimestamp(channel.test_time) : '未测试'} content={
channel.test_time
? renderTimestamp(channel.test_time)
: '未测试'
}
key={channel.id} key={channel.id}
trigger={renderResponseTime(channel.response_time)} trigger={renderResponseTime(channel.response_time)}
basic basic
@ -363,7 +404,11 @@ const ChannelsTable = () => {
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Popup <Popup
content={channel.balance_updated_time ? renderTimestamp(channel.balance_updated_time) : '未更新'} content={
channel.balance_updated_time
? renderTimestamp(channel.balance_updated_time)
: '未更新'
}
key={channel.id} key={channel.id}
trigger={renderBalance(channel.type, channel.balance)} trigger={renderBalance(channel.type, channel.balance)}
basic basic
@ -415,7 +460,7 @@ const ChannelsTable = () => {
manageChannel( manageChannel(
channel.id, channel.id,
channel.status === 1 ? 'disable' : 'enable', channel.status === 1 ? 'disable' : 'enable',
idx idx,
); );
}} }}
> >
@ -438,14 +483,24 @@ const ChannelsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='8'> <Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/channel/add' loading={loading}> <Button
size='small'
as={Link}
to='/channel/add'
loading={loading}
>
添加新的渠道 添加新的渠道
</Button> </Button>
<Button size='small' loading={loading} onClick={testAllChannels}> <Button size='small' loading={loading} onClick={testAllChannels}>
测试所有已启用通道 测试所有已启用通道
</Button> </Button>
<Button size='small' onClick={updateAllChannelsBalance} <Button
loading={loading || updatingBalance}>更新所有已启用通道余额</Button> size='small'
onClick={updateAllChannelsBalance}
loading={loading || updatingBalance}
>
更新所有已启用通道余额
</Button>
<Pagination <Pagination
floated='right' floated='right'
activePage={activePage} activePage={activePage}
@ -457,7 +512,9 @@ const ChannelsTable = () => {
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
} }
/> />
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
</Table.HeaderCell> </Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Footer> </Table.Footer>

View File

@ -0,0 +1,57 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { UserContext } from '../context/User';
const DiscordOAuth = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
let navigate = useNavigate();
const sendCode = async (code, count) => {
const res = await API.get(`/api/oauth/discord?code=${code}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind GitHub
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, count);
}
};
useEffect(() => {
let code = searchParams.get('code');
sendCode(code, 0).then();
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
};
export default DiscordOAuth;

View File

@ -37,11 +37,8 @@ const Footer = () => {
></div> ></div>
) : ( ) : (
<div className='custom-footer'> <div className='custom-footer'>
<a <a href='https://github.com/songquanpeng/one-api' target='_blank'>
href='https://github.com/songquanpeng/one-api' {systemName} {import.meta.env.VITE_REACT_APP_VERSION}{' '}
target='_blank'
>
{systemName} {process.env.REACT_APP_VERSION}{' '}
</a> </a>
{' '} {' '}
<a href='https://github.com/songquanpeng' target='_blank'> <a href='https://github.com/songquanpeng' target='_blank'>

View File

@ -2,8 +2,22 @@ import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; import {
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css'; import '../index.css';
// Header Buttons // Header Buttons
@ -11,58 +25,58 @@ let headerButtons = [
{ {
name: '首页', name: '首页',
to: '/', to: '/',
icon: 'home' icon: 'home',
}, },
{ {
name: '渠道', name: '渠道',
to: '/channel', to: '/channel',
icon: 'sitemap', icon: 'sitemap',
admin: true admin: true,
}, },
{ {
name: '令牌', name: '令牌',
to: '/token', to: '/token',
icon: 'key' icon: 'key',
}, },
{ {
name: '兑换', name: '兑换',
to: '/redemption', to: '/redemption',
icon: 'dollar sign', icon: 'dollar sign',
admin: true admin: true,
}, },
{ {
name: '充值', name: '充值',
to: '/topup', to: '/topup',
icon: 'cart' icon: 'cart',
}, },
{ {
name: '用户', name: '用户',
to: '/user', to: '/user',
icon: 'user', icon: 'user',
admin: true admin: true,
}, },
{ {
name: '日志', name: '日志',
to: '/log', to: '/log',
icon: 'book' icon: 'book',
}, },
{ {
name: '设置', name: '设置',
to: '/setting', to: '/setting',
icon: 'setting' icon: 'setting',
}, },
{ {
name: '关于', name: '关于',
to: '/about', to: '/about',
icon: 'info circle' icon: 'info circle',
} },
]; ];
if (localStorage.getItem('chat_link')) { if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, { headerButtons.splice(1, 0, {
name: '聊天', name: '聊天',
to: '/chat', to: '/chat',
icon: 'comments' icon: 'comments',
}); });
} }
@ -120,21 +134,17 @@ const Header = () => {
style={ style={
showSidebar showSidebar
? { ? {
borderBottom: 'none', borderBottom: 'none',
marginBottom: '0', marginBottom: '0',
borderTop: 'none', borderTop: 'none',
height: '51px' height: '51px',
} }
: { borderTop: 'none', height: '52px' } : { borderTop: 'none', height: '52px' }
} }
> >
<Container> <Container>
<Menu.Item as={Link} to='/'> <Menu.Item as={Link} to='/'>
<img <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}> <div style={{ fontSize: '20px' }}>
<b>{systemName}</b> <b>{systemName}</b>
</div> </div>

View File

@ -12,7 +12,8 @@ import {
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess } from '../helpers'; import { API, getLogo, showError, showSuccess, showInfo } from '../helpers';
import Turnstile from 'react-turnstile';
const LoginForm = () => { const LoginForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
@ -24,19 +25,27 @@ const LoginForm = () => {
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs; const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
let navigate = useNavigate(); let navigate = useNavigate();
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
const logo = getLogo(); const logo = getLogo();
useEffect(() => { useEffect(() => {
if (searchParams.get("expired")) { if (searchParams.get('expired')) {
showError('未登录或登录已过期,请重新登录!'); showError('未登录或登录已过期,请重新登录!');
} }
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
if (status) { if (status) {
status = JSON.parse(status); status = JSON.parse(status);
setStatus(status); setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
} }
}, []); }, []);
@ -44,7 +53,13 @@ const LoginForm = () => {
const onGitHubOAuthClicked = () => { const onGitHubOAuthClicked = () => {
window.open( window.open(
`https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email`,
);
};
const onDiscordOAuthClicked = () => {
window.open(
`https://discord.com/oauth2/authorize?response_type=code&client_id=${status.discord_client_id}&redirect_uri=${window.location.origin}/oauth/discord&scope=identify`,
); );
}; };
@ -54,7 +69,7 @@ const LoginForm = () => {
const onSubmitWeChatVerificationCode = async () => { const onSubmitWeChatVerificationCode = async () => {
const res = await API.get( const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}` `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
); );
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@ -76,10 +91,18 @@ const LoginForm = () => {
async function handleSubmit(e) { async function handleSubmit(e) {
setSubmitted(true); setSubmitted(true);
if (username && password) { if (username && password) {
const res = await API.post('/api/user/login', { if (turnstileEnabled && turnstileToken === '') {
username, showInfo('请稍后几秒重试Turnstile 正在检查用户环境!');
password, return;
}); }
const res = await API.post(
`/api/user/login?turnstile=${turnstileToken}`,
{
username,
password,
},
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
userDispatch({ type: 'login', payload: data }); userDispatch({ type: 'login', payload: data });
@ -93,69 +116,83 @@ const LoginForm = () => {
} }
return ( return (
<Grid textAlign="center" style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as="h2" color="" textAlign="center"> <Header as='h2' color='' textAlign='center'>
<Image src={logo} /> 用户登录 <Image src={logo} /> 用户登录
</Header> </Header>
<Form size="large"> <Form size='large'>
<Segment> <Segment>
<Form.Input <Form.Input
fluid fluid
icon="user" icon='user'
iconPosition="left" iconPosition='left'
placeholder="用户名" placeholder='用户名'
name="username" name='username'
value={username} value={username}
onChange={handleChange} onChange={handleChange}
/> />
<Form.Input <Form.Input
fluid fluid
icon="lock" icon='lock'
iconPosition="left" iconPosition='left'
placeholder="密码" placeholder='密码'
name="password" name='password'
type="password" type='password'
value={password} value={password}
onChange={handleChange} onChange={handleChange}
/> />
<Button color="" fluid size="large" onClick={handleSubmit}> {turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button color='' fluid size='large' onClick={handleSubmit}>
登录 登录
</Button> </Button>
</Segment> </Segment>
</Form> </Form>
<Message> <Message>
忘记密码 忘记密码
<Link to="/reset" className="btn btn-link"> <Link to='/reset' className='btn btn-link'>
点击重置 点击重置
</Link> </Link>
没有账户 没有账户
<Link to="/register" className="btn btn-link"> <Link to='/register' className='btn btn-link'>
点击注册 点击注册
</Link> </Link>
</Message> </Message>
{status.github_oauth || status.wechat_login ? ( {status.github_oauth || status.wechat_login || status.discord_oauth ? (
<> <>
<Divider horizontal>Or</Divider> <Divider horizontal>Or</Divider>
{status.github_oauth ? ( {status.discord_oauth && (
<Button <Button
circular circular
color="black" color='blue'
icon="github" icon='discord'
onClick={onDiscordOAuthClicked}
/>
)}
{status.github_oauth && (
<Button
circular
color='black'
icon='github'
onClick={onGitHubOAuthClicked} onClick={onGitHubOAuthClicked}
/> />
) : (
<></>
)} )}
{status.wechat_login ? ( {status.wechat_login && (
<Button <Button
circular circular
color="green" color='green'
icon="wechat" icon='wechat'
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
/> />
) : (
<></>
)} )}
</> </>
) : ( ) : (
@ -175,18 +212,18 @@ const LoginForm = () => {
微信扫码关注公众号输入验证码获取验证码三分钟内有效 微信扫码关注公众号输入验证码获取验证码三分钟内有效
</p> </p>
</div> </div>
<Form size="large"> <Form size='large'>
<Form.Input <Form.Input
fluid fluid
placeholder="验证码" placeholder='验证码'
name="wechat_verification_code" name='wechat_verification_code'
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={handleChange} onChange={handleChange}
/> />
<Button <Button
color="" color=''
fluid fluid
size="large" size='large'
onClick={onSubmitWeChatVerificationCode} onClick={onSubmitWeChatVerificationCode}
> >
登录 登录

View File

@ -1,21 +1,26 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react'; import {
Button,
Form,
Header,
Label,
Pagination,
Segment,
Select,
Table,
} from 'semantic-ui-react';
import { API, isAdmin, showError, timestamp2string } from '../helpers'; import { API, isAdmin, showError, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
const MODE_OPTIONS = [ const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' }, { key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' } { key: 'self', text: '当前用户', value: 'self' },
]; ];
const LOG_OPTIONS = [ const LOG_OPTIONS = [
@ -23,21 +28,46 @@ const LOG_OPTIONS = [
{ key: '1', text: '充值', value: 1 }, { key: '1', text: '充值', value: 1 },
{ key: '2', text: '消费', value: 2 }, { key: '2', text: '消费', value: 2 },
{ key: '3', text: '管理', value: 3 }, { key: '3', text: '管理', value: 3 },
{ key: '4', text: '系统', value: 4 } { key: '4', text: '系统', value: 4 },
]; ];
function renderType(type) { function renderType(type) {
switch (type) { switch (type) {
case 1: case 1:
return <Label basic color='green'> 充值 </Label>; return (
<Label basic color='green'>
{' '}
充值{' '}
</Label>
);
case 2: case 2:
return <Label basic color='olive'> 消费 </Label>; return (
<Label basic color='olive'>
{' '}
消费{' '}
</Label>
);
case 3: case 3:
return <Label basic color='orange'> 管理 </Label>; return (
<Label basic color='orange'>
{' '}
管理{' '}
</Label>
);
case 4: case 4:
return <Label basic color='purple'> 系统 </Label>; return (
<Label basic color='purple'>
{' '}
系统{' '}
</Label>
);
default: default:
return <Label basic color='black'> 未知 </Label>; return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
} }
} }
@ -55,13 +85,14 @@ const LogsTable = () => {
token_name: '', token_name: '',
model_name: '', model_name: '',
start_timestamp: timestamp2string(0), start_timestamp: timestamp2string(0),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600) end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
}); });
const { username, token_name, model_name, start_timestamp, end_timestamp } = inputs; const { username, token_name, model_name, start_timestamp, end_timestamp } =
inputs;
const [stat, setStat] = useState({ const [stat, setStat] = useState({
quota: 0, quota: 0,
token: 0 token: 0,
}); });
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
@ -71,7 +102,9 @@ const LogsTable = () => {
const getLogSelfStat = async () => { const getLogSelfStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`); let res = await API.get(
`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`,
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setStat(data); setStat(data);
@ -83,7 +116,9 @@ const LogsTable = () => {
const getLogStat = async () => { const getLogStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`); let res = await API.get(
`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`,
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setStat(data); setStat(data);
@ -129,7 +164,7 @@ const LogsTable = () => {
const refresh = async () => { const refresh = async () => {
setLoading(true); setLoading(true);
setActivePage(1) setActivePage(1);
await loadLogs(0); await loadLogs(0);
if (isAdminUser) { if (isAdminUser) {
getLogStat().then(); getLogStat().then();
@ -169,7 +204,7 @@ const LogsTable = () => {
if (logs.length === 0) return; if (logs.length === 0) return;
setLoading(true); setLoading(true);
let sortedLogs = [...logs]; let sortedLogs = [...logs];
if (typeof sortedLogs[0][key] === 'string'){ if (typeof sortedLogs[0][key] === 'string') {
sortedLogs.sort((a, b) => { sortedLogs.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]); return ('' + a[key]).localeCompare(b[key]);
}); });
@ -190,28 +225,61 @@ const LogsTable = () => {
return ( return (
<> <>
<Segment> <Segment>
<Header as='h3'>使用明细总消耗额度{renderQuota(stat.quota)}</Header> <Header as='h3'>
使用明细总消耗额度{renderQuota(stat.quota)}
</Header>
<Form> <Form>
<Form.Group> <Form.Group>
{ {isAdminUser && (
isAdminUser && ( <Form.Input
<Form.Input fluid label={'用户名称'} width={2} value={username} fluid
placeholder={'可选值'} name='username' label={'用户名称'}
onChange={handleInputChange} /> width={2}
) value={username}
} placeholder={'可选值'}
<Form.Input fluid label={'令牌名称'} width={isAdminUser ? 2 : 3} value={token_name} name='username'
placeholder={'可选值'} name='token_name' onChange={handleInputChange} /> onChange={handleInputChange}
<Form.Input fluid label='模型名称' width={isAdminUser ? 2 : 3} value={model_name} placeholder='可选值' />
name='model_name' )}
onChange={handleInputChange} /> <Form.Input
<Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local' fluid
name='start_timestamp' label={'令牌名称'}
onChange={handleInputChange} /> width={isAdminUser ? 2 : 3}
<Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local' value={token_name}
name='end_timestamp' placeholder={'可选值'}
onChange={handleInputChange} /> name='token_name'
<Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button> onChange={handleInputChange}
/>
<Form.Input
fluid
label='模型名称'
width={isAdminUser ? 2 : 3}
value={model_name}
placeholder='可选值'
name='model_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='起始时间'
width={4}
value={start_timestamp}
type='datetime-local'
name='start_timestamp'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='结束时间'
width={4}
value={end_timestamp}
type='datetime-local'
name='end_timestamp'
onChange={handleInputChange}
/>
<Form.Button fluid label='操作' width={2} onClick={refresh}>
查询
</Form.Button>
</Form.Group> </Form.Group>
</Form> </Form>
<Table basic compact size='small'> <Table basic compact size='small'>
@ -226,8 +294,8 @@ const LogsTable = () => {
> >
时间 时间
</Table.HeaderCell> </Table.HeaderCell>
{ {isAdminUser && (
isAdminUser && <Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortLog('username'); sortLog('username');
@ -236,7 +304,7 @@ const LogsTable = () => {
> >
用户 用户
</Table.HeaderCell> </Table.HeaderCell>
} )}
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
@ -307,24 +375,42 @@ const LogsTable = () => {
{logs {logs
.slice( .slice(
(activePage - 1) * ITEMS_PER_PAGE, (activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE activePage * ITEMS_PER_PAGE,
) )
.map((log, idx) => { .map((log, idx) => {
if (log.deleted) return <></>; if (log.deleted) return <></>;
return ( return (
<Table.Row key={log.created_at}> <Table.Row key={log.created_at}>
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell> <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
{ {isAdminUser && (
isAdminUser && ( <Table.Cell>
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell> {log.username ? <Label>{log.username}</Label> : ''}
) </Table.Cell>
} )}
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell> <Table.Cell>
{log.token_name ? (
<Label basic>{log.token_name}</Label>
) : (
''
)}
</Table.Cell>
<Table.Cell>{renderType(log.type)}</Table.Cell> <Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell> <Table.Cell>
<Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell> {log.model_name ? (
<Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell> <Label basic>{log.model_name}</Label>
<Table.Cell>{log.quota ? renderQuota(log.quota, 6) : ''}</Table.Cell> ) : (
''
)}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''}
</Table.Cell>
<Table.Cell>{log.content}</Table.Cell> <Table.Cell>{log.content}</Table.Cell>
</Table.Row> </Table.Row>
); );
@ -344,7 +430,9 @@ const LogsTable = () => {
setLogType(value); setLogType(value);
}} }}
/> />
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
<Pagination <Pagination
floated='right' floated='right'
activePage={activePage} activePage={activePage}

View File

@ -20,6 +20,7 @@ const OperationSetting = () => {
DisplayInCurrencyEnabled: '', DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: '', DisplayTokenStatEnabled: '',
ApproximateTokenEnabled: '', ApproximateTokenEnabled: '',
RetryTimes: 0,
}); });
const [originInputs, setOriginInputs] = useState({}); const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
@ -53,7 +54,7 @@ const OperationSetting = () => {
} }
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -75,11 +76,22 @@ const OperationSetting = () => {
const submitConfig = async (group) => { const submitConfig = async (group) => {
switch (group) { switch (group) {
case 'monitor': case 'monitor':
if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { if (
await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); originInputs['ChannelDisableThreshold'] !==
inputs.ChannelDisableThreshold
) {
await updateOption(
'ChannelDisableThreshold',
inputs.ChannelDisableThreshold,
);
} }
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) { if (
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold); originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold
) {
await updateOption(
'QuotaRemindThreshold',
inputs.QuotaRemindThreshold,
);
} }
break; break;
case 'ratio': case 'ratio':
@ -122,6 +134,9 @@ const OperationSetting = () => {
if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) { if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) {
await updateOption('QuotaPerUnit', inputs.QuotaPerUnit); await updateOption('QuotaPerUnit', inputs.QuotaPerUnit);
} }
if (originInputs['RetryTimes'] !== inputs.RetryTimes) {
await updateOption('RetryTimes', inputs.RetryTimes);
}
break; break;
} }
}; };
@ -130,10 +145,8 @@ const OperationSetting = () => {
<Grid columns={1}> <Grid columns={1}>
<Grid.Column> <Grid.Column>
<Form loading={loading}> <Form loading={loading}>
<Header as='h3'> <Header as='h3'>通用设置</Header>
通用设置 <Form.Group widths={4}>
</Header>
<Form.Group widths={3}>
<Form.Input <Form.Input
label='充值链接' label='充值链接'
name='TopUpLink' name='TopUpLink'
@ -162,6 +175,17 @@ const OperationSetting = () => {
step='0.01' step='0.01'
placeholder='一单位货币能兑换的额度' placeholder='一单位货币能兑换的额度'
/> />
<Form.Input
label='失败重试次数'
name='RetryTimes'
type={'number'}
step='1'
min='0'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.RetryTimes}
placeholder='失败重试次数'
/>
</Form.Group> </Form.Group>
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
@ -189,13 +213,15 @@ const OperationSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button
submitConfig('general').then(); onClick={() => {
}}>保存通用设置</Form.Button> submitConfig('general').then();
}}
>
保存通用设置
</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>监控设置</Header>
监控设置
</Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='最长响应时间' label='最长响应时间'
@ -226,13 +252,15 @@ const OperationSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button
submitConfig('monitor').then(); onClick={() => {
}}>保存监控设置</Form.Button> submitConfig('monitor').then();
}}
>
保存监控设置
</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>额度设置</Header>
额度设置
</Header>
<Form.Group widths={4}> <Form.Group widths={4}>
<Form.Input <Form.Input
label='新用户初始额度' label='新用户初始额度'
@ -275,13 +303,15 @@ const OperationSetting = () => {
placeholder='例如1000' placeholder='例如1000'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button
submitConfig('quota').then(); onClick={() => {
}}>保存额度设置</Form.Button> submitConfig('quota').then();
}}
>
保存额度设置
</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>倍率设置</Header>
倍率设置
</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='模型倍率' label='模型倍率'
@ -304,9 +334,13 @@ const OperationSetting = () => {
placeholder='为一个 JSON 文本,键为分组名称,值为倍率' placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button
submitConfig('ratio').then(); onClick={() => {
}}>保存倍率设置</Form.Button> submitConfig('ratio').then();
}}
>
保存倍率设置
</Form.Button>
</Form> </Form>
</Grid.Column> </Grid.Column>
</Grid> </Grid>

View File

@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react'; import {
Button,
Divider,
Form,
Grid,
Header,
Message,
Modal,
} from 'semantic-ui-react';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../helpers';
import { marked } from 'marked'; import { marked } from 'marked';
@ -10,13 +18,13 @@ const OtherSetting = () => {
About: '', About: '',
SystemName: '', SystemName: '',
Logo: '', Logo: '',
HomePageContent: '' HomePageContent: '',
}); });
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({ const [updateData, setUpdateData] = useState({
tag_name: '', tag_name: '',
content: '' content: '',
}); });
const getOptions = async () => { const getOptions = async () => {
@ -43,7 +51,7 @@ const OtherSetting = () => {
setLoading(true); setLoading(true);
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -83,21 +91,20 @@ const OtherSetting = () => {
}; };
const openGitHubRelease = () => { const openGitHubRelease = () => {
window.location = window.location = 'https://github.com/songquanpeng/one-api/releases/latest';
'https://github.com/songquanpeng/one-api/releases/latest';
}; };
const checkUpdate = async () => { const checkUpdate = async () => {
const res = await API.get( const res = await API.get(
'https://api.github.com/repos/songquanpeng/one-api/releases/latest' 'https://api.github.com/repos/songquanpeng/one-api/releases/latest',
); );
const { tag_name, body } = res.data; const { tag_name, body } = res.data;
if (tag_name === process.env.REACT_APP_VERSION) { if (tag_name === import.meta.env.VITE_REACT_APP_VERSION) {
showSuccess(`已是最新版本:${tag_name}`); showSuccess(`已是最新版本:${tag_name}`);
} else { } else {
setUpdateData({ setUpdateData({
tag_name: tag_name, tag_name: tag_name,
content: marked.parse(body) content: marked.parse(body),
}); });
setShowUpdateModal(true); setShowUpdateModal(true);
} }
@ -153,7 +160,9 @@ const OtherSetting = () => {
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button> <Form.Button onClick={() => submitOption('HomePageContent')}>
保存首页内容
</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='关于' label='关于'
@ -165,7 +174,10 @@ const OtherSetting = () => {
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitAbout}>保存关于</Form.Button> <Form.Button onClick={submitAbout}>保存关于</Form.Button>
<Message>移除 One API 的版权标识必须首先获得授权项目维护需要花费大量精力如果本项目对你有意义请主动支持本项目</Message> <Message>
移除 One API
的版权标识必须首先获得授权项目维护需要花费大量精力如果本项目对你有意义请主动支持本项目
</Message>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='页脚' label='页脚'

View File

@ -1,6 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => { const PasswordResetConfirm = () => {

View File

@ -38,7 +38,7 @@ const PasswordResetForm = () => {
} }
setLoading(true); setLoading(true);
const res = await API.get( const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}` `/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {

View File

@ -1,18 +1,40 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useContext } from 'react';
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; import {
import { Link } from 'react-router-dom'; Button,
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; Divider,
Form,
Header,
Image,
Message,
Modal,
Label,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User';
const PersonalSetting = () => { const PersonalSetting = () => {
const [userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
wechat_verification_code: '', wechat_verification_code: '',
email_verification_code: '', email_verification_code: '',
email: '', email: '',
self_account_deletion_confirmation: '',
}); });
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
const [showEmailBindModal, setShowEmailBindModal] = useState(false); const [showEmailBindModal, setShowEmailBindModal] = useState(false);
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState(''); const [turnstileToken, setTurnstileToken] = useState('');
@ -57,10 +79,30 @@ const PersonalSetting = () => {
} }
}; };
const deleteAccount = async () => {
if (inputs.self_account_deletion_confirmation !== 'CONFIRM') {
showError('请确认您要删除账户!');
return;
}
const res = await API.delete('/api/user/self');
const { success, message } = res.data;
if (success) {
showSuccess('账户已删除!');
await API.get('/api/user/logout');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
} else {
showError(message);
}
};
const bindWeChat = async () => { const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return; if (inputs.wechat_verification_code === '') return;
const res = await API.get( const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}` `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -73,7 +115,13 @@ const PersonalSetting = () => {
const openGitHubOAuth = () => { const openGitHubOAuth = () => {
window.open( window.open(
`https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email`,
);
};
const openDiscordOAuth = () => {
window.open(
`https://discord.com/api/oauth2/authorize?client_id=${status.discord_client_id}&scope=identify%20email&response_type=code&redirect_uri=${window.location.origin}/oauth/discord`,
); );
}; };
@ -85,7 +133,7 @@ const PersonalSetting = () => {
} }
setLoading(true); setLoading(true);
const res = await API.get( const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -100,7 +148,7 @@ const PersonalSetting = () => {
if (inputs.email_verification_code === '') return; if (inputs.email_verification_code === '') return;
setLoading(true); setLoading(true);
const res = await API.get( const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}` `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -116,26 +164,33 @@ const PersonalSetting = () => {
<div style={{ lineHeight: '40px' }}> <div style={{ lineHeight: '40px' }}>
<Header as='h3'>通用设置</Header> <Header as='h3'>通用设置</Header>
<Message> <Message>
注意此处生成的令牌用于系统管理而非用于请求 OpenAI 相关的服务请知悉 注意此处生成的令牌用于系统管理而非用于请求 OpenAI
相关的服务请知悉
</Message> </Message>
<Button as={Link} to={`/user/edit/`}> <Button as={Link} to={`/user/edit/`}>
更新个人信息 更新个人信息
</Button> </Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button> <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
<Button onClick={getAffLink}>复制邀请链接</Button> <Button onClick={getAffLink}>复制邀请链接</Button>
<Button
onClick={() => {
setShowAccountDeleteModal(true);
}}
color='red'
>
删除个人账户
</Button>
<Divider /> <Divider />
<Header as='h3'>账号绑定</Header> <Header as='h3'>账号绑定</Header>
{ {status.wechat_login && (
status.wechat_login && ( <Button
<Button onClick={() => {
onClick={() => { setShowWeChatBindModal(true);
setShowWeChatBindModal(true); }}
}} >
> 绑定微信账号
绑定微信账号 </Button>
</Button> )}
)
}
<Modal <Modal
onClose={() => setShowWeChatBindModal(false)} onClose={() => setShowWeChatBindModal(false)}
onOpen={() => setShowWeChatBindModal(true)} onOpen={() => setShowWeChatBindModal(true)}
@ -165,11 +220,12 @@ const PersonalSetting = () => {
</Modal.Description> </Modal.Description>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
{ {status.github_oauth && (
status.github_oauth && ( <Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button>
<Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button> )}
) {status.discord_oauth && (
} <Button onClick={openDiscordOAuth}>绑定 Discord 账号</Button>
)}
<Button <Button
onClick={() => { onClick={() => {
setShowEmailBindModal(true); setShowEmailBindModal(true);
@ -230,6 +286,48 @@ const PersonalSetting = () => {
</Modal.Description> </Modal.Description>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
<Modal
onClose={() => setShowAccountDeleteModal(false)}
onOpen={() => setShowAccountDeleteModal(true)}
open={showAccountDeleteModal}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>您是否确认删除自己的帐户?</Modal.Header>
<Modal.Content>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
label='请输入 "CONFIRM" 以删除您的帐户。'
placeholder='确认文字'
name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation}
onChange={handleInputChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color='red'
fluid
size='large'
onClick={deleteAccount}
loading={loading}
>
删除
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</div> </div>
); );
}; };

View File

@ -2,7 +2,6 @@ import { Navigate } from 'react-router-dom';
import { history } from '../helpers'; import { history } from '../helpers';
function PrivateRoute({ children }) { function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) { if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />; return <Navigate to='/login' state={{ from: history.location }} />;
@ -10,4 +9,4 @@ function PrivateRoute({ children }) {
return children; return children;
} }
export { PrivateRoute }; export { PrivateRoute };

View File

@ -1,29 +1,59 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Message,
Pagination,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; 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 { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
function renderStatus(status) { function renderStatus(status) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>未使用</Label>; return (
<Label basic color='green'>
未使用
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{' '}
已禁用{' '}
</Label>
);
case 3: case 3:
return <Label basic color='grey'> 已使用 </Label>; return (
<Label basic color='grey'>
{' '}
已使用{' '}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{' '}
未知状态{' '}
</Label>
);
} }
} }
@ -110,7 +140,9 @@ const RedemptionsTable = () => {
return; return;
} }
setSearching(true); 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; const { success, message, data } = res.data;
if (success) { if (success) {
setRedemptions(data); setRedemptions(data);
@ -212,18 +244,26 @@ const RedemptionsTable = () => {
{redemptions {redemptions
.slice( .slice(
(activePage - 1) * ITEMS_PER_PAGE, (activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE activePage * ITEMS_PER_PAGE,
) )
.map((redemption, idx) => { .map((redemption, idx) => {
if (redemption.deleted) return <></>; if (redemption.deleted) return <></>;
return ( return (
<Table.Row key={redemption.id}> <Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell> <Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell> <Table.Cell>
{redemption.name ? redemption.name : '无'}
</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell> <Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell> <Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell> <Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell> {renderTimestamp(redemption.created_time)}
</Table.Cell>
<Table.Cell>
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: '尚未兑换'}{' '}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
@ -233,7 +273,9 @@ const RedemptionsTable = () => {
if (await copy(redemption.key)) { if (await copy(redemption.key)) {
showSuccess('已复制到剪贴板!'); showSuccess('已复制到剪贴板!');
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。') showWarning(
'无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。',
);
setSearchKeyword(redemption.key); setSearchKeyword(redemption.key);
} }
}} }}
@ -251,12 +293,12 @@ const RedemptionsTable = () => {
</Button> </Button>
<Button <Button
size={'small'} size={'small'}
disabled={redemption.status === 3} // used disabled={redemption.status === 3} // used
onClick={() => { onClick={() => {
manageRedemption( manageRedemption(
redemption.id, redemption.id,
redemption.status === 1 ? 'disable' : 'enable', redemption.status === 1 ? 'disable' : 'enable',
idx idx,
); );
}} }}
> >
@ -279,7 +321,12 @@ const RedemptionsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='8'> <Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/redemption/add' loading={loading}> <Button
size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
添加新的兑换码 添加新的兑换码
</Button> </Button>
<Pagination <Pagination

View File

@ -73,7 +73,7 @@ const RegisterForm = () => {
inputs.aff_code = affCode; inputs.aff_code = affCode;
const res = await API.post( const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`, `/api/user/register?turnstile=${turnstileToken}`,
inputs inputs,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -94,7 +94,7 @@ const RegisterForm = () => {
} }
setLoading(true); setLoading(true);
const res = await API.get( const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {

View File

@ -8,8 +8,11 @@ const SystemSetting = () => {
PasswordRegisterEnabled: '', PasswordRegisterEnabled: '',
EmailVerificationEnabled: '', EmailVerificationEnabled: '',
GitHubOAuthEnabled: '', GitHubOAuthEnabled: '',
DiscordOAuthEnabled: '',
GitHubClientId: '', GitHubClientId: '',
GitHubClientSecret: '', GitHubClientSecret: '',
DiscordClientId: '',
DiscordClientSecret: '',
Notice: '', Notice: '',
SMTPServer: '', SMTPServer: '',
SMTPPort: '', SMTPPort: '',
@ -56,6 +59,7 @@ const SystemSetting = () => {
case 'PasswordRegisterEnabled': case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled': case 'EmailVerificationEnabled':
case 'GitHubOAuthEnabled': case 'GitHubOAuthEnabled':
case 'DiscordOAuthEnabled':
case 'WeChatAuthEnabled': case 'WeChatAuthEnabled':
case 'TurnstileCheckEnabled': case 'TurnstileCheckEnabled':
case 'RegisterEnabled': case 'RegisterEnabled':
@ -66,7 +70,7 @@ const SystemSetting = () => {
} }
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -82,6 +86,8 @@ const SystemSetting = () => {
name === 'Notice' || name === 'Notice' ||
name.startsWith('SMTP') || name.startsWith('SMTP') ||
name === 'ServerAddress' || name === 'ServerAddress' ||
name === 'DiscordClientId' ||
name === 'DiscordClientSecret' ||
name === 'GitHubClientId' || name === 'GitHubClientId' ||
name === 'GitHubClientSecret' || name === 'GitHubClientSecret' ||
name === 'WeChatServerAddress' || name === 'WeChatServerAddress' ||
@ -129,7 +135,7 @@ const SystemSetting = () => {
if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) { if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
await updateOption( await updateOption(
'WeChatServerAddress', 'WeChatServerAddress',
removeTrailingSlash(inputs.WeChatServerAddress) removeTrailingSlash(inputs.WeChatServerAddress),
); );
} }
if ( if (
@ -138,7 +144,7 @@ const SystemSetting = () => {
) { ) {
await updateOption( await updateOption(
'WeChatAccountQRCodeImageURL', 'WeChatAccountQRCodeImageURL',
inputs.WeChatAccountQRCodeImageURL inputs.WeChatAccountQRCodeImageURL,
); );
} }
if ( if (
@ -161,6 +167,18 @@ const SystemSetting = () => {
} }
}; };
const submitDiscordOAuth = async () => {
if (originInputs['DiscordClientId'] !== inputs.DiscordClientId) {
await updateOption('DiscordClientId', inputs.DiscordClientId);
}
if (
originInputs['DiscordClientSecret'] !== inputs.DiscordClientSecret &&
inputs.DiscordClientSecret !== ''
) {
await updateOption('DiscordClientSecret', inputs.DiscordClientSecret);
}
};
const submitTurnstile = async () => { const submitTurnstile = async () => {
if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) { if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey); await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
@ -177,49 +195,55 @@ const SystemSetting = () => {
<Grid columns={1}> <Grid columns={1}>
<Grid.Column> <Grid.Column>
<Form loading={loading}> <Form loading={loading}>
<Header as='h3'>通用设置</Header> <Header as='h3'>General Settings</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='服务器地址' label='Server Address'
placeholder='例如https://yourdomain.com' placeholder='For examplehttps://yourdomain.com'
value={inputs.ServerAddress} value={inputs.ServerAddress}
name='ServerAddress' name='ServerAddress'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitServerAddress}> <Form.Button onClick={submitServerAddress}>
更新服务器地址 Update Server Address
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'>配置登录注册</Header> <Header as='h3'>Configure Login/Registration</Header>
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
checked={inputs.PasswordLoginEnabled === 'true'} checked={inputs.PasswordLoginEnabled === 'true'}
label='允许通过密码进行登录' label='Allow login via password'
name='PasswordLoginEnabled' name='PasswordLoginEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.PasswordRegisterEnabled === 'true'} checked={inputs.PasswordRegisterEnabled === 'true'}
label='允许通过密码进行注册' label='Allow registration via password'
name='PasswordRegisterEnabled' name='PasswordRegisterEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.EmailVerificationEnabled === 'true'} checked={inputs.EmailVerificationEnabled === 'true'}
label='通过密码注册时需要进行邮箱验证' label='Email verification is required when registering via password'
name='EmailVerificationEnabled' name='EmailVerificationEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.GitHubOAuthEnabled === 'true'} checked={inputs.GitHubOAuthEnabled === 'true'}
label='允许通过 GitHub 账户登录 & 注册' label='Allow login & registration via GitHub account'
name='GitHubOAuthEnabled' name='GitHubOAuthEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox
checked={inputs.DiscordOAuthEnabled === 'true'}
label='允许通过 Discord 账户登录和注册'
name='DiscordOAuthEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox <Form.Checkbox
checked={inputs.WeChatAuthEnabled === 'true'} checked={inputs.WeChatAuthEnabled === 'true'}
label='允许通过微信登录 & 注册' label='Allow login & registration via WeChat'
name='WeChatAuthEnabled' name='WeChatAuthEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
@ -227,82 +251,125 @@ const SystemSetting = () => {
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
checked={inputs.RegisterEnabled === 'true'} checked={inputs.RegisterEnabled === 'true'}
label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册' label='Allow new user registration (if this option is off, new users will not be able to register in any way'
name='RegisterEnabled' name='RegisterEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.TurnstileCheckEnabled === 'true'} checked={inputs.TurnstileCheckEnabled === 'true'}
label='启用 Turnstile 用户校验' label='Enable Turnstile user verification'
name='TurnstileCheckEnabled' name='TurnstileCheckEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 SMTP Configure SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader> <Header.Subheader>
To support the system email sending
</Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='SMTP 服务器地址' label='SMTP Server Address'
name='SMTPServer' name='SMTPServer'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.SMTPServer} value={inputs.SMTPServer}
placeholder='例如:smtp.qq.com' placeholder='For example: smtp.qq.com'
/> />
<Form.Input <Form.Input
label='SMTP 端口' label='SMTP Port'
name='SMTPPort' name='SMTPPort'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.SMTPPort} value={inputs.SMTPPort}
placeholder='默认: 587' placeholder='Default: 587'
/> />
<Form.Input <Form.Input
label='SMTP 账户' label='SMTP Account'
name='SMTPAccount' name='SMTPAccount'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.SMTPAccount} value={inputs.SMTPAccount}
placeholder='通常是邮箱地址' placeholder='Usually an email address'
/> />
</Form.Group> </Form.Group>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='SMTP 发送者邮箱' label='SMTP Sender email'
name='SMTPFrom' name='SMTPFrom'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.SMTPFrom} value={inputs.SMTPFrom}
placeholder='通常和邮箱地址保持一致' placeholder='Usually consistent with the email address'
/> />
<Form.Input <Form.Input
label='SMTP 访问凭证' label='SMTP Access Credential'
name='SMTPToken' name='SMTPToken'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.SMTPToken} value={inputs.SMTPToken}
placeholder='敏感信息不会发送到前端显示' placeholder='Sensitive information will not be displayed in the frontend'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button> <Form.Button onClick={submitSMTP}>Save SMTP Settings</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 GitHub OAuth App Configure Discord OAuth App
<Header.Subheader> <Header.Subheader>
用以支持通过 GitHub 进行登录注册 To support login & registration via GitHub
<a href='https://github.com/settings/developers' target='_blank'> <a
点击此处 href='https://discord.com/developers/applications'
target='_blank'
>
Click here
</a> </a>
管理你的 GitHub OAuth App Manage your Discord OAuth App
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Message> <Message>
Homepage URL <code>{inputs.ServerAddress}</code> Fill in the Homepage URL <code>{inputs.ServerAddress}</code>
Authorization callback URL {' '} Fill in the Authorization callback URL{' '}
<code>{`${inputs.ServerAddress}/oauth/discord`}</code>
</Message>
<Form.Group widths={3}>
<Form.Input
label='Discord Client ID'
name='DiscordClientId'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.DiscordClientId}
placeholder='Enter the ID of your registered Discord OAuth APP'
/>
<Form.Input
label='Discord Client Secret'
name='DiscordClientSecret'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.DiscordClientSecret}
placeholder='Sensitive information will not be displayed in the frontend'
/>
</Form.Group>
<Form.Button onClick={submitDiscordOAuth}>
Save Discord OAuth Settings
</Form.Button>
<Divider />
<Header as='h3'>
Configure GitHub OAuth App
<Header.Subheader>
To support login & registration via GitHub
<a href='https://github.com/settings/developers' target='_blank'>
Click here
</a>
Manage your GitHub OAuth App
</Header.Subheader>
</Header>
<Message>
Fill in the Homepage URL <code>{inputs.ServerAddress}</code>
Fill in the Authorization callback URL{' '}
<code>{`${inputs.ServerAddress}/oauth/github`}</code> <code>{`${inputs.ServerAddress}/oauth/github`}</code>
</Message> </Message>
<Form.Group widths={3}> <Form.Group widths={3}>
@ -312,7 +379,7 @@ const SystemSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.GitHubClientId} value={inputs.GitHubClientId}
placeholder='输入你注册的 GitHub OAuth APP 的 ID' placeholder='Enter your registered GitHub OAuth APP ID'
/> />
<Form.Input <Form.Input
label='GitHub Client Secret' label='GitHub Client Secret'
@ -321,65 +388,66 @@ const SystemSetting = () => {
type='password' type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.GitHubClientSecret} value={inputs.GitHubClientSecret}
placeholder='敏感信息不会发送到前端显示' placeholder='Sensitive information will not be displayed in the frontend'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitGitHubOAuth}> <Form.Button onClick={submitGitHubOAuth}>
保存 GitHub OAuth 设置 Save GitHub OAuth Settings
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 WeChat Server Configure WeChat Server
<Header.Subheader> <Header.Subheader>
用以支持通过微信进行登录注册 To support login & registration via WeChat
<a <a
href='https://github.com/songquanpeng/wechat-server' href='https://github.com/songquanpeng/wechat-server'
target='_blank' target='_blank'
> >
点击此处 Click here
</a> </a>
了解 WeChat Server Learn about WeChat Server
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='WeChat Server 服务器地址' label='WeChat Server Server Address'
name='WeChatServerAddress' name='WeChatServerAddress'
placeholder='例如https://yourdomain.com' placeholder='For examplehttps://yourdomain.com'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatServerAddress} value={inputs.WeChatServerAddress}
/> />
<Form.Input <Form.Input
label='WeChat Server 访问凭证' label='WeChat Server Access Credential'
name='WeChatServerToken' name='WeChatServerToken'
type='password' type='password'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatServerToken} value={inputs.WeChatServerToken}
placeholder='敏感信息不会发送到前端显示' placeholder='Sensitive information will not be displayed in the frontend'
/> />
<Form.Input <Form.Input
label='微信公众号二维码图片链接' label='WeChat Public Account QR Code Image Link'
name='WeChatAccountQRCodeImageURL' name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL} value={inputs.WeChatAccountQRCodeImageURL}
placeholder='输入一个图片链接' placeholder='Enter an image link'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitWeChat}> <Form.Button onClick={submitWeChat}>
保存 WeChat Server 设置 Save WeChat Server Settings
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 Turnstile Configure Turnstile
<Header.Subheader> <Header.Subheader>
用以支持用户校验 To support user verification
<a href='https://dash.cloudflare.com/' target='_blank'> <a href='https://dash.cloudflare.com/' target='_blank'>
点击此处 Click here
</a> </a>
管理你的 Turnstile Sites推荐选择 Invisible Widget Type Manage your Turnstile Sites, recommend selecting Invisible Widget
Type
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={3}>
@ -389,7 +457,7 @@ const SystemSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.TurnstileSiteKey} value={inputs.TurnstileSiteKey}
placeholder='输入你注册的 Turnstile Site Key' placeholder='Enter your registered Turnstile Site Key'
/> />
<Form.Input <Form.Input
label='Turnstile Secret Key' label='Turnstile Secret Key'
@ -398,11 +466,11 @@ const SystemSetting = () => {
type='password' type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.TurnstileSecretKey} value={inputs.TurnstileSecretKey}
placeholder='敏感信息不会发送到前端显示' placeholder='Sensitive information will not be displayed in the frontend'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitTurnstile}> <Form.Button onClick={submitTurnstile}>
保存 Turnstile 设置 Save Turnstile Settings
</Form.Button> </Form.Button>
</Form> </Form>
</Grid.Column> </Grid.Column>

View File

@ -1,31 +1,66 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Modal, Pagination, Popup, Table } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Modal,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; 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 { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
function renderStatus(status) { function renderStatus(status) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>已启用</Label>; return (
<Label basic color='green'>
已启用
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{' '}
已禁用{' '}
</Label>
);
case 3: case 3:
return <Label basic color='yellow'> 已过期 </Label>; return (
<Label basic color='yellow'>
{' '}
已过期{' '}
</Label>
);
case 4: case 4:
return <Label basic color='grey'> 已耗尽 </Label>; return (
<Label basic color='grey'>
{' '}
已耗尽{' '}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{' '}
未知状态{' '}
</Label>
);
} }
} }
@ -68,7 +103,7 @@ const TokensTable = () => {
const refresh = async () => { const refresh = async () => {
setLoading(true); setLoading(true);
await loadTokens(activePage - 1); await loadTokens(activePage - 1);
} };
useEffect(() => { useEffect(() => {
loadTokens(0) loadTokens(0)
@ -221,7 +256,7 @@ const TokensTable = () => {
{tokens {tokens
.slice( .slice(
(activePage - 1) * ITEMS_PER_PAGE, (activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE activePage * ITEMS_PER_PAGE,
) )
.map((token, idx) => { .map((token, idx) => {
if (token.deleted) return <></>; if (token.deleted) return <></>;
@ -230,20 +265,30 @@ const TokensTable = () => {
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell> <Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell> <Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell> <Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
<Table.Cell>{token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)}</Table.Cell> <Table.Cell>
{token.unlimited_quota
? '无限制'
: renderQuota(token.remain_quota, 2)}
</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell> <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell> <Table.Cell>
{token.expired_time === -1
? '永不过期'
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
size={'small'} size={'small'}
positive positive
onClick={async () => { onClick={async () => {
let key = "sk-" + token.key; let key = 'sk-' + token.key;
if (await copy(key)) { if (await copy(key)) {
showSuccess('已复制到剪贴板!'); showSuccess('已复制到剪贴板!');
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); showWarning(
'无法复制到剪贴板,请手动复制,已将令牌填入搜索框。',
);
setSearchKeyword(key); setSearchKeyword(key);
} }
}} }}
@ -275,7 +320,7 @@ const TokensTable = () => {
manageToken( manageToken(
token.id, token.id,
token.status === 1 ? 'disable' : 'enable', token.status === 1 ? 'disable' : 'enable',
idx idx,
); );
}} }}
> >
@ -301,7 +346,9 @@ const TokensTable = () => {
<Button size='small' as={Link} to='/token/add' loading={loading}> <Button size='small' as={Link} to='/token/add' loading={loading}>
添加新的令牌 添加新的令牌
</Button> </Button>
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
<Pagination <Pagination
floated='right' floated='right'
activePage={activePage} activePage={activePage}

View File

@ -1,10 +1,22 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render'; import {
renderGroup,
renderNumber,
renderQuota,
renderText,
} from '../helpers/render';
function renderRole(role) { function renderRole(role) {
switch (role) { switch (role) {
@ -65,7 +77,7 @@ const UsersTable = () => {
(async () => { (async () => {
const res = await API.post('/api/user/manage', { const res = await API.post('/api/user/manage', {
username, username,
action action,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@ -215,7 +227,7 @@ const UsersTable = () => {
{users {users
.slice( .slice(
(activePage - 1) * ITEMS_PER_PAGE, (activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE activePage * ITEMS_PER_PAGE,
) )
.map((user, idx) => { .map((user, idx) => {
if (user.deleted) return <></>; if (user.deleted) return <></>;
@ -226,7 +238,9 @@ const UsersTable = () => {
<Popup <Popup
content={user.email ? user.email : '未绑定邮箱地址'} content={user.email ? user.email : '未绑定邮箱地址'}
key={user.username} key={user.username}
header={user.display_name ? user.display_name : user.username} header={
user.display_name ? user.display_name : user.username
}
trigger={<span>{renderText(user.username, 10)}</span>} trigger={<span>{renderText(user.username, 10)}</span>}
hoverable hoverable
/> />
@ -236,9 +250,22 @@ const UsersTable = () => {
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/} {/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}
{/*</Table.Cell>*/} {/*</Table.Cell>*/}
<Table.Cell> <Table.Cell>
<Popup content='剩余额度' trigger={<Label basic>{renderQuota(user.quota)}</Label>} /> <Popup
<Popup content='已用额度' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} /> content='剩余额度'
<Popup content='请求次数' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} /> trigger={<Label basic>{renderQuota(user.quota)}</Label>}
/>
<Popup
content='已用额度'
trigger={
<Label basic>{renderQuota(user.used_quota)}</Label>
}
/>
<Popup
content='请求次数'
trigger={
<Label basic>{renderNumber(user.request_count)}</Label>
}
/>
</Table.Cell> </Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell> <Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell> <Table.Cell>{renderStatus(user.status)}</Table.Cell>
@ -266,7 +293,11 @@ const UsersTable = () => {
</Button> </Button>
<Popup <Popup
trigger={ trigger={
<Button size='small' negative disabled={user.role === 100}> <Button
size='small'
negative
disabled={user.role === 100}
>
删除 删除
</Button> </Button>
} }
@ -289,7 +320,7 @@ const UsersTable = () => {
manageUser( manageUser(
user.username, user.username,
user.status === 1 ? 'disable' : 'enable', user.status === 1 ? 'disable' : 'enable',
idx idx,
); );
}} }}
disabled={user.role === 100} disabled={user.role === 100}

View File

@ -10,5 +10,8 @@ export const CHANNEL_OPTIONS = [
{ key: 9, text: 'AI.LS', value: 9, color: 'yellow' }, { key: 9, text: 'AI.LS', value: 9, color: 'yellow' },
{ key: 10, text: 'AI Proxy', value: 10, color: 'purple' }, { key: 10, text: 'AI Proxy', value: 10, color: 'purple' },
{ key: 12, text: 'API2GPT', value: 12, color: 'blue' }, { key: 12, text: 'API2GPT', value: 12, color: 'blue' },
{ key: 13, text: 'AIGC2D', value: 13, color: 'purple' } { key: 13, text: 'AIGC2D', value: 13, color: 'purple' },
];
//
{ key: 14, text: 'Chanzhaoyu/chatgpt-web', value: 14, color: 'purple' },
];

View File

@ -1,4 +1,4 @@
export * from './toast.constants'; export * from './toast.constants';
export * from './user.constants'; export * from './user.constants';
export * from './common.constant'; export * from './common.constant';
export * from './channel.constants'; export * from './channel.constants';

View File

@ -3,5 +3,5 @@ export const toastConstants = {
INFO_TIMEOUT: 3000, INFO_TIMEOUT: 3000,
ERROR_TIMEOUT: 5000, ERROR_TIMEOUT: 5000,
WARNING_TIMEOUT: 10000, WARNING_TIMEOUT: 10000,
NOTICE_TIMEOUT: 20000 NOTICE_TIMEOUT: 20000,
}; };

View File

@ -1,19 +0,0 @@
export const userConstants = {
REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
LOGOUT: 'USERS_LOGOUT',
GETALL_REQUEST: 'USERS_GETALL_REQUEST',
GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
GETALL_FAILURE: 'USERS_GETALL_FAILURE',
DELETE_REQUEST: 'USERS_DELETE_REQUEST',
DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
DELETE_FAILURE: 'USERS_DELETE_FAILURE'
};

View File

@ -0,0 +1,19 @@
export const userConstants = {
REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
LOGOUT: 'USERS_LOGOUT',
GETALL_REQUEST: 'USERS_GETALL_REQUEST',
GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
GETALL_FAILURE: 'USERS_GETALL_FAILURE',
DELETE_REQUEST: 'USERS_DELETE_REQUEST',
DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
DELETE_FAILURE: 'USERS_DELETE_FAILURE',
};

View File

@ -16,4 +16,4 @@ export const StatusProvider = ({ children }) => {
{children} {children}
</StatusContext.Provider> </StatusContext.Provider>
); );
}; };

View File

@ -1,19 +1,19 @@
// contexts/User/index.jsx // contexts/User/index.jsx
import React from "react" import React from 'react';
import { reducer, initialState } from "./reducer" import { reducer, initialState } from './reducer';
export const UserContext = React.createContext({ export const UserContext = React.createContext({
state: initialState, state: initialState,
dispatch: () => null dispatch: () => null,
}) });
export const UserProvider = ({ children }) => { export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState) const [state, dispatch] = React.useReducer(reducer, initialState);
return ( return (
<UserContext.Provider value={[ state, dispatch ]}> <UserContext.Provider value={[state, dispatch]}>
{ children } {children}
</UserContext.Provider> </UserContext.Provider>
) );
} };

View File

@ -3,12 +3,12 @@ export const reducer = (state, action) => {
case 'login': case 'login':
return { return {
...state, ...state,
user: action.payload user: action.payload,
}; };
case 'logout': case 'logout':
return { return {
...state, ...state,
user: undefined user: undefined,
}; };
default: default:
@ -17,5 +17,5 @@ export const reducer = (state, action) => {
}; };
export const initialState = { export const initialState = {
user: undefined user: undefined,
}; };

View File

@ -2,12 +2,12 @@ import { showError } from './utils';
import axios from 'axios'; import axios from 'axios';
export const API = axios.create({ export const API = axios.create({
baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '', baseURL: import.meta.env.VITE_REACT_APP_SERVER ? import.meta.env.VITE_REACT_APP_SERVER : '',
}); });
API.interceptors.response.use( API.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
showError(error); showError(error);
} },
); );

View File

@ -1,10 +0,0 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { 'Authorization': 'Bearer ' + user.token };
} else {
return {};
}
}

View File

@ -0,0 +1,10 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { Authorization: 'Bearer ' + user.token };
} else {
return {};
}
}

View File

@ -1,3 +1,3 @@
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
export const history = createBrowserHistory(); export const history = createBrowserHistory();

View File

@ -1,4 +1,4 @@
export * from './history'; export * from './history';
export * from './auth-header'; export * from './auth-header';
export * from './utils'; export * from './utils';
export * from './api'; export * from './api';

View File

@ -13,16 +13,18 @@ export function renderGroup(group) {
} }
let groups = group.split(','); let groups = group.split(',');
groups.sort(); groups.sort();
return <> return (
{groups.map((group) => { <>
if (group === 'vip' || group === 'pro') { {groups.map((group) => {
return <Label color='yellow'>{group}</Label>; if (group === 'vip' || group === 'pro') {
} else if (group === 'svip' || group === 'premium') { return <Label color='yellow'>{group}</Label>;
return <Label color='red'>{group}</Label>; } else if (group === 'svip' || group === 'premium') {
} return <Label color='red'>{group}</Label>;
return <Label>{group}</Label>; }
})} return <Label>{group}</Label>;
</>; })}
</>
);
} }
export function renderNumber(num) { export function renderNumber(num) {
@ -55,4 +57,4 @@ export function renderQuotaWithPrompt(quota, digits) {
return `(等价金额:${renderQuota(quota, digits)}`; return `(等价金额:${renderQuota(quota, digits)}`;
} }
return ''; return '';
} }

View File

@ -24,7 +24,7 @@ export function getSystemName() {
export function getLogo() { export function getLogo() {
let logo = localStorage.getItem('logo'); let logo = localStorage.getItem('logo');
if (!logo) return '/logo.png'; if (!logo) return '/logo.png';
return logo return logo;
} }
export function getFooterHTML() { export function getFooterHTML() {
@ -147,17 +147,7 @@ export function timestamp2string(timestamp) {
second = '0' + second; second = '0' + second;
} }
return ( return (
year + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
'-' +
month +
'-' +
day +
' ' +
hour +
':' +
minute +
':' +
second
); );
} }
@ -177,4 +167,4 @@ export const verifyJSON = (str) => {
return false; return false;
} }
return true; return true;
}; };

View File

@ -1,35 +1,37 @@
body { body {
margin: 0; margin: 0;
padding-top: 55px; padding-top: 55px;
overflow-y: scroll; overflow-y: scroll;
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif; font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei',
-webkit-font-smoothing: antialiased; sans-serif;
-moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased;
scrollbar-width: none; -moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
} }
body::-webkit-scrollbar { body::-webkit-scrollbar {
display: none; display: none;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
} }
.main-content { .main-content {
padding: 4px; padding: 4px;
} }
.small-icon .icon { .small-icon .icon {
font-size: 1em !important; font-size: 1em !important;
} }
.custom-footer { .custom-footer {
font-size: 1.1em; font-size: 1.1em;
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.hide-on-mobile { .hide-on-mobile {
display: none !important; display: none !important;
} }
} }

View File

@ -27,5 +27,5 @@ root.render(
</BrowserRouter> </BrowserRouter>
</UserProvider> </UserProvider>
</StatusProvider> </StatusProvider>
</React.StrictMode> </React.StrictMode>,
); );

View File

@ -31,8 +31,8 @@ const About = () => {
return ( return (
<> <>
{ {aboutLoaded && about === '' ? (
aboutLoaded && about === '' ? <> <>
<Segment> <Segment>
<Header as='h3'>关于</Header> <Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p> <p>可在设置页面设置关于内容支持 HTML & Markdown</p>
@ -41,20 +41,26 @@ const About = () => {
https://github.com/songquanpeng/one-api https://github.com/songquanpeng/one-api
</a> </a>
</Segment> </Segment>
</> : <> </>
{ ) : (
about.startsWith('https://') ? <iframe <>
{about.startsWith('https://') ? (
<iframe
src={about} src={about}
style={{ width: '100%', height: '100vh', border: 'none' }} style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <Segment> />
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div> ) : (
<Segment>
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
</Segment> </Segment>
} )}
</> </>
} )}
</> </>
); );
}; };
export default About; export default About;

View File

@ -1,13 +1,26 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; import {
Button,
Form,
Header,
Input,
Message,
Segment,
} from 'semantic-ui-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { API, showError, showInfo, showSuccess, verifyJSON } from '../../helpers'; import {
API,
showError,
showInfo,
showSuccess,
verifyJSON,
} from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
const MODEL_MAPPING_EXAMPLE = { const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo', 'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4', 'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k' 'gpt-4-32k-0314': 'gpt-4-32k',
}; };
const EditChannel = () => { const EditChannel = () => {
@ -23,7 +36,8 @@ const EditChannel = () => {
other: '', other: '',
model_mapping: '', model_mapping: '',
models: [], models: [],
groups: ['default'] groups: ['default'],
enable_ip_randomization: false,
}; };
const [batch, setBatch] = useState(false); const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
@ -31,7 +45,9 @@ const EditChannel = () => {
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const [basicModels, setBasicModels] = useState([]); const [basicModels, setBasicModels] = useState([]);
const [fullModels, setFullModels] = useState([]); const [fullModels, setFullModels] = useState([]);
const [customModel, setCustomModel] = useState('');
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
console.log(name, value);
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@ -43,6 +59,19 @@ const EditChannel = () => {
data.models = []; data.models = [];
} else { } else {
data.models = data.models.split(','); data.models = data.models.split(',');
setTimeout(() => {
let localModelOptions = [...modelOptions];
data.models.forEach((model) => {
if (!localModelOptions.find((option) => option.key === model)) {
localModelOptions.push({
key: model,
text: model,
value: model,
});
}
});
setModelOptions(localModelOptions);
}, 1000);
} }
if (data.group === '') { if (data.group === '') {
data.groups = []; data.groups = [];
@ -50,7 +79,11 @@ const EditChannel = () => {
data.groups = data.group.split(','); data.groups = data.group.split(',');
} }
if (data.model_mapping !== '') { if (data.model_mapping !== '') {
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2); data.model_mapping = JSON.stringify(
JSON.parse(data.model_mapping),
null,
2,
);
} }
setInputs(data); setInputs(data);
} else { } else {
@ -62,13 +95,19 @@ const EditChannel = () => {
const fetchModels = async () => { const fetchModels = async () => {
try { try {
let res = await API.get(`/api/channel/models`); let res = await API.get(`/api/channel/models`);
setModelOptions(res.data.data.map((model) => ({ setModelOptions(
key: model.id, res.data.data.map((model) => ({
text: model.id, key: model.id,
value: model.id text: model.id,
}))); value: model.id,
})),
);
setFullModels(res.data.data.map((model) => model.id)); setFullModels(res.data.data.map((model) => model.id));
setBasicModels(res.data.data.filter((model) => !model.id.startsWith('gpt-4')).map((model) => model.id)); setBasicModels(
res.data.data
.filter((model) => !model.id.startsWith('gpt-4'))
.map((model) => model.id),
);
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} }
@ -77,11 +116,13 @@ const EditChannel = () => {
const fetchGroups = async () => { const fetchGroups = async () => {
try { try {
let res = await API.get(`/api/group/`); let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({ setGroupOptions(
key: group, res.data.data.map((group) => ({
text: group, key: group,
value: group text: group,
}))); value: group,
})),
);
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} }
@ -110,7 +151,10 @@ const EditChannel = () => {
} }
let localInputs = inputs; let localInputs = inputs;
if (localInputs.base_url.endsWith('/')) { if (localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); localInputs.base_url = localInputs.base_url.slice(
0,
localInputs.base_url.length - 1,
);
} }
if (localInputs.type === 3 && localInputs.other === '') { if (localInputs.type === 3 && localInputs.other === '') {
localInputs.other = '2023-03-15-preview'; localInputs.other = '2023-03-15-preview';
@ -119,7 +163,10 @@ const EditChannel = () => {
localInputs.models = localInputs.models.join(','); localInputs.models = localInputs.models.join(',');
localInputs.group = localInputs.groups.join(','); localInputs.group = localInputs.groups.join(',');
if (isEdit) { if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) }); res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else { } else {
res = await API.post(`/api/channel/`, localInputs); res = await API.post(`/api/channel/`, localInputs);
} }
@ -151,65 +198,74 @@ const EditChannel = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Field> </Form.Field>
{ {inputs.type === 3 && (
inputs.type === 3 && ( <>
<> <Message>
<Message> 注意<strong>模型部署名称必须和模型名称保持一致</strong>因为
注意<strong>模型部署名称必须和模型名称保持一致</strong>因为 One API 会把请求体中的 model One API 会把请求体中的 model
参数替换为你的部署名称模型名称中的点会被剔除<a target='_blank' 参数替换为你的部署名称模型名称中的点会被剔除
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a> <a
</Message> target='_blank'
<Form.Field> href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
<Form.Input >
label='AZURE_OPENAI_ENDPOINT' 图片演示
name='base_url' </a>
placeholder={'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'}
onChange={handleInputChange} </Message>
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='默认 API 版本'
name='other'
placeholder={'请输入默认 API 版本例如2023-03-15-preview该配置可以被实际的请求查询参数所覆盖'}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{
inputs.type === 8 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Base URL' label='AZURE_OPENAI_ENDPOINT'
name='base_url' name='base_url'
placeholder={'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'} placeholder={
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
)
}
{
inputs.type !== 3 && inputs.type !== 8 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='镜像' label='默认 API 版本'
name='base_url' name='other'
placeholder={'此项可选输入镜像站地址格式为https://domain.com'} placeholder={
'请输入默认 API 版本例如2023-03-15-preview该配置可以被实际的请求查询参数所覆盖'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) </>
} )}
{inputs.type === 8 && (
<Form.Field>
<Form.Input
label='Base URL'
name='base_url'
placeholder={
'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'
}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type !== 3 && inputs.type !== 8 && (
<Form.Field>
<Form.Input
label='镜像'
name='base_url'
placeholder={
'此项可选输入镜像站地址格式为https://domain.com'
}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
@ -254,20 +310,79 @@ const EditChannel = () => {
/> />
</Form.Field> </Form.Field>
<div style={{ lineHeight: '40px', marginBottom: '12px' }}> <div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button type={'button'} onClick={() => { <Button
handleInputChange(null, { name: 'models', value: basicModels }); type={'button'}
}}>填入基础模型</Button> onClick={() => {
<Button type={'button'} onClick={() => { handleInputChange(null, { name: 'models', value: basicModels });
handleInputChange(null, { name: 'models', value: fullModels }); }}
}}>填入所有模型</Button> >
<Button type={'button'} onClick={() => { 填入基础模型
handleInputChange(null, { name: 'models', value: [] }); </Button>
}}>清除所有模型</Button> <Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: fullModels });
}}
>
填入所有模型
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}
>
清除所有模型
</Button>
<Input
action={
<Button
type={'button'}
onClick={() => {
let localModels = [...inputs.models];
localModels.push(customModel);
let localModelOptions = [...modelOptions];
localModelOptions.push({
key: customModel,
text: customModel,
value: customModel,
});
setModelOptions(localModelOptions);
handleInputChange(null, {
name: 'models',
value: localModels,
});
}}
>
填入
</Button>
}
placeholder='输入自定义模型名称'
value={customModel}
onChange={(e, { value }) => {
setCustomModel(value);
}}
/>
</div> </div>
<Form.Field>
<Form.Checkbox
name='enable_ip_randomization'
label='将IP随机地址传递给HTTP头'
onChange={(e, { name, checked }) => {
handleInputChange(e, { name, value: checked });
}}
checked={inputs.enable_ip_randomization}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field> <Form.Field>
<Form.TextArea <Form.TextArea
label='模型映射' label='模型映射'
placeholder={`此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} placeholder={`此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(
MODEL_MAPPING_EXAMPLE,
null,
2,
)}`}
name='model_mapping' name='model_mapping'
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.model_mapping} value={inputs.model_mapping}
@ -275,19 +390,23 @@ const EditChannel = () => {
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
{ {batch ? (
batch ? <Form.Field> <Form.Field>
<Form.TextArea <Form.TextArea
label='密钥' label='密钥'
name='key' name='key'
required
placeholder={'请输入密钥,一行一个'} placeholder={'请输入密钥,一行一个'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> : <Form.Field> </Form.Field>
) : (
<Form.Field>
<Form.Input <Form.Input
label='密钥' label='密钥'
name='key' name='key'
@ -298,18 +417,18 @@ const EditChannel = () => {
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
} )}
{ {!isEdit && (
!isEdit && ( <Form.Checkbox
<Form.Checkbox checked={batch}
checked={batch} label='批量创建'
label='批量创建' name='batch'
name='batch' onChange={() => setBatch(!batch)}
onChange={() => setBatch(!batch)} />
/> )}
) <Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>
} 提交
<Button positive onClick={submit}>提交</Button> </Button>
</Form> </Form>
</Segment> </Segment>
</> </>

View File

@ -11,5 +11,4 @@ const Chat = () => {
); );
}; };
export default Chat; export default Chat;

View File

@ -52,8 +52,8 @@ const Home = () => {
}, []); }, []);
return ( return (
<> <>
{ {homePageContentLoaded && homePageContent === '' ? (
homePageContentLoaded && homePageContent === '' ? <> <>
<Segment> <Segment>
<Header as='h3'>系统状况</Header> <Header as='h3'>系统状况</Header>
<Grid columns={2} stackable> <Grid columns={2} stackable>
@ -97,6 +97,12 @@ const Home = () => {
? '已启用' ? '已启用'
: '未启用'} : '未启用'}
</p> </p>
<p>
Discord 身份验证
{statusState?.status?.discord_oauth === true
? '已启用'
: '未启用'}
</p>
<p> <p>
微信身份验证 微信身份验证
{statusState?.status?.wechat_login === true {statusState?.status?.wechat_login === true
@ -115,16 +121,22 @@ const Home = () => {
</Grid.Column> </Grid.Column>
</Grid> </Grid>
</Segment> </Segment>
</> : <> </>
{ ) : (
homePageContent.startsWith('https://') ? <iframe <>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent} src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }} style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div> />
} ) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
</> </>
} )}
</> </>
); );
}; };

View File

@ -1,20 +0,0 @@
import React from 'react';
import { Segment, Header } from 'semantic-ui-react';
const NotFound = () => (
<>
<Header
block
as="h4"
content="404"
attached="top"
icon="info"
className="small-icon"
/>
<Segment attached="bottom">
未找到所请求的页面
</Segment>
</>
);
export default NotFound;

View File

@ -0,0 +1,18 @@
import React from 'react';
import { Segment, Header } from 'semantic-ui-react';
const NotFound = () => (
<>
<Header
block
as='h4'
content='404'
attached='top'
icon='info'
className='small-icon'
/>
<Segment attached='bottom'>未找到所请求的页面</Segment>
</>
);
export default NotFound;

View File

@ -12,7 +12,7 @@ const EditRedemption = () => {
const originInputs = { const originInputs = {
name: '', name: '',
quota: 100000, quota: 100000,
count: 1 count: 1,
}; };
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs; const { name, quota, count } = inputs;
@ -44,10 +44,13 @@ const EditRedemption = () => {
localInputs.quota = parseInt(localInputs.quota); localInputs.quota = parseInt(localInputs.quota);
let res; let res;
if (isEdit) { if (isEdit) {
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) }); res = await API.put(`/api/redemption/`, {
...localInputs,
id: parseInt(redemptionId),
});
} else { } else {
res = await API.post(`/api/redemption/`, { res = await API.post(`/api/redemption/`, {
...localInputs ...localInputs,
}); });
} }
const { success, message, data } = res.data; const { success, message, data } = res.data;
@ -62,9 +65,9 @@ const EditRedemption = () => {
showError(message); showError(message);
} }
if (!isEdit && data) { if (!isEdit && data) {
let text = ""; let text = '';
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
text += data[i] + "\n"; text += data[i] + '\n';
} }
downloadTextAsFile(text, `${inputs.name}.txt`); downloadTextAsFile(text, `${inputs.name}.txt`);
} }
@ -97,8 +100,8 @@ const EditRedemption = () => {
type='number' type='number'
/> />
</Form.Field> </Form.Field>
{ {!isEdit && (
!isEdit && <> <>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='生成数量' label='生成数量'
@ -111,8 +114,10 @@ const EditRedemption = () => {
/> />
</Form.Field> </Form.Field>
</> </>
} )}
<Button positive onClick={submit}>提交</Button> <Button positive onClick={submit}>
提交
</Button>
</Form> </Form>
</Segment> </Segment>
</> </>

View File

@ -6,7 +6,7 @@ const Redemption = () => (
<> <>
<Segment> <Segment>
<Header as='h3'>管理兑换码</Header> <Header as='h3'>管理兑换码</Header>
<RedemptionsTable/> <RedemptionsTable />
</Segment> </Segment>
</> </>
); );

View File

@ -14,8 +14,8 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<PersonalSetting /> <PersonalSetting />
</Tab.Pane> </Tab.Pane>
) ),
} },
]; ];
if (isRoot()) { if (isRoot()) {
@ -25,7 +25,7 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<OperationSetting /> <OperationSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '系统设置', menuItem: '系统设置',
@ -33,7 +33,7 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<SystemSetting /> <SystemSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '其他设置', menuItem: '其他设置',
@ -41,7 +41,7 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<OtherSetting /> <OtherSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
} }

View File

@ -1,161 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => {
const params = useParams();
const tokenId = params.id;
const isEdit = tokenId !== undefined;
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
remain_quota: isEdit ? 0 : 500000,
expired_time: -1,
unlimited_quota: false
};
const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setExpiredTime = (month, day, hour, minute) => {
let now = new Date();
let timestamp = now.getTime() / 1000;
let seconds = month * 30 * 24 * 60 * 60;
seconds += day * 24 * 60 * 60;
seconds += hour * 60 * 60;
seconds += minute * 60;
if (seconds !== 0) {
timestamp += seconds;
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
} else {
setInputs({ ...inputs, expired_time: -1 });
}
};
const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
};
const loadToken = async () => {
let res = await API.get(`/api/token/${tokenId}`);
const { success, message, data } = res.data;
if (success) {
if (data.expired_time !== -1) {
data.expired_time = timestamp2string(data.expired_time);
}
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (isEdit) {
loadToken().then();
}
}, []);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
let localInputs = inputs;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) {
showError('过期时间格式错误!');
return;
}
localInputs.expired_time = Math.ceil(time / 1000);
}
let res;
if (isEdit) {
res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) });
} else {
res = await API.post(`/api/token/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess('令牌更新成功!');
} else {
showSuccess('令牌创建成功!');
setInputs(originInputs);
}
} else {
showError(message);
}
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Input
label='名称'
name='name'
placeholder={'请输入名称'}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='过期时间'
name='expired_time'
placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>永不过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>一个月后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>一天后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>一小时后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>一分钟后过期</Button>
</div>
<Message>注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>
);
};
export default EditToken;

View File

@ -0,0 +1,288 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => {
const params = useParams();
const tokenId = params.id;
const isEdit = tokenId !== undefined;
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
remain_quota: isEdit ? 0 : 500000,
expired_time: -1,
unlimited_quota: false,
models: isEdit
? []
: [
'gpt-3.5-turbo',
'gpt-3.5-turbo-0301',
'gpt-3.5-turbo-0613',
'gpt-3.5-turbo-16k',
'gpt-3.5-turbo-16k-0613',
],
};
const [modelOptions, setModelOptions] = useState([]);
const [basicModels, setBasicModels] = useState([]);
const [fullModels, setFullModels] = useState([]);
const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setExpiredTime = (month, day, hour, minute) => {
let now = new Date();
let timestamp = now.getTime() / 1000;
let seconds = month * 30 * 24 * 60 * 60;
seconds += day * 24 * 60 * 60;
seconds += hour * 60 * 60;
seconds += minute * 60;
if (seconds !== 0) {
timestamp += seconds;
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
} else {
setInputs({ ...inputs, expired_time: -1 });
}
};
const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
};
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
setModelOptions(
res.data.data.map((model) => ({
key: model.id,
text: model.id,
value: model.id,
})),
);
setFullModels(res.data.data.map((model) => model.id));
setBasicModels(
res.data.data
.filter((model) => !model.id.startsWith('gpt-4'))
.map((model) => model.id),
);
} catch (error) {
showError(error.message);
}
};
const loadToken = async () => {
let res = await API.get(`/api/token/${tokenId}`);
const { success, message, data } = res.data;
if (success) {
if (data.expired_time !== -1) {
data.expired_time = timestamp2string(data.expired_time);
}
if (data.models === '') {
data.models = [];
} else {
data.models = data.models.split(',');
}
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (isEdit) {
loadToken().then();
}
}, []);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
let localInputs = inputs;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) {
showError('过期时间格式错误!');
return;
}
localInputs.expired_time = Math.ceil(time / 1000);
}
if (inputs.models.length === 0) {
showError('请至少选择一个模型!');
return;
}
localInputs.models = localInputs.models.join(',');
let res;
if (isEdit) {
res = await API.put(`/api/token/`, {
...localInputs,
id: parseInt(tokenId),
});
} else {
res = await API.post(`/api/token/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess('令牌更新成功!');
} else {
showSuccess('令牌创建成功!');
setInputs(originInputs);
}
} else {
showError(message);
}
};
useEffect(() => {
fetchModels().then();
}, []);
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Input
label='名称'
name='name'
placeholder={'请输入名称'}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='过期时间'
name='expired_time'
placeholder={
'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'
}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}
>
永不过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}
>
一个月后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}
>
一天后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}
>
一小时后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}
>
一分钟后过期
</Button>
</div>
<Message>
注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制
</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button
type={'button'}
onClick={() => {
setUnlimitedQuota();
}}
>
{unlimited_quota ? '取消无限额度' : '设置为无限额度'}
</Button>
<Form.Field style={{ marginTop: '12px' }}>
<Form.Dropdown
label='模型'
placeholder={'请选择此密钥支持的模型'}
name='models'
required
fluid
multiple
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: basicModels });
}}
>
填入基础模型
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: fullModels });
}}
>
填入所有模型
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}
>
清除所有模型
</Button>
</div>
<Button positive onClick={submit}>
提交
</Button>
</Form>
</Segment>
</>
);
};
export default EditToken;

View File

@ -6,7 +6,7 @@ const Token = () => (
<> <>
<Segment> <Segment>
<Header as='h3'>我的令牌</Header> <Header as='h3'>我的令牌</Header>
<TokensTable/> <TokensTable />
</Segment> </Segment>
</> </>
); );

View File

@ -1,5 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react'; import {
Button,
Form,
Grid,
Header,
Segment,
Statistic,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers'; import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render'; import { renderQuota } from '../../helpers/render';
@ -10,11 +17,11 @@ const TopUp = () => {
const topUp = async () => { const topUp = async () => {
if (redemptionCode === '') { if (redemptionCode === '') {
showInfo('请输入充值码!') showInfo('请输入充值码!');
return; return;
} }
const res = await API.post('/api/user/topup', { const res = await API.post('/api/user/topup', {
key: redemptionCode key: redemptionCode,
}); });
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@ -36,15 +43,15 @@ const TopUp = () => {
window.open(topUpLink, '_blank'); window.open(topUpLink, '_blank');
}; };
const getUserQuota = async ()=>{ const getUserQuota = async () => {
let res = await API.get(`/api/user/self`); let res = await API.get(`/api/user/self`);
const {success, message, data} = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setUserQuota(data.quota); setUserQuota(data.quota);
} else { } else {
showError(message); showError(message);
} }
} };
useEffect(() => { useEffect(() => {
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@ -92,5 +99,4 @@ const TopUp = () => {
); );
}; };
export default TopUp; export default TopUp;

View File

@ -30,38 +30,38 @@ const AddUser = () => {
return ( return (
<> <>
<Segment> <Segment>
<Header as="h3">创建新用户账户</Header> <Header as='h3'>创建新用户账户</Header>
<Form autoComplete="off"> <Form autoComplete='off'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label="用户名" label='用户名'
name="username" name='username'
placeholder={'请输入用户名'} placeholder={'请输入用户名'}
onChange={handleInputChange} onChange={handleInputChange}
value={username} value={username}
autoComplete="off" autoComplete='off'
required required
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label="显示名称" label='显示名称'
name="display_name" name='display_name'
placeholder={'请输入显示名称'} placeholder={'请输入显示名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={display_name} value={display_name}
autoComplete="off" autoComplete='off'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label="密码" label='密码'
name="password" name='password'
type={'password'} type={'password'}
placeholder={'请输入密码'} placeholder={'请输入密码'}
onChange={handleInputChange} onChange={handleInputChange}
value={password} value={password}
autoComplete="off" autoComplete='off'
required required
/> />
</Form.Field> </Form.Field>

View File

@ -13,25 +13,36 @@ const EditUser = () => {
display_name: '', display_name: '',
password: '', password: '',
github_id: '', github_id: '',
discord_id: '',
wechat_id: '', wechat_id: '',
email: '', email: '',
quota: 0, quota: 0,
group: 'default' group: 'default',
}); });
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, email, quota, group } = const {
inputs; username,
display_name,
password,
github_id,
wechat_id,
email,
quota,
discord_id,
} = inputs;
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
const fetchGroups = async () => { const fetchGroups = async () => {
try { try {
let res = await API.get(`/api/group/`); let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({ setGroupOptions(
key: group, res.data.data.map((group) => ({
text: group, key: group,
value: group, text: group,
}))); value: group,
})),
);
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} }
@ -115,8 +126,8 @@ const EditUser = () => {
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
{ {userId && (
userId && <> <>
<Form.Field> <Form.Field>
<Form.Dropdown <Form.Dropdown
label='分组' label='分组'
@ -145,7 +156,7 @@ const EditUser = () => {
/> />
</Form.Field> </Form.Field>
</> </>
} )}
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='已绑定的 GitHub 账户' label='已绑定的 GitHub 账户'
@ -156,6 +167,16 @@ const EditUser = () => {
readOnly readOnly
/> />
</Form.Field> </Form.Field>
<Form.Field>
<Form.Input
label='已绑定的 Discord 账户'
name='discord_id'
value={discord_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='已绑定的微信账户' label='已绑定的微信账户'
@ -176,7 +197,9 @@ const EditUser = () => {
readOnly readOnly
/> />
</Form.Field> </Form.Field>
<Button positive onClick={submit}>提交</Button> <Button positive onClick={submit}>
提交
</Button>
</Form> </Form>
</Segment> </Segment>
</> </>

View File

@ -6,7 +6,7 @@ const User = () => (
<> <>
<Segment> <Segment>
<Header as='h3'>管理用户</Header> <Header as='h3'>管理用户</Header>
<UsersTable/> <UsersTable />
</Segment> </Segment>
</> </>
); );

11
web/vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: 'build',
minify: 'terser',
},
})