diff --git a/Dockerfile b/Dockerfile index 4afbf100..22055553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /build COPY ./web . COPY ./VERSION . RUN npm install -RUN REACT_APP_VERSION=$(cat VERSION) npm run build +RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build FROM golang AS builder2 diff --git a/README.en.md b/README.en.md index 92eb567f..8a041da8 100644 --- a/README.en.md +++ b/README.en.md @@ -190,7 +190,7 @@ If you encounter a blank page after deployment, refer to [#97](https://github.co > Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage. 1. First, fork the code. -2. Go to [Zeabur](https://zeabur.com/), log in, and enter the console. +2. Go to [Zeabur](https://zeabur.com?referralCode=songquanpeng), log in, and enter the console. 3. Create a new project. In Service -> Add Service, select Marketplace, and choose MySQL. Note down the connection parameters (username, password, address, and port). 4. Copy the connection parameters and run ```create database `one-api` ``` to create the database. 5. Then, in Service -> Add Service, select Git (authorization is required for the first use) and choose your forked repository. diff --git a/README.md b/README.md index 9840fa19..02127100 100644 --- a/README.md +++ b/README.md @@ -102,16 +102,16 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ### 基于 Docker 进行部署 部署命令:`docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api` +其中,`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 + +数据将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 + 如果上面的镜像无法拉取,可以尝试使用 GitHub 的 Docker 镜像,将上面的 `justsong/one-api` 替换为 `ghcr.io/songquanpeng/one-api` 即可。 如果你的并发量较大,**务必**设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。 更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR` -`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 - -数据将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 - Nginx 的参考配置: ``` server{ @@ -227,7 +227,7 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope > Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用。 1. 首先 fork 一份代码。 -2. 进入 [Zeabur](https://zeabur.com/),登录,进入控制台。 +2. 进入 [Zeabur](https://zeabur.com?referralCode=songquanpeng),登录,进入控制台。 3. 新建一个 Project,在 Service -> Add Service 选择 Marketplace,选择 MySQL,并记下连接参数(用户名、密码、地址、端口)。 4. 复制链接参数,运行 ```create database `one-api` ``` 创建数据库。 5. 然后在 Service -> Add Service,选择 Git(第一次使用需要先授权),选择你 fork 的仓库。 @@ -281,6 +281,11 @@ graph LR + 注意需要提前建立数据库 `oneapi`,无需手动建表,程序将自动建表。 + 如果使用本地数据库:部署命令可添加 `--network="host"` 以使得容器内的程序可以访问到宿主机上的 MySQL。 + 如果使用云数据库:如果云服务器需要验证身份,需要在连接参数中添加 `?tls=skip-verify`。 + + 请根据你的数据库配置修改下列参数(或者保持默认值): + + `SQL_MAX_IDLE_CONNS`:最大空闲连接数,默认为 `10`。 + + `SQL_MAX_OPEN_CONNS`:最大打开连接数,默认为 `100`。 + + 如果报错 `Error 1040: Too many connections`,请适当减小该值。 + + `SQL_CONN_MAX_LIFETIME`:连接的最大生命周期,默认为 `60`,单位分钟。 4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。 + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn` 5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。 diff --git a/common/utils.go b/common/utils.go index 1329c1a0..bb9b7e0c 100644 --- a/common/utils.go +++ b/common/utils.go @@ -7,6 +7,7 @@ import ( "log" "math/rand" "net" + "os" "os/exec" "runtime" "strconv" @@ -177,3 +178,15 @@ func Max(a int, b int) int { return b } } + +func GetOrDefault(env string, defaultValue int) int { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue)) + return defaultValue + } + return num +} diff --git a/controller/relay-ali.go b/controller/relay-ali.go index e8437c27..e94abd6a 100644 --- a/controller/relay-ali.go +++ b/controller/relay-ali.go @@ -121,7 +121,10 @@ func responseAli2OpenAI(response *AliChatResponse) *OpenAITextResponse { func streamResponseAli2OpenAI(aliResponse *AliChatResponse) *ChatCompletionsStreamResponse { var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = aliResponse.Output.Text - choice.FinishReason = aliResponse.Output.FinishReason + if aliResponse.Output.FinishReason != "null" { + finishReason := aliResponse.Output.FinishReason + choice.FinishReason = &finishReason + } response := ChatCompletionsStreamResponse{ Id: aliResponse.RequestId, Object: "chat.completion.chunk", diff --git a/controller/relay-baidu.go b/controller/relay-baidu.go index 7960e8ee..664bbd11 100644 --- a/controller/relay-baidu.go +++ b/controller/relay-baidu.go @@ -120,7 +120,9 @@ func responseBaidu2OpenAI(response *BaiduChatResponse) *OpenAITextResponse { func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *ChatCompletionsStreamResponse { var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = baiduResponse.Result - choice.FinishReason = "stop" + if baiduResponse.IsEnd { + choice.FinishReason = &stopFinishReason + } response := ChatCompletionsStreamResponse{ Id: baiduResponse.Id, Object: "chat.completion.chunk", diff --git a/controller/relay-claude.go b/controller/relay-claude.go index 1d67fa7b..052e5605 100644 --- a/controller/relay-claude.go +++ b/controller/relay-claude.go @@ -81,7 +81,10 @@ func requestOpenAI2Claude(textRequest GeneralOpenAIRequest) *ClaudeRequest { func streamResponseClaude2OpenAI(claudeResponse *ClaudeResponse) *ChatCompletionsStreamResponse { var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = claudeResponse.Completion - choice.FinishReason = stopReasonClaude2OpenAI(claudeResponse.StopReason) + finishReason := stopReasonClaude2OpenAI(claudeResponse.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } var response ChatCompletionsStreamResponse response.Object = "chat.completion.chunk" response.Model = claudeResponse.Model diff --git a/controller/relay-palm.go b/controller/relay-palm.go index 74624c7f..0053c9b8 100644 --- a/controller/relay-palm.go +++ b/controller/relay-palm.go @@ -94,7 +94,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *ChatCompletionsS if len(palmResponse.Candidates) > 0 { choice.Delta.Content = palmResponse.Candidates[0].Content } - choice.FinishReason = "stop" + choice.FinishReason = &stopFinishReason var response ChatCompletionsStreamResponse response.Object = "chat.completion.chunk" response.Model = "palm2" diff --git a/controller/relay-utils.go b/controller/relay-utils.go index 2133d8be..3695e119 100644 --- a/controller/relay-utils.go +++ b/controller/relay-utils.go @@ -6,6 +6,8 @@ import ( "one-api/common" ) +var stopFinishReason = "stop" + var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} func getTokenEncoder(model string) *tiktoken.Tiktoken { diff --git a/controller/relay-xunfei.go b/controller/relay-xunfei.go index 1faf3294..48472456 100644 --- a/controller/relay-xunfei.go +++ b/controller/relay-xunfei.go @@ -138,6 +138,9 @@ func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *ChatComple } var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = xunfeiResponse.Payload.Choices.Text[0].Content + if xunfeiResponse.Payload.Choices.Status == 2 { + choice.FinishReason = &stopFinishReason + } response := ChatCompletionsStreamResponse{ Object: "chat.completion.chunk", Created: common.GetTimestamp(), diff --git a/controller/relay-zhipu.go b/controller/relay-zhipu.go index 20a4fa42..b125f1e7 100644 --- a/controller/relay-zhipu.go +++ b/controller/relay-zhipu.go @@ -163,7 +163,6 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *OpenAITextResponse { func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResponse { var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = zhipuResponse - choice.FinishReason = "" response := ChatCompletionsStreamResponse{ Object: "chat.completion.chunk", Created: common.GetTimestamp(), @@ -176,7 +175,7 @@ func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResp func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*ChatCompletionsStreamResponse, *Usage) { var choice ChatCompletionsStreamResponseChoice choice.Delta.Content = "" - choice.FinishReason = "stop" + choice.FinishReason = &stopFinishReason response := ChatCompletionsStreamResponse{ Id: zhipuResponse.RequestId, Object: "chat.completion.chunk", diff --git a/controller/relay.go b/controller/relay.go index 617e22b8..86f16c45 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -124,7 +124,7 @@ type ChatCompletionsStreamResponseChoice struct { Delta struct { Content string `json:"content"` } `json:"delta"` - FinishReason string `json:"finish_reason,omitempty"` + FinishReason *string `json:"finish_reason"` } type ChatCompletionsStreamResponse struct { @@ -176,7 +176,7 @@ func Relay(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s?retry=%d", c.Request.URL.Path, retryTimes-1)) } else { if err.StatusCode == http.StatusTooManyRequests { - err.OpenAIError.Message = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。" + err.OpenAIError.Message = "当前分组上游负载已饱和,请稍后再试" } c.JSON(err.StatusCode, gin.H{ "error": err.OpenAIError, diff --git a/controller/token.go b/controller/token.go index 5341ea3a..b05d820a 100644 --- a/controller/token.go +++ b/controller/token.go @@ -109,10 +109,10 @@ func AddToken(c *gin.Context) { }) return } - if len(token.Name) == 0 || len(token.Name) > 20 { + if len(token.Name) == 0 || len(token.Name) > 30 { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "令牌名称长度必须在1-20之间", + "message": "令牌名称过长", }) return } @@ -171,6 +171,13 @@ func UpdateToken(c *gin.Context) { }) return } + if len(token.Name) == 0 || len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } cleanToken, err := model.GetTokenByIds(token.Id, userId) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/i18n/en.json b/i18n/en.json index f53aad4c..67ce8a56 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -39,8 +39,8 @@ "兑换码个数必须大于0": "The number of redemption codes must be greater than 0", "一次兑换码批量生成的个数不能大于 100": "The number of redemption codes generated in a batch cannot be greater than 100", "通过令牌「%s」使用模型 %s 消耗 %s(模型倍率 %.2f,分组倍率 %.2f)": "Using model %s with token %s consumes %s (model rate %.2f, group rate %.2f)", - "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。": "The current group load is saturated, please try again later, or upgrade your account to improve service quality.", - "令牌名称长度必须在1-20之间": "The length of the token name must be between 1-20", + "当前分组上游负载已饱和,请稍后再试": "The current group load is saturated, please try again later", + "令牌名称过长": "Token name is too long", "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期": "The token has expired and cannot be enabled. Please modify the expiration time of the token, or set it to never expire.", "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度": "The available quota of the token has been used up and cannot be enabled. Please modify the remaining quota of the token, or set it to unlimited quota", "管理员关闭了密码登录": "The administrator has turned off password login", @@ -229,7 +229,7 @@ "已是最新版本": "Is the latest version", "检查更新": "Check for updates", "公告": "Announcement", - "在此输入新的公告内容": "Enter new announcement content here", + "在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code", "保存公告": "Save Announcement", "个性化设置": "Personalization Settings", "系统名称": "System Name", @@ -518,5 +518,6 @@ ",图片演示。": "related image demo.", "令牌创建成功,请在列表页面点击复制获取令牌!": "Token created successfully, please click copy on the list page to get the token!", "代理": "Proxy", - "此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com": "This is optional, used to make API calls through the proxy site, please enter the proxy site address, the format is: https://domain.com" + "此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com": "This is optional, used to make API calls through the proxy site, please enter the proxy site address, the format is: https://domain.com", + "取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?": "Canceling password login will cause all users (including administrators) who have not bound other login methods to be unable to log in via password, confirm cancel?" } diff --git a/model/main.go b/model/main.go index 5bc5ce19..ddbc69aa 100644 --- a/model/main.go +++ b/model/main.go @@ -6,6 +6,7 @@ import ( "gorm.io/gorm" "one-api/common" "os" + "time" ) var DB *gorm.DB @@ -57,10 +58,18 @@ func InitDB() (err error) { common.SysLog("database connected") if err == nil { DB = db + sqlDB, err := DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetOrDefault("SQL_MAX_IDLE_CONNS", 10)) + sqlDB.SetMaxOpenConns(common.GetOrDefault("SQL_MAX_OPEN_CONNS", 100)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetOrDefault("SQL_MAX_LIFETIME", 60))) + if !common.IsMasterNode { return nil } - err := db.AutoMigrate(&Channel{}) + err = db.AutoMigrate(&Channel{}) if err != nil { return err } diff --git a/web/src/components/ChannelsTable.js b/web/src/components/ChannelsTable.js index 0459619a..072f5b90 100644 --- a/web/src/components/ChannelsTable.js +++ b/web/src/components/ChannelsTable.js @@ -447,8 +447,8 @@ const ChannelsTable = () => { 测试所有已启用通道 - 更新所有已启用通道余额 + {/* 更新所有已启用通道余额 */} { { > 复制 - { - manageRedemption(redemption.id, 'delete', idx); - }} + + 删除 + + } + on='click' + flowing + hoverable > - 删除 - + { + manageRedemption(redemption.id, 'delete', idx); + }} + > + 确认删除 + + { @@ -33,6 +33,7 @@ const SystemSetting = () => { let [loading, setLoading] = useState(false); const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]); const [restrictedDomainInput, setRestrictedDomainInput] = useState(''); + const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false); const getOptions = async () => { const res = await API.get('/api/option/'); @@ -95,6 +96,11 @@ const SystemSetting = () => { }; const handleInputChange = async (e, { name, value }) => { + if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') { + // block disabling password login + setShowPasswordWarningModal(true); + return; + } if ( name === 'Notice' || name.startsWith('SMTP') || @@ -243,6 +249,32 @@ const SystemSetting = () => { name='PasswordLoginEnabled' onChange={handleInputChange} /> + { + showPasswordWarningModal && + setShowPasswordWarningModal(false)} + size={'tiny'} + style={{ maxWidth: '450px' }} + > + 警告 + + 取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消? + + + setShowPasswordWarningModal(false)}>取消 + { + setShowPasswordWarningModal(false); + await updateOption('PasswordLoginEnabled', 'false'); + }} + > + 确定 + + + + } { + return ; +}; +export default HTMLToastContent; export function isAdmin() { let user = localStorage.getItem('user'); if (!user) return false; @@ -107,8 +112,12 @@ export function showInfo(message) { toast.info(message, showInfoOptions); } -export function showNotice(message) { - toast.info(message, showNoticeOptions); +export function showNotice(message, isHTML = false) { + if (isHTML) { + toast(, showNoticeOptions); + } else { + toast.info(message, showNoticeOptions); + } } export function openPage(url) { diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 4cfec018..0d7a4a01 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react'; -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { API, showError, showInfo, showSuccess, verifyJSON } from '../../helpers'; import { CHANNEL_OPTIONS } from '../../constants'; @@ -12,9 +12,14 @@ const MODEL_MAPPING_EXAMPLE = { const EditChannel = () => { const params = useParams(); + const navigate = useNavigate(); const channelId = params.id; const isEdit = channelId !== undefined; const [loading, setLoading] = useState(isEdit); + const handleCancel = () => { + navigate('/channel'); + }; + const originInputs = { name: '', type: 1, @@ -381,6 +386,7 @@ const EditChannel = () => { ) } + 取消 提交 diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js index 20d42104..c9f4d445 100644 --- a/web/src/pages/Home/index.js +++ b/web/src/pages/Home/index.js @@ -14,10 +14,11 @@ const Home = () => { const { success, message, data } = res.data; if (success) { let oldNotice = localStorage.getItem('notice'); - if (data !== oldNotice && data !== '') { - showNotice(data); - localStorage.setItem('notice', data); - } + if (data !== oldNotice && data !== '') { + const htmlNotice = marked(data); + showNotice(htmlNotice, true); + localStorage.setItem('notice', data); + } } else { showError(message); } diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js index df614ab5..7a33f770 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/pages/Redemption/EditRedemption.js @@ -1,11 +1,12 @@ 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, downloadTextAsFile, showError, showSuccess } from '../../helpers'; import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; const EditRedemption = () => { const params = useParams(); + const navigate = useNavigate(); const redemptionId = params.id; const isEdit = redemptionId !== undefined; const [loading, setLoading] = useState(isEdit); @@ -17,6 +18,10 @@ const EditRedemption = () => { const [inputs, setInputs] = useState(originInputs); const { name, quota, count } = inputs; + const handleCancel = () => { + navigate('/redemption'); + }; + const handleInputChange = (e, { name, value }) => { setInputs((inputs) => ({ ...inputs, [name]: value })); }; @@ -113,6 +118,7 @@ const EditRedemption = () => { > } 提交 + 取消 >
取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?