Compare commits

...

17 Commits

Author SHA1 Message Date
Yolo°
5a62357c93 feat: add chat button for each token (#363)
* fork

* fork

* chore: update style

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-08-06 22:02:58 +08:00
JustSong
b464e2907a chore: update domain 2023-08-06 18:14:13 +08:00
JustSong
d96cf2e84d fix: fix stream mode determine related logic (close #360) 2023-08-06 18:09:00 +08:00
glzjin
446337c329 fix: calculate usage if not given in non-stream mode (#352) 2023-08-06 17:40:31 +08:00
Miniers
1dfa190e79 feat: able to copy scheme of ama, opencat & chatgpt next web (#343)
* Token Adds Option to Quickly Copy AMA and OpenCat URL Scheme

* feat: add ChatGPT Next Web

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-08-06 13:56:59 +08:00
glzjin
2d49ca6a07 fix: fix SparkDesk not billed (#344) 2023-08-06 13:24:49 +08:00
a497625414
89bcaaf989 docs: update readme (#359)
* update-deploy-on-sealos

* update-deploy-on-sealos
2023-08-06 13:19:54 +08:00
a497625414
afcd1bd27b docs: update deploy-on-sealos (#351) 2023-08-02 19:11:21 +08:00
glzjin
c2c455c980 fix: fix zhipu streaming (#349)
* Fix #348

* chore: update implementation

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-08-01 23:51:28 +08:00
JustSong
30a7f1a1c7 docs: update README 2023-07-30 22:24:07 +08:00
JustSong
c9d2e42a9e fix: fix sse not ending properly in some case 2023-07-30 22:20:42 +08:00
ckt
3fca6ff534 feat: support email domain whitelist (#337)
* feat: support email domain restriction

* fix(SMTPToken): disable password auto complete

* chore: update implementation

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-30 12:44:41 +08:00
JustSong
8cbbeb784f fix: ignore data if not have proper prefix 2023-07-30 12:03:06 +08:00
JustSong
ec88c0c240 fix: prompt user that channel test is unavailable 2023-07-29 23:54:09 +08:00
JustSong
065147b440 fix: close connection when response ended 2023-07-29 23:52:18 +08:00
JustSong
fe8f216dd9 refactor: update billing related code 2023-07-29 22:32:05 +08:00
JustSong
b7d0616ae0 chore: update title for xunfei 2023-07-29 22:09:10 +08:00
19 changed files with 459 additions and 96 deletions

View File

@@ -137,7 +137,7 @@ The initial account username is `root` and password is `123456`.
cd one-api/web
npm install
npm run build
# Build the backend
cd ..
go mod download
@@ -173,7 +173,12 @@ If you encounter a blank page after deployment, refer to [#97](https://github.co
<summary><strong>Deploy on Sealos</strong></summary>
<div>
Please refer to [this tutorial](https://github.com/c121914yu/FastGPT/blob/main/docs/deploy/one-api/sealos.md).
> Sealos supports high concurrency, dynamic scaling, and stable operations for millions of users.
> Click the button below to deploy with one click.👇
[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)
</div>
</details>

View File

@@ -94,7 +94,7 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用
19. 支持通过系统访问令牌访问管理 API。
20. 支持 Cloudflare Turnstile 用户校验。
21. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。
+ 邮箱登录注册(支持注册邮箱白名单)以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
@@ -153,7 +153,7 @@ sudo service nginx restart
cd one-api/web
npm install
npm run build
# 构建后端
cd ..
go mod download
@@ -211,9 +211,11 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope
<summary><strong>部署到 Sealos </strong></summary>
<div>
> Sealos 可视化部署,仅需 1 分钟
> Sealos 的服务器在国外,不需要额外处理网络问题,支持高并发 & 动态伸缩
参考这个[教程](https://github.com/c121914yu/FastGPT/blob/main/docs/deploy/one-api/sealos.md)中 1~5 步。
点击以下按钮一键部署:
[![Deploy-on-Sealos.svg](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)
</div>
</details>
@@ -314,6 +316,7 @@ https://openai.justsong.cn
+ 额度 = 分组倍率 * 模型倍率 * (提示 token 数 + 补全 token 数 * 补全倍率)
+ 其中补全倍率对于 GPT3.5 固定为 1.33GPT4 为 2与官方保持一致。
+ 如果是非流模式,官方接口会返回消耗的总 token但是你要注意提示和补全的消耗倍率不一样。
+ 注意One API 的默认倍率就是官方倍率,是已经调整过的。
2. 账户额度足够为什么提示额度不足?
+ 请检查你的令牌额度是否足够,这个和账户额度是分开的。
+ 令牌额度仅供用户设置最大使用量,用户可自由设置。

View File

@@ -42,6 +42,19 @@ var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false
var RegisterEnabled = true
var EmailDomainRestrictionEnabled = false
var EmailDomainWhitelist = []string{
"gmail.com",
"163.com",
"126.com",
"qq.com",
"outlook.com",
"hotmail.com",
"icloud.com",
"yahoo.com",
"foxmail.com",
}
var LogConsumeEnabled = true
var SMTPServer = ""

View File

@@ -23,6 +23,8 @@ func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIErr
case common.ChannelTypeBaidu:
fallthrough
case common.ChannelTypeZhipu:
fallthrough
case common.ChannelTypeXunfei:
return errors.New("该渠道类型当前版本不支持测试,请手动测试"), nil
case common.ChannelTypeAzure:
request.Model = "gpt-35-turbo"

View File

@@ -3,10 +3,12 @@ package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strings"
"github.com/gin-gonic/gin"
)
func GetStatus(c *gin.Context) {
@@ -78,6 +80,22 @@ func SendEmailVerification(c *gin.Context) {
})
return
}
if common.EmailDomainRestrictionEnabled {
allowed := false
for _, domain := range common.EmailDomainWhitelist {
if strings.HasSuffix(email, "@"+domain) {
allowed = true
break
}
}
if !allowed {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员启用了邮箱域名白名单,您的邮箱地址的域名不在白名单中",
})
return
}
}
if model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,

View File

@@ -2,11 +2,12 @@ package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strings"
"github.com/gin-gonic/gin"
)
func GetOptions(c *gin.Context) {
@@ -49,6 +50,14 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "EmailDomainRestrictionEnabled":
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
})
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
c.JSON(http.StatusOK, gin.H{

View File

@@ -34,6 +34,9 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
if len(data) < 6 { // ignore blank line or wrong format
continue
}
if data[:6] != "data: " && data[:6] != "[DONE]" {
continue
}
dataChan <- data
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
@@ -43,7 +46,7 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
err := json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
continue // just ignore the error
}
for _, choice := range streamResponse.Choices {
responseText += choice.Delta.Content
@@ -53,7 +56,7 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
err := json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
continue
}
for _, choice := range streamResponse.Choices {
responseText += choice.Text
@@ -89,7 +92,7 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O
return nil, responseText
}
func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool) (*OpenAIErrorWithStatusCode, *Usage) {
func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) {
var textResponse TextResponse
if consumeQuota {
responseBody, err := io.ReadAll(resp.Body)
@@ -129,5 +132,17 @@ func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool) (*Ope
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
}
if textResponse.Usage.TotalTokens == 0 {
completionTokens := 0
for _, choice := range textResponse.Choices {
completionTokens += countTokenText(choice.Message.Content, model)
}
textResponse.Usage = Usage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}
}
return nil, &textResponse.Usage
}

View File

@@ -302,11 +302,10 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
}
isStream = strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
isStream = isStream || strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
}
var textResponse TextResponse
var streamResponseText string
defer func() {
if consumeQuota {
@@ -318,16 +317,10 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if strings.HasPrefix(textRequest.Model, "gpt-4") {
completionRatio = 2
}
if isStream && apiType != APITypeBaidu && apiType != APITypeZhipu && apiType != APITypeAli {
completionTokens = countTokenText(streamResponseText, textRequest.Model)
} else {
promptTokens = textResponse.Usage.PromptTokens
completionTokens = textResponse.Usage.CompletionTokens
if apiType == APITypeZhipu {
// zhipu's API does not return prompt tokens & completion tokens
promptTokens = textResponse.Usage.TotalTokens
}
}
promptTokens = textResponse.Usage.PromptTokens
completionTokens = textResponse.Usage.CompletionTokens
quota = promptTokens + int(float64(completionTokens)*completionRatio)
quota = int(float64(quota) * ratio)
if ratio != 0 && quota <= 0 {
@@ -365,10 +358,11 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if err != nil {
return err
}
streamResponseText = responseText
textResponse.Usage.PromptTokens = promptTokens
textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model)
return nil
} else {
err, usage := openaiHandler(c, resp, consumeQuota)
err, usage := openaiHandler(c, resp, consumeQuota, promptTokens, textRequest.Model)
if err != nil {
return err
}
@@ -383,7 +377,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if err != nil {
return err
}
streamResponseText = responseText
textResponse.Usage.PromptTokens = promptTokens
textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model)
return nil
} else {
err, usage := claudeHandler(c, resp, promptTokens, textRequest.Model)
@@ -428,7 +423,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if err != nil {
return err
}
streamResponseText = responseText
textResponse.Usage.PromptTokens = promptTokens
textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model)
return nil
} else {
err, usage := palmHandler(c, resp, promptTokens, textRequest.Model)
@@ -449,6 +445,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if usage != nil {
textResponse.Usage = *usage
}
// zhipu's API does not return prompt tokens & completion tokens
textResponse.Usage.PromptTokens = textResponse.Usage.TotalTokens
return nil
} else {
err, usage := zhipuHandler(c, resp)
@@ -458,6 +456,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
if usage != nil {
textResponse.Usage = *usage
}
// zhipu's API does not return prompt tokens & completion tokens
textResponse.Usage.PromptTokens = textResponse.Usage.TotalTokens
return nil
}
case APITypeAli:

View File

@@ -63,16 +63,16 @@ type XunfeiChatResponse struct {
Seq int `json:"seq"`
Text []XunfeiChatResponseTextItem `json:"text"`
} `json:"choices"`
Usage struct {
//Text struct {
// QuestionTokens string `json:"question_tokens"`
// PromptTokens string `json:"prompt_tokens"`
// CompletionTokens string `json:"completion_tokens"`
// TotalTokens string `json:"total_tokens"`
//} `json:"text"`
Text Usage `json:"text"`
} `json:"usage"`
} `json:"payload"`
Usage struct {
//Text struct {
// QuestionTokens string `json:"question_tokens"`
// PromptTokens string `json:"prompt_tokens"`
// CompletionTokens string `json:"completion_tokens"`
// TotalTokens string `json:"total_tokens"`
//} `json:"text"`
Text Usage `json:"text"`
} `json:"usage"`
}
func requestOpenAI2Xunfei(request GeneralOpenAIRequest, xunfeiAppId string) *XunfeiChatRequest {
@@ -123,7 +123,7 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *OpenAITextResponse {
Object: "chat.completion",
Created: common.GetTimestamp(),
Choices: []OpenAITextResponseChoice{choice},
Usage: response.Usage.Text,
Usage: response.Payload.Usage.Text,
}
return &fullTextResponse
}
@@ -205,6 +205,10 @@ func xunfeiStreamHandler(c *gin.Context, textRequest GeneralOpenAIRequest, appId
}
dataChan <- response
if response.Payload.Choices.Status == 2 {
err := conn.Close()
if err != nil {
common.SysError("error closing websocket connection: " + err.Error())
}
break
}
}
@@ -218,9 +222,9 @@ func xunfeiStreamHandler(c *gin.Context, textRequest GeneralOpenAIRequest, appId
c.Stream(func(w io.Writer) bool {
select {
case xunfeiResponse := <-dataChan:
usage.PromptTokens += xunfeiResponse.Usage.Text.PromptTokens
usage.CompletionTokens += xunfeiResponse.Usage.Text.CompletionTokens
usage.TotalTokens += xunfeiResponse.Usage.Text.TotalTokens
usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens
usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens
usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens
response := streamResponseXunfei2OpenAI(&xunfeiResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {

View File

@@ -194,8 +194,8 @@ func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSt
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 i := strings.Index(string(data), "\n\n"); i >= 0 && strings.Index(string(data), ":") >= 0 {
return i + 2, data[0:i], nil
}
if atEOF {
return len(data), data, nil
@@ -208,14 +208,19 @@ func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSt
go func() {
for scanner.Scan() {
data := scanner.Text()
data = strings.Trim(data, "\"")
if len(data) < 5 { // ignore blank line or wrong format
continue
}
if data[:5] == "data:" {
dataChan <- data[5:]
} else if data[:5] == "meta:" {
metaChan <- data[5:]
lines := strings.Split(data, "\n")
for i, line := range lines {
if len(line) < 5 {
continue
}
if line[:5] == "data:" {
dataChan <- line[5:]
if i != len(lines)-1 {
dataChan <- "\n"
}
} else if line[:5] == "meta:" {
metaChan <- line[5:]
}
}
}
stopChan <- true

View File

@@ -81,8 +81,9 @@ type OpenAIErrorWithStatusCode struct {
}
type TextResponse struct {
Usage `json:"usage"`
Error OpenAIError `json:"error"`
Choices []OpenAITextResponseChoice `json:"choices"`
Usage `json:"usage"`
Error OpenAIError `json:"error"`
}
type OpenAITextResponseChoice struct {

View File

@@ -3,6 +3,11 @@
"%d 点额度": "%d point quota",
"尚未实现": "Not yet implemented",
"余额不足": "Insufficient balance",
"危险操作": "Hazardous operations",
"输入你的账户名": "Enter your account name",
"确认删除": "Confirm Delete",
"确认绑定": "Confirm Binding",
"您正在删除自己的帐户,将清空所有数据且不可恢复": "You are deleting your account, all data will be cleared and unrecoverable.",
"\"通道「%s」#%d已被禁用\"": "\"Channel %s (#%d) has been disabled\"",
"通道「%s」#%d已被禁用原因%s": "Channel %s (#%d) has been disabled, reason: %s",
"测试已在运行中": "Test is already running",
@@ -427,7 +432,7 @@
"一分钟后过期": "Expires after one minute",
"创建新的令牌": "Create New Token",
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
"设为无限额度": "Set to unlimited quota",
"设为无限额度": "Set to unlimited quota",
"更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name",
@@ -493,6 +498,7 @@
"参数替换为你的部署名称(模型名称中的点会被剔除)": "Replace the parameter with your deployment name (dots in the model name will be removed)",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
"取消无限额度": "Cancel unlimited quota",
"取消": "Cancel",
"请输入新的剩余额度": "Please enter the new remaining quota",
"请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code",
"请输入用户名": "Please enter username",

View File

@@ -39,6 +39,8 @@ func InitOptionMap() {
common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled)
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",")
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPFrom"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
@@ -141,6 +143,8 @@ func updateOptionMap(key string, value string) (err error) {
common.TurnstileCheckEnabled = boolValue
case "RegisterEnabled":
common.RegisterEnabled = boolValue
case "EmailDomainRestrictionEnabled":
common.EmailDomainRestrictionEnabled = boolValue
case "AutomaticDisableChannelEnabled":
common.AutomaticDisableChannelEnabled = boolValue
case "ApproximateTokenEnabled":
@@ -154,6 +158,8 @@ func updateOptionMap(key string, value string) (err error) {
}
}
switch key {
case "EmailDomainWhitelist":
common.EmailDomainWhitelist = strings.Split(value, ",")
case "SMTPServer":
common.SMTPServer = value
case "SMTPPort":

View File

@@ -25,6 +25,8 @@ const PersonalSetting = () => {
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [affLink, setAffLink] = useState("");
const [systemToken, setSystemToken] = useState("");
useEffect(() => {
let status = localStorage.getItem('status');
@@ -59,8 +61,10 @@ const PersonalSetting = () => {
const res = await API.get('/api/user/token');
const { success, message, data } = res.data;
if (success) {
setSystemToken(data);
setAffLink("");
await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板${data}`);
showSuccess(`令牌已重置并已复制到剪贴板`);
} else {
showError(message);
}
@@ -71,13 +75,27 @@ const PersonalSetting = () => {
const { success, message, data } = res.data;
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link);
setSystemToken("");
await copy(link);
showNotice(`邀请链接已复制到剪切板${link}`);
showSuccess(`邀请链接已复制到剪切板`);
} else {
showError(message);
}
};
const handleAffLinkClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`邀请链接已复制到剪切板`);
};
const handleSystemTokenClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(`系统令牌已复制到剪切板`);
};
const deleteAccount = async () => {
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
showError('请输入你的账户名以确认删除!');
@@ -168,6 +186,25 @@ const PersonalSetting = () => {
<Button onClick={() => {
setShowAccountDeleteModal(true);
}}>删除个人账户</Button>
{systemToken && (
<Form.Input
fluid
readOnly
value={systemToken}
onClick={handleSystemTokenClick}
style={{ marginTop: '10px' }}
/>
)}
{affLink && (
<Form.Input
fluid
readOnly
value={affLink}
onClick={handleAffLinkClick}
style={{ marginTop: '10px' }}
/>
)}
<Divider />
<Header as='h3'>账号绑定</Header>
{
@@ -262,6 +299,7 @@ const PersonalSetting = () => {
) : (
<></>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
<Button
color=''
fluid
@@ -269,8 +307,17 @@ const PersonalSetting = () => {
onClick={bindEmail}
loading={loading}
>
绑定
确认绑定
</Button>
<div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowEmailBindModal(false)}
>
取消
</Button>
</div>
</Form>
</Modal.Description>
</Modal.Content>
@@ -282,8 +329,9 @@ const PersonalSetting = () => {
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>确认删除自己的帐户</Modal.Header>
<Modal.Header>危险操作</Modal.Header>
<Modal.Content>
<Message>您正在删除自己的帐户将清空所有数据且不可恢复</Message>
<Modal.Description>
<Form size='large'>
<Form.Input
@@ -303,15 +351,25 @@ const PersonalSetting = () => {
) : (
<></>
)}
<Button
color='red'
fluid
size='large'
onClick={deleteAccount}
loading={loading}
>
删除
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
<Button
color='red'
fluid
size='large'
onClick={deleteAccount}
loading={loading}
>
确认删除
</Button>
<div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowAccountDeleteModal(false)}
>
取消
</Button>
</div>
</Form>
</Modal.Description>
</Modal.Content>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
import { Button, Divider, Form, Grid, Header, Input, Message } from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers';
const SystemSetting = () => {
let [inputs, setInputs] = useState({
@@ -26,9 +26,13 @@ const SystemSetting = () => {
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
EmailDomainRestrictionEnabled: '',
EmailDomainWhitelist: ''
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -38,8 +42,15 @@ const SystemSetting = () => {
data.forEach((item) => {
newInputs[item.key] = item.value;
});
setInputs(newInputs);
setInputs({
...newInputs,
EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')
});
setOriginInputs(newInputs);
setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
return { key: item, text: item, value: item };
}));
} else {
showError(message);
}
@@ -58,6 +69,7 @@ const SystemSetting = () => {
case 'GitHubOAuthEnabled':
case 'WeChatAuthEnabled':
case 'TurnstileCheckEnabled':
case 'EmailDomainRestrictionEnabled':
case 'RegisterEnabled':
value = inputs[key] === 'true' ? 'false' : 'true';
break;
@@ -70,7 +82,12 @@ const SystemSetting = () => {
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
if (key === 'EmailDomainWhitelist') {
value = value.split(',');
}
setInputs((inputs) => ({
...inputs, [key]: value
}));
} else {
showError(message);
}
@@ -88,7 +105,8 @@ const SystemSetting = () => {
name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey'
name === 'TurnstileSecretKey' ||
name === 'EmailDomainWhitelist'
) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
} else {
@@ -125,6 +143,16 @@ const SystemSetting = () => {
}
};
const submitEmailDomainWhitelist = async () => {
if (
originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') &&
inputs.SMTPToken !== ''
) {
await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(','));
}
};
const submitWeChat = async () => {
if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
await updateOption(
@@ -173,6 +201,22 @@ const SystemSetting = () => {
}
};
const submitNewRestrictedDomain = () => {
const localDomainList = inputs.EmailDomainWhitelist;
if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) {
setRestrictedDomainInput('');
setInputs({
...inputs,
EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
});
setEmailDomainWhitelist([...EmailDomainWhitelist, {
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
}]);
}
}
return (
<Grid columns={1}>
<Grid.Column>
@@ -239,6 +283,54 @@ const SystemSetting = () => {
/>
</Form.Group>
<Divider />
<Header as='h3'>
配置邮箱域名白名单
<Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Checkbox
label='启用邮箱域名白名单'
name='EmailDomainRestrictionEnabled'
onChange={handleInputChange}
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
/>
</Form.Group>
<Form.Group widths={2}>
<Form.Dropdown
label='允许的邮箱域名'
placeholder='允许的邮箱域名'
name='EmailDomainWhitelist'
required
fluid
multiple
selection
onChange={handleInputChange}
value={inputs.EmailDomainWhitelist}
autoComplete='new-password'
options={EmailDomainWhitelist}
/>
<Form.Input
label='添加新的允许的邮箱域名'
action={
<Button type='button' onClick={() => {
submitNewRestrictedDomain();
}}>填入</Button>
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitNewRestrictedDomain();
}
}}
autoComplete='new-password'
placeholder='输入新的允许的邮箱域名'
value={restrictedDomainInput}
onChange={(e, { value }) => {
setRestrictedDomainInput(value);
}}
/>
</Form.Group>
<Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>
<Divider />
<Header as='h3'>
配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
@@ -284,7 +376,7 @@ const SystemSetting = () => {
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.SMTPToken}
checked={inputs.RegisterEnabled === 'true'}
placeholder='敏感信息不会发送到前端显示'
/>
</Form.Group>

View File

@@ -1,11 +1,22 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Modal, Pagination, Popup, Table } from 'semantic-ui-react';
import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
const COPY_OPTIONS = [
{ key: 'next', text: 'ChatGPT Next Web', value: 'next' },
{ key: 'ama', text: 'AMA 问天', value: 'ama' },
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
];
const OPEN_LINK_OPTIONS = [
{ key: 'ama', text: 'AMA 问天', value: 'ama' },
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
];
function renderTimestamp(timestamp) {
return (
<>
@@ -68,6 +79,84 @@ const TokensTable = () => {
const refresh = async () => {
setLoading(true);
await loadTokens(activePage - 1);
};
const onCopy = async (type, key) => {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
const nextLink = localStorage.getItem('chat_link');
let nextUrl;
if (nextLink) {
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}"}`;
} else {
nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
let url;
switch (type) {
case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break;
case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break;
case 'next':
url = nextUrl;
break;
default:
url = `sk-${key}`;
}
if (await copy(url)) {
showSuccess('已复制到剪贴板!');
} else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
setSearchKeyword(url);
}
};
const onOpenLink = async (type, key) => {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
status = JSON.parse(status);
serverAddress = status.server_address;
}
if (serverAddress === '') {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
const chatLink = localStorage.getItem('chat_link');
let defaultUrl;
if (chatLink) {
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}"}`;
} else {
defaultUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
let url;
switch (type) {
case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break;
case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break;
default:
url = defaultUrl;
}
window.open(url, '_blank');
}
useEffect(() => {
@@ -235,21 +324,51 @@ const TokensTable = () => {
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
positive
onClick={async () => {
let key = "sk-" + token.key;
if (await copy(key)) {
showSuccess('已复制到剪贴板!');
} else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
setSearchKeyword(key);
}
}}
>
复制
</Button>
<Button.Group color='green' size={'small'}>
<Button
size={'small'}
positive
onClick={async () => {
await onCopy('', token.key);
}}
>
复制
</Button>
<Dropdown
className='button icon'
floating
options={COPY_OPTIONS.map(option => ({
...option,
onClick: async () => {
await onCopy(option.value, token.key);
}
}))}
trigger={<></>}
/>
</Button.Group>
{' '}
<Button.Group color='blue' size={'small'}>
<Button
size={'small'}
positive
onClick={() => {
onOpenLink('', token.key);
}}>
聊天
</Button>
<Dropdown
className="button icon"
floating
options={OPEN_LINK_OPTIONS.map(option => ({
...option,
onClick: async () => {
await onOpenLink(option.value, token.key);
}
}))}
trigger={<></>}
/>
</Button.Group>
{' '}
<Popup
trigger={
<Button size='small' negative>

View File

@@ -5,7 +5,7 @@ export const CHANNEL_OPTIONS = [
{ key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },
{ key: 15, text: '百度文心千帆', value: 15, color: 'blue' },
{ key: 17, text: '阿里通义千问', value: 17, color: 'orange' },
{ key: 18, text: '讯飞星火认知大模型', value: 18, color: 'blue' },
{ key: 18, text: '讯飞星火认知', value: 18, color: 'blue' },
{ key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },
{ key: 8, text: '自定义渠道', value: 8, color: 'pink' },
{ key: 2, text: '代理API2D', value: 2, color: 'blue' },

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@@ -17,11 +17,13 @@ const EditToken = () => {
};
const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
const navigate = useNavigate();
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const handleCancel = () => {
navigate("/token");
}
const setExpiredTime = (month, day, hour, minute) => {
let now = new Date();
let timestamp = now.getTime() / 1000;
@@ -150,8 +152,9 @@ const EditToken = () => {
</Form.Field>
<Button type={'button'} onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
<Button positive onClick={submit}>提交</Button>
}}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
<Button floated='right' positive onClick={submit}>提交</Button>
<Button floated='right' onClick={handleCancel}>取消</Button>
</Form>
</Segment>
</>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@@ -36,7 +36,10 @@ const EditUser = () => {
showError(error.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
navigate("/setting");
}
const loadUser = async () => {
let res = undefined;
if (userId) {
@@ -176,6 +179,7 @@ const EditUser = () => {
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>取消</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>