diff --git a/README.en.md b/README.en.md index c43ee3af..2ea6e65e 100644 --- a/README.en.md +++ b/README.en.md @@ -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,6 +173,10 @@ If you encounter a blank page after deployment, refer to [#97](https://github.co Deploy on Sealos
+> 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) diff --git a/README.md b/README.md index a7c06fc5..ad90bb15 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 如果上面的镜像无法拉取,可以尝试使用 GitHub 的 Docker 镜像,将上面的 `justsong/one-api` 替换为 `ghcr.io/songquanpeng/one-api` 即可。 -如果你的并发量较大,推荐设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。 +如果你的并发量较大,**务必**设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。 更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR` @@ -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 部署到 Sealos
-> Sealos 可视化一键部署。 +> Sealos 的服务器在国外,不需要额外处理网络问题,支持高并发 & 动态伸缩。 -[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) +点击以下按钮一键部署(部署后访问出现 404 请等待 3~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)
diff --git a/controller/relay-openai.go b/controller/relay-openai.go index 6de2bd8e..808b224d 100644 --- a/controller/relay-openai.go +++ b/controller/relay-openai.go @@ -111,7 +111,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) @@ -151,5 +151,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 } diff --git a/controller/relay-text.go b/controller/relay-text.go index 45197f43..c5f48040 100644 --- a/controller/relay-text.go +++ b/controller/relay-text.go @@ -302,7 +302,7 @@ 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") } if resp.StatusCode != http.StatusOK { return errorWrapper( @@ -366,7 +366,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { 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 } diff --git a/controller/relay-xunfei.go b/controller/relay-xunfei.go index c6d78a84..1faf3294 100644 --- a/controller/relay-xunfei.go +++ b/controller/relay-xunfei.go @@ -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 } @@ -222,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 { diff --git a/controller/relay.go b/controller/relay.go index 795dd3e7..653511b7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -82,8 +82,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 { diff --git a/i18n/en.json b/i18n/en.json index 5b836c9c..3727df0c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", @@ -591,5 +597,7 @@ "请输入渠道对应的鉴权密钥": "Please enter the authentication key corresponding to the channel", "注意,": "Note that, ", ",图片演示。": "related image demo.", - "令牌创建成功,请在列表页面点击复制获取令牌!": "Token created successfully, please click copy on the list page to get the token!" + "令牌创建成功,请在列表页面点击复制获取令牌!": "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" } diff --git a/router/api-router.go b/router/api-router.go index dad417b1..8bb31f8e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -38,7 +38,7 @@ func SetApiRouter(router *gin.Engine) { { selfRoute.GET("/self", controller.GetSelf) selfRoute.PUT("/self", controller.UpdateSelf) - selfRoute.DELETE("/self", middleware.TurnstileCheck(), controller.DeleteSelf) + selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.POST("/topup", controller.TopUp) diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js index 2d9ca850..90b2e13d 100644 --- a/web/src/components/PersonalSetting.js +++ b/web/src/components/PersonalSetting.js @@ -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('请输入你的账户名以确认删除!'); @@ -180,6 +198,25 @@ const PersonalSetting = () => { + + {systemToken && ( + + )} + {affLink && ( + + )}
账号绑定
{ @@ -285,6 +322,7 @@ const PersonalSetting = () => { ) : ( <> )} +
+
+ +
@@ -305,8 +352,9 @@ const PersonalSetting = () => { size={'tiny'} style={{ maxWidth: '450px' }} > - 确认删除自己的帐户 + 危险操作 + 您正在删除自己的帐户,将清空所有数据且不可恢复
{ ) : ( <> )} - +
+ +
+ +
diff --git a/web/src/components/TokensTable.js b/web/src/components/TokensTable.js index b42f7df8..b45f07df 100644 --- a/web/src/components/TokensTable.js +++ b/web/src/components/TokensTable.js @@ -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 = () => { {token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}
- + + + ({ + ...option, + onClick: async () => { + await onCopy(option.value, token.key); + } + }))} + trigger={<>} + /> + + {' '} + + + ({ + ...option, + onClick: async () => { + await onOpenLink(option.value, token.key); + } + }))} + trigger={<>} + /> + + {' '} diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 17b84bf4..910b0dc3 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -401,9 +401,9 @@ const EditChannel = () => { inputs.type !== 3 && inputs.type !== 8 && ( { }; 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 = () => { - + }}>{unlimited_quota ? '取消无限额度' : '设为无限额度'} + + diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index 683fea65..5c1fdd3b 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -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'; @@ -38,7 +38,10 @@ const EditUser = () => { showError(error.message); } }; - + const navigate = useNavigate(); + const handleCancel = () => { + navigate("/setting"); + } const loadUser = async () => { let res = undefined; if (userId) { @@ -198,6 +201,7 @@ const EditUser = () => { readOnly /> +