mirror of
				https://github.com/songquanpeng/one-api.git
				synced 2025-10-27 11:53:42 +08:00 
			
		
		
		
	Compare commits
	
		
			137 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 66e02a4bcf | ||
|  | cf564f36fa | ||
|  | 43f8d5fd92 | ||
|  | 15d9a0c177 | ||
|  | 17125448cb | ||
|  | a891a3e64f | ||
|  | fd72565011 | ||
|  | 4b9756b257 | ||
|  | a6ae20ed54 | ||
|  | 617149d731 | ||
|  | edd2c4f6e9 | ||
|  | 481c4ebf49 | ||
|  | 203471d7a9 | ||
|  | 7ad6f7d99d | ||
|  | 68abcd48ab | ||
|  | 0c175b4e44 | ||
|  | 4139a7036f | ||
|  | 4e31c3991d | ||
|  | 5b8a826cf9 | ||
|  | f5f21dffd8 | ||
|  | 4e94c85a9a | ||
|  | caabdd1e21 | ||
|  | 0424baef6a | ||
|  | 256d290507 | ||
|  | 8f0799d909 | ||
|  | 02da0b51f8 | ||
|  | 35cfebee12 | ||
|  | 0e088f7c3e | ||
|  | f61d326721 | ||
|  | 74b06b643a | ||
|  | ccf7709e23 | ||
|  | d592e2c8b8 | ||
|  | b520b54625 | ||
|  | 81c5901123 | ||
|  | abc53cb208 | ||
|  | 349e3a3661 | ||
|  | 8cc7f983e1 | ||
|  | 455643e317 | ||
|  | 1c7bad7b87 | ||
|  | 3141292026 | ||
|  | e4500bf8bf | ||
|  | 4043fccedb | ||
|  | 164df4e708 | ||
|  | d850f465cd | ||
|  | e2f5c1eb8c | ||
|  | d68aa4c96f | ||
|  | 47cb77de53 | ||
|  | 61912f5e2c | ||
|  | 2b17bb8dd7 | ||
|  | ea73201b6f | ||
|  | 379d03798c | ||
|  | 520eb34b72 | ||
|  | 855bb82ae7 | ||
|  | 8c91bd9c97 | ||
|  | 7c7a45a4f5 | ||
|  | 0ac0214c41 | ||
|  | b63400ebe2 | ||
|  | b17d9bc649 | ||
|  | 9ef8167e5d | ||
|  | 3baad1d926 | ||
|  | 80d5d6edfb | ||
|  | 12365ccf69 | ||
|  | 4928319494 | ||
|  | 839dcc3ab2 | ||
|  | 270e366cd9 | ||
|  | 67b8e82457 | ||
|  | 4b2cb573b6 | ||
|  | bde43cc358 | ||
|  | 6e06dcfcf8 | ||
|  | b617599211 | ||
|  | 1656b9a1de | ||
|  | eb5f8f2d75 | ||
|  | 2ae5741214 | ||
|  | 28d58849a0 | ||
|  | adc9679d56 | ||
|  | 07589ae305 | ||
|  | 95bc32c555 | ||
|  | d61dc4a9ca | ||
|  | b29acb0c89 | ||
|  | a8e418275d | ||
|  | d7ab9b0935 | ||
|  | ebd62c3bfc | ||
|  | 4e6f9f67b3 | ||
|  | 77d295bbf5 | ||
|  | 7e3e25fbd9 | ||
|  | 9bf98ab53a | ||
|  | 3ed9a219c7 | ||
|  | 37d7afcedc | ||
|  | 2756554f7c | ||
|  | 6215d2e71c | ||
|  | d17bdc40a7 | ||
|  | 280df27705 | ||
|  | 991f5bf4ee | ||
|  | 701aaba191 | ||
|  | 3bab5b48bf | ||
|  | f3bccee3b5 | ||
|  | d84b0b0f5d | ||
|  | d383302e8a | ||
|  | 04f40def2f | ||
|  | c48b7bc0f5 | ||
|  | b09daf5ec1 | ||
|  | c90c0ecef4 | ||
|  | 1ab5fb7d2d | ||
|  | f769711c19 | ||
|  | edc5156693 | ||
|  | 9ec6506c32 | ||
|  | f387cc5ead | ||
|  | 569b68c43b | ||
|  | f0c40a6cd0 | ||
|  | 0cea9e6a6f | ||
|  | b1b3651e84 | ||
|  | 8f6bd51f58 | ||
|  | bddbf57104 | ||
|  | 9a16b0f9e5 | ||
|  | 3530309a31 | ||
|  | 733ebc067b | ||
|  | 6a8567ac14 | ||
|  | aabc546691 | ||
|  | 1c82b06f35 | ||
|  | 9e4109672a | ||
|  | 64c35334e6 | ||
|  | 0ce572b405 | ||
|  | a326ac4b28 | ||
|  | 05b0e77839 | ||
|  | 51f19470bc | ||
|  | 737672fb0b | ||
|  | 0941e294bf | ||
|  | 431d505f79 | ||
|  | f0dc7f3f06 | ||
|  | 99fed1f850 | ||
|  | 4dc5388a80 | ||
|  | f81f4c60b2 | ||
|  | c613d8b6b2 | ||
|  | 7adac1c09c | ||
|  | 6f05128368 | ||
|  | 9b178a28a3 | ||
|  | 4a6a7f4635 | 
							
								
								
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -8,11 +8,13 @@ assignees: '' | ||||
| --- | ||||
|  | ||||
| **例行检查** | ||||
|  | ||||
| [//]: # (方框内删除已有的空格,填 x 号) | ||||
| + [ ] 我已确认目前没有类似 issue | ||||
| + [ ] 我已确认我已升级到最新版本 | ||||
| + [ ] 我已完整查看过项目 README,尤其是常见问题部分 | ||||
| + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈  | ||||
| + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭 | ||||
| + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** | ||||
|  | ||||
| **问题描述** | ||||
|  | ||||
|   | ||||
							
								
								
									
										3
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,6 +6,3 @@ contact_links: | ||||
|   - name: 赞赏支持 | ||||
|     url: https://iamazing.cn/page/reward | ||||
|     about: 请作者喝杯咖啡,以激励作者持续开发 | ||||
|   - name: 付费部署或定制功能 | ||||
|     url: https://openai.justsong.cn/ | ||||
|     about: 加群后联系群主 | ||||
|   | ||||
							
								
								
									
										5
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							| @@ -8,10 +8,13 @@ assignees: '' | ||||
| --- | ||||
|  | ||||
| **例行检查** | ||||
|  | ||||
| [//]: # (方框内删除已有的空格,填 x 号) | ||||
| + [ ] 我已确认目前没有类似 issue | ||||
| + [ ] 我已确认我已升级到最新版本 | ||||
| + [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求 | ||||
| + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 | ||||
| + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭 | ||||
| + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** | ||||
|  | ||||
| **功能描述** | ||||
|  | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/docker-image-amd64-en.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-amd64-en.yml
									
									
									
									
										vendored
									
									
								
							| @@ -38,7 +38,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api-en | ||||
|             ckt1031/one-api-en | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|         uses: docker/build-push-action@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/docker-image-amd64.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-amd64.yml
									
									
									
									
										vendored
									
									
								
							| @@ -42,7 +42,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api | ||||
|             ckt1031/one-api | ||||
|             ghcr.io/${{ github.repository }} | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/docker-image-arm64.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-arm64.yml
									
									
									
									
										vendored
									
									
								
							| @@ -49,7 +49,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api | ||||
|             ckt1031/one-api | ||||
|             ghcr.io/${{ github.repository }} | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/linux-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/linux-release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -24,7 +24,7 @@ jobs: | ||||
|         run: | | ||||
|           cd web | ||||
|           npm install | ||||
|           REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           VITE_REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           cd .. | ||||
|       - name: Set up Go | ||||
|         uses: actions/setup-go@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/macos-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/macos-release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -24,7 +24,7 @@ jobs: | ||||
|         run: | | ||||
|           cd web | ||||
|           npm install | ||||
|           REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           VITE_REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           cd .. | ||||
|       - name: Set up Go | ||||
|         uses: actions/setup-go@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/windows-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/windows-release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -27,7 +27,7 @@ jobs: | ||||
|         run: | | ||||
|           cd web | ||||
|           npm install | ||||
|           REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           VITE_REACT_APP_VERSION=$(git describe --tags) npm run build | ||||
|           cd .. | ||||
|       - name: Set up Go | ||||
|         uses: actions/setup-go@v3 | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,3 +5,4 @@ upload | ||||
| *.db | ||||
| build | ||||
| *.db-journal | ||||
| .env* | ||||
|   | ||||
							
								
								
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,31 +1,29 @@ | ||||
| FROM node:16 as builder | ||||
|  | ||||
| # Node build stage | ||||
| FROM node:18 as builder | ||||
| WORKDIR /build | ||||
| COPY ./web/package*.json ./ | ||||
| RUN npm ci | ||||
| COPY ./web . | ||||
| COPY ./VERSION . | ||||
| RUN npm install | ||||
| RUN REACT_APP_VERSION=$(cat VERSION) npm run build | ||||
| RUN VITE_REACT_APP_VERSION=$(cat VERSION) npm run build | ||||
|  | ||||
| # Go build stage | ||||
| FROM golang AS builder2 | ||||
|  | ||||
| ENV GO111MODULE=on \ | ||||
|     CGO_ENABLED=1 \ | ||||
|     GOOS=linux | ||||
|  | ||||
| WORKDIR /build | ||||
| COPY go.mod . | ||||
| COPY go.sum . | ||||
| RUN go mod download | ||||
| COPY . . | ||||
| 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 | ||||
|  | ||||
| # Final stage | ||||
| FROM alpine | ||||
|  | ||||
| RUN apk update \ | ||||
|     && apk upgrade \ | ||||
|     && apk add --no-cache ca-certificates tzdata \ | ||||
|     && update-ca-certificates 2>/dev/null || true | ||||
|  | ||||
| RUN apk update && apk upgrade && apk add --no-cache ca-certificates tzdata && update-ca-certificates 2>/dev/null || true | ||||
| WORKDIR /data | ||||
| COPY --from=builder2 /build/one-api / | ||||
| EXPOSE 3000 | ||||
| WORKDIR /data | ||||
| ENTRYPOINT ["/one-api"] | ||||
|   | ||||
							
								
								
									
										26
									
								
								README.en.md
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								README.en.md
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | ||||
|  | ||||
| # One API | ||||
|  | ||||
| _✨ The all-in-one OpenAI interface, integrates various API access methods, ready to use ✨_ | ||||
| _✨ An OpenAI key management & redistribution system, easy to deploy & use ✨_ | ||||
|  | ||||
| </div> | ||||
|  | ||||
| @@ -57,17 +57,14 @@ _✨ The all-in-one OpenAI interface, integrates various API access methods, rea | ||||
| > **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability. | ||||
|  | ||||
| ## Features | ||||
| 1. Supports multiple API access channels. Welcome PRs or issue submissions for additional channels: | ||||
| 1. Supports multiple API access channels: | ||||
|     + [x] Official OpenAI channel (support proxy configuration) | ||||
|     + [x] **Azure OpenAI API** | ||||
|     + [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj) | ||||
|     + [x] [OpenAI-SB](https://openai-sb.com) | ||||
|     + [x] [API2D](https://api2d.com/r/197971) | ||||
|     + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf) | ||||
|     + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (invitation code: `OneAPI`) | ||||
|     + [x] [API2GPT](http://console.api2gpt.com/m/00002S) | ||||
|     + [x] [CloseAI](https://console.closeai-asia.com/r/2412) | ||||
|     + [x] [AI.LS](https://ai.ls) | ||||
|     + [x] [OpenAI Max](https://openaimax.com) | ||||
|     + [x] Custom channel: Various third-party proxy services not included in the list | ||||
| 2. Supports access to multiple channels through **load balancing**. | ||||
| 3. Supports **stream mode** that enables typewriter-like effect through stream transmission. | ||||
| @@ -174,6 +171,15 @@ Refer to [#175](https://github.com/songquanpeng/one-api/issues/175) for detailed | ||||
| If you encounter a blank page after deployment, refer to [#97](https://github.com/songquanpeng/one-api/issues/97) for possible solutions. | ||||
|  | ||||
| ### Deployment on Third-Party Platforms | ||||
| <details> | ||||
| <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). | ||||
|  | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| <details> | ||||
| <summary><strong>Deployment on Zeabur</strong></summary> | ||||
| <div> | ||||
| @@ -240,7 +246,7 @@ If the channel ID is not provided, load balancing will be used to distribute the | ||||
|     + Example: `CHANNEL_UPDATE_FREQUENCY=1440` | ||||
| 8. `CHANNEL_TEST_FREQUENCY`: When set, it periodically tests the channels, with the unit in minutes. If not set, no test will happen. | ||||
|     + Example: `CHANNEL_TEST_FREQUENCY=1440` | ||||
| 9. `REQUEST_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval. | ||||
| 9. `POLLING_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval. | ||||
|     + Example: `POLLING_INTERVAL=5` | ||||
|  | ||||
| ### Command Line Parameters | ||||
| @@ -279,6 +285,10 @@ If the channel ID is not provided, load balancing will be used to distribute the | ||||
| ## Note | ||||
| This project is an open-source project. Please use it in compliance with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**. It must not be used for illegal purposes. | ||||
|  | ||||
| This project is open-sourced under the MIT license. One must somehow retain the copyright information of One API. | ||||
| This project is released under the MIT license. Based on this, attribution and a link to this project must be included at the bottom of the page. | ||||
|  | ||||
| The same applies to derivative projects based on this project. | ||||
|  | ||||
| If you do not wish to include attribution, prior authorization must be obtained. | ||||
|  | ||||
| According to the MIT license, users should bear the risk and responsibility of using this project, and the developer of this open-source project is not responsible for this. | ||||
|   | ||||
							
								
								
									
										64
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								README.md
									
									
									
									
									
								
							| @@ -51,27 +51,27 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用 | ||||
|   <a href="https://iamazing.cn/page/reward">赞赏支持</a> | ||||
| </p> | ||||
|  | ||||
| > **Note**:本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 | ||||
|  | ||||
| > **Note**:使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 | ||||
|  | ||||
| > **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。 | ||||
|  | ||||
| ## 功能 | ||||
| 1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道: | ||||
|    + [x] OpenAI 官方通道(支持配置代理) | ||||
| 1. 支持多种 API 访问渠道: | ||||
|    + [x] OpenAI 官方通道(支持配置镜像) | ||||
|    + [x] **Azure OpenAI API** | ||||
|    + [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj) | ||||
|    + [x] [OpenAI-SB](https://openai-sb.com) | ||||
|    + [x] [API2D](https://api2d.com/r/197971) | ||||
|    + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf) | ||||
|    + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`) | ||||
|    + [x] [API2GPT](http://console.api2gpt.com/m/00002S) | ||||
|    + [x] [CloseAI](https://console.closeai-asia.com/r/2412) | ||||
|    + [x] [AI.LS](https://ai.ls) | ||||
|    + [x] [OpenAI Max](https://openaimax.com) | ||||
|    + [x] 自定义渠道:例如各种未收录的第三方代理服务 | ||||
| 2. 支持通过**负载均衡**的方式访问多个渠道。 | ||||
| 3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 | ||||
| 4. 支持**多机部署**,[详见此处](#多机部署)。 | ||||
| 5. 支持**令牌管理**,设置令牌的过期时间和使用次数。 | ||||
| 5. 支持**令牌管理**,设置令牌的过期时间和额度。 | ||||
| 6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 | ||||
| 7. 支持**通道管理**,批量创建通道。 | ||||
| 8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 | ||||
| @@ -80,21 +80,29 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用 | ||||
| 11. 支持**用户邀请奖励**。 | ||||
| 12. 支持以美元为单位显示额度。 | ||||
| 13. 支持发布公告,设置充值链接,设置新用户初始额度。 | ||||
| 14. 支持丰富的**自定义**设置, | ||||
| 14. 支持模型映射,重定向用户的请求模型。 | ||||
| 15. 支持失败自动重试。 | ||||
| 16. 支持绘图接口。 | ||||
| 17. 支持丰富的**自定义**设置, | ||||
|     1. 支持自定义系统名称,logo 以及页脚。 | ||||
|     2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 | ||||
| 15. 支持通过系统访问令牌访问管理 API。 | ||||
| 16. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 17. 支持用户管理,支持**多种用户登录注册方式**: | ||||
| 18. 支持通过系统访问令牌访问管理 API。 | ||||
| 19. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 20. 支持用户管理,支持**多种用户登录注册方式**: | ||||
|     + 邮箱登录注册以及通过邮箱进行密码重置。 | ||||
|     + [GitHub 开放授权](https://github.com/settings/applications/new)。 | ||||
|     + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 | ||||
| 18. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 | ||||
| 21. 支持 [ChatGLM](https://github.com/THUDM/ChatGLM2-6B)。 | ||||
| 22. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 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` | ||||
|  | ||||
| 如果上面的镜像无法拉取,可以尝试使用 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` 是宿主机的端口,可以根据需要进行修改。 | ||||
| @@ -114,6 +122,7 @@ server{ | ||||
|           proxy_set_header X-Forwarded-For $remote_addr; | ||||
|           proxy_cache_bypass $http_upgrade; | ||||
|           proxy_set_header Accept-Encoding gzip; | ||||
|           proxy_read_timeout 300s;  # GPT-4 需要较长的超时时间,请自行调整 | ||||
|    } | ||||
| } | ||||
| ``` | ||||
| @@ -159,8 +168,8 @@ sudo service nginx restart | ||||
| ### 多机部署 | ||||
| 1. 所有服务器 `SESSION_SECRET` 设置一样的值。 | ||||
| 2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite,所有服务器连接同一个数据库。 | ||||
| 3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`。 | ||||
| 4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置。 | ||||
| 3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器。 | ||||
| 4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis,无论主从。 | ||||
| 5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。 | ||||
| 6. 从服务器上**分别**装好 Redis,设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。 | ||||
| 7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis,并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。 | ||||
| @@ -183,7 +192,7 @@ sudo service nginx restart | ||||
| docker run --name chat-next-web -d -p 3001:3000 yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| 注意修改端口号和 `BASE_URL`。 | ||||
| 注意修改端口号,之后在页面上设置接口地址(例如:https://openai.justsong.cn/ )和 API Key 即可。 | ||||
|  | ||||
| #### ChatGPT Web | ||||
| 项目主页:https://github.com/Chanzhaoyu/chatgpt-web | ||||
| @@ -195,6 +204,17 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
| 注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。 | ||||
|  | ||||
| ### 部署到第三方平台 | ||||
| <details> | ||||
| <summary><strong>部署到 Sealos </strong></summary> | ||||
| <div> | ||||
|  | ||||
| > Sealos 可视化部署,仅需 1 分钟。 | ||||
|  | ||||
| 参考这个[教程](https://github.com/c121914yu/FastGPT/blob/main/docs/deploy/one-api/sealos.md)中 1~5 步。 | ||||
|  | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| <details> | ||||
| <summary><strong>部署到 Zeabur</strong></summary> | ||||
| <div> | ||||
| @@ -221,6 +241,8 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
|  | ||||
| 等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。 | ||||
|  | ||||
| **Note**:如果你不知道某个配置项的含义,可以临时删掉值以看到进一步的提示文字。 | ||||
|  | ||||
| ## 使用方法 | ||||
| 在`渠道`页面中添加你的 API Key,之后在`令牌`页面中新增访问令牌。 | ||||
|  | ||||
| @@ -251,7 +273,10 @@ graph LR | ||||
|    + 例子:`SESSION_SECRET=random_string` | ||||
| 3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite,请使用 MySQL 8.0 版本。 | ||||
|    + 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi` | ||||
| 4. `FRONTEND_BASE_URL`:设置之后将使用指定的前端地址,而非后端地址。 | ||||
|    + 注意需要提前建立数据库 `oneapi`,无需手动建表,程序将自动建表。 | ||||
|    + 如果使用本地数据库:部署命令可添加 `--network="host"` 以使得容器内的程序可以访问到宿主机上的 MySQL。 | ||||
|    + 如果使用云数据库:如果云服务器需要验证身份,需要在连接参数中添加 `?tls=skip-verify`。 | ||||
| 4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。 | ||||
|    + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn` | ||||
| 5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。 | ||||
|    + 例子:`SYNC_FREQUENCY=60` | ||||
| @@ -261,7 +286,7 @@ graph LR | ||||
|    + 例子:`CHANNEL_UPDATE_FREQUENCY=1440` | ||||
| 8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 | ||||
|    + 例子:`CHANNEL_TEST_FREQUENCY=1440` | ||||
| 9. `REQUEST_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 | ||||
| 9. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 | ||||
|    + 例子:`POLLING_INTERVAL=5` | ||||
|  | ||||
| ### 命令行参数 | ||||
| @@ -298,13 +323,16 @@ https://openai.justsong.cn | ||||
| 5. ChatGPT Next Web 报错:`Failed to fetch` | ||||
|    + 部署的时候不要设置 `BASE_URL`。 | ||||
|    + 检查你的接口地址和 API Key 有没有填对。 | ||||
| 6. 报错:`当前分组负载已饱和,请稍后再试` | ||||
|    + 上游通道 429 了。 | ||||
|  | ||||
| ## 相关项目 | ||||
| [FastGPT](https://github.com/c121914yu/FastGPT): 三分钟搭建 AI 知识库 | ||||
|  | ||||
| ## 注意 | ||||
| 本项目为开源项目,请在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 | ||||
|  | ||||
| 本项目使用 MIT 协议进行开源,请以某种方式保留 One API 的版权信息。 | ||||
| 本项目使用 MIT 协议进行开源,**在此基础上**,必须在页面底部保留署名以及指向本项目的链接。如果不想保留署名,必须首先获得授权。 | ||||
|  | ||||
| 同样适用于基于本项目的二开项目。 | ||||
|  | ||||
| 依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。 | ||||
| @@ -1,25 +1,29 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| if [ $# -ne 3 ]; then | ||||
|   echo "Usage: time_test.sh <domain> <key> <count>" | ||||
| if [ $# -lt 3 ]; then | ||||
|   echo "Usage: time_test.sh <domain> <key> <count> [<model>]" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| domain=$1 | ||||
| key=$2 | ||||
| count=$3 | ||||
| model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo | ||||
|  | ||||
| total_time=0 | ||||
| times=() | ||||
|  | ||||
| for ((i=1; i<=count; i++)); do | ||||
|   result=$(curl -o /dev/null -s -w %{time_total}\\n \ | ||||
|   result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \ | ||||
|            https://"$domain"/v1/chat/completions \ | ||||
|            -H "Content-Type: application/json" \ | ||||
|            -H "Authorization: Bearer $key" \ | ||||
|            -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "gpt-3.5-turbo", "stream": false, "max_tokens": 1}') | ||||
|   echo "$result" | ||||
|   total_time=$(bc <<< "$total_time + $result") | ||||
|   times+=("$result") | ||||
|            -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}') | ||||
|   http_code=$(echo "$result" | awk '{print $1}') | ||||
|   time=$(echo "$result" | awk '{print $2}') | ||||
|   echo "HTTP status code: $http_code, Time taken: $time" | ||||
|   total_time=$(bc <<< "$total_time + $time") | ||||
|   times+=("$time") | ||||
| done | ||||
|  | ||||
| average_time=$(echo "scale=4; $total_time / $count" | bc) | ||||
|   | ||||
| @@ -38,6 +38,7 @@ var PasswordLoginEnabled = true | ||||
| var PasswordRegisterEnabled = true | ||||
| var EmailVerificationEnabled = false | ||||
| var GitHubOAuthEnabled = false | ||||
| var DiscordOAuthEnabled = false | ||||
| var WeChatAuthEnabled = false | ||||
| var TurnstileCheckEnabled = false | ||||
| var RegisterEnabled = true | ||||
| @@ -53,6 +54,12 @@ var SMTPToken = "" | ||||
| var GitHubClientId = "" | ||||
| var GitHubClientSecret = "" | ||||
|  | ||||
| var DiscordClientId = "" | ||||
| var DiscordClientSecret = "" | ||||
| var DiscordGuildId = "" | ||||
| var DiscordAllowJoiningGuild = "false" | ||||
| var DiscordBotToken = "" | ||||
|  | ||||
| var WeChatServerAddress = "" | ||||
| var WeChatServerToken = "" | ||||
| var WeChatAccountQRCodeImageURL = "" | ||||
| @@ -67,12 +74,14 @@ var ChannelDisableThreshold = 5.0 | ||||
| var AutomaticDisableChannelEnabled = false | ||||
| var QuotaRemindThreshold = 1000 | ||||
| var PreConsumedQuota = 500 | ||||
| var ApproximateTokenEnabled = false | ||||
| var RetryTimes = 0 | ||||
|  | ||||
| var RootUserEmail = "" | ||||
|  | ||||
| var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" | ||||
|  | ||||
| var requestInterval, _ = strconv.Atoi(os.Getenv("REQUEST_INTERVAL")) | ||||
| var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) | ||||
| var RequestInterval = time.Duration(requestInterval) * time.Second | ||||
|  | ||||
| const ( | ||||
| @@ -148,6 +157,11 @@ const ( | ||||
| 	ChannelTypeAIProxy   = 10 | ||||
| 	ChannelTypePaLM      = 11 | ||||
| 	ChannelTypeAPI2GPT   = 12 | ||||
| 	ChannelTypeAIGC2D    = 13 | ||||
|  | ||||
| 	// Reserve engineering for public projects | ||||
| 	ChannelTypeChatGPTWeb = 14 // Chanzhaoyu/chatgpt-web | ||||
| 	ChannelTypeChatbotUI  = 15 // mckaywrigley/chatbot-ui | ||||
| ) | ||||
|  | ||||
| var ChannelBaseURLs = []string{ | ||||
| @@ -155,7 +169,7 @@ var ChannelBaseURLs = []string{ | ||||
| 	"https://api.openai.com",        // 1 | ||||
| 	"https://oa.api2d.net",          // 2 | ||||
| 	"",                              // 3 | ||||
| 	"https://api.openai-proxy.org", // 4 | ||||
| 	"https://api.closeai-proxy.xyz", // 4 | ||||
| 	"https://api.openai-sb.com",     // 5 | ||||
| 	"https://api.openaimax.com",     // 6 | ||||
| 	"https://api.ohmygpt.com",       // 7 | ||||
| @@ -164,4 +178,9 @@ var ChannelBaseURLs = []string{ | ||||
| 	"https://api.aiproxy.io",        // 10 | ||||
| 	"",                              // 11 | ||||
| 	"https://api.api2gpt.com",       // 12 | ||||
| 	"https://api.aigc2d.com",        // 13 | ||||
|  | ||||
| 	// Reserve engineering for public projects | ||||
| 	"", // 14 // Chanzhaoyu/chatgpt-web | ||||
| 	"", // 15 // mckaywrigley/chatbot-ui | ||||
| } | ||||
|   | ||||
							
								
								
									
										16
									
								
								common/ip-gen.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								common/ip-gen.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @@ -31,10 +31,11 @@ var ModelRatio = map[string]float64{ | ||||
| 	"curie":                   10, | ||||
| 	"babbage":                 10, | ||||
| 	"ada":                     10, | ||||
| 	"text-embedding-ada-002":  0.2, | ||||
| 	"text-embedding-ada-002":  0.05, | ||||
| 	"text-search-ada-doc-001": 10, | ||||
| 	"text-moderation-stable":  0.1, | ||||
| 	"text-moderation-latest":  0.1, | ||||
| 	"dall-e":                  8, | ||||
| } | ||||
|  | ||||
| func ModelRatio2JSONString() string { | ||||
|   | ||||
| @@ -1,22 +1,32 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetSubscription(c *gin.Context) { | ||||
| 	var quota int | ||||
| 	var remainQuota int | ||||
| 	var usedQuota int | ||||
| 	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 { | ||||
| 		tokenId := c.GetInt("token_id") | ||||
| 		token, err = model.GetTokenById(tokenId) | ||||
| 		quota = token.RemainQuota | ||||
| 		remainQuota = token.RemainQuota | ||||
| 		usedQuota = token.UsedQuota | ||||
| 	} else { | ||||
| 		userId := c.GetInt("id") | ||||
| 		quota, err = model.GetUserQuota(userId) | ||||
| 		remainQuota, err = model.GetUserQuota(userId) | ||||
| 		usedQuota, err = model.GetUserUsedQuota(userId) | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		openAIError := OpenAIError{ | ||||
| @@ -28,12 +38,13 @@ func GetSubscription(c *gin.Context) { | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	quota := remainQuota + usedQuota | ||||
| 	amount := float64(quota) | ||||
| 	if common.DisplayInCurrencyEnabled { | ||||
| 		amount /= common.QuotaPerUnit | ||||
| 	} | ||||
| 	if token != nil && token.UnlimitedQuota { | ||||
| 		amount = 99999999.9999 | ||||
| 		amount = 100000000 | ||||
| 	} | ||||
| 	subscription := OpenAISubscriptionResponse{ | ||||
| 		Object:             "billing_subscription", | ||||
| @@ -41,6 +52,7 @@ func GetSubscription(c *gin.Context) { | ||||
| 		SoftLimitUSD:       amount, | ||||
| 		HardLimitUSD:       amount, | ||||
| 		SystemHardLimitUSD: amount, | ||||
| 		AccessUntil:        expirationDate, | ||||
| 	} | ||||
| 	c.JSON(200, subscription) | ||||
| 	return | ||||
|   | ||||
| @@ -22,6 +22,7 @@ type OpenAISubscriptionResponse struct { | ||||
| 	SoftLimitUSD       float64 `json:"soft_limit_usd"` | ||||
| 	HardLimitUSD       float64 `json:"hard_limit_usd"` | ||||
| 	SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` | ||||
| 	AccessUntil        int64   `json:"access_until"` | ||||
| } | ||||
|  | ||||
| type OpenAIUsageDailyCost struct { | ||||
| @@ -32,6 +33,13 @@ type OpenAIUsageDailyCost struct { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type OpenAICreditGrants struct { | ||||
| 	Object         string  `json:"object"` | ||||
| 	TotalGranted   float64 `json:"total_granted"` | ||||
| 	TotalUsed      float64 `json:"total_used"` | ||||
| 	TotalAvailable float64 `json:"total_available"` | ||||
| } | ||||
|  | ||||
| type OpenAIUsageResponse struct { | ||||
| 	Object string `json:"object"` | ||||
| 	//DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` | ||||
| @@ -61,6 +69,14 @@ type API2GPTUsageResponse struct { | ||||
| 	TotalRemaining float64 `json:"total_remaining"` | ||||
| } | ||||
|  | ||||
| type APGC2DGPTUsageResponse struct { | ||||
| 	//Grants         interface{} `json:"grants"` | ||||
| 	Object         string  `json:"object"` | ||||
| 	TotalAvailable float64 `json:"total_available"` | ||||
| 	TotalGranted   float64 `json:"total_granted"` | ||||
| 	TotalUsed      float64 `json:"total_used"` | ||||
| } | ||||
|  | ||||
| // GetAuthHeader get auth header | ||||
| func GetAuthHeader(token string) http.Header { | ||||
| 	h := http.Header{} | ||||
| @@ -81,6 +97,9 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if res.StatusCode != http.StatusOK { | ||||
| 		return nil, fmt.Errorf("status code: %d", res.StatusCode) | ||||
| 	} | ||||
| 	body, err := io.ReadAll(res.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -92,6 +111,22 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He | ||||
| 	return body, nil | ||||
| } | ||||
|  | ||||
| func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { | ||||
| 	url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.BaseURL) | ||||
| 	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	response := OpenAICreditGrants{} | ||||
| 	err = json.Unmarshal(body, &response) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	channel.UpdateBalance(response.TotalAvailable) | ||||
| 	return response.TotalAvailable, nil | ||||
| } | ||||
|  | ||||
| func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { | ||||
| 	url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key) | ||||
| 	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) | ||||
| @@ -150,8 +185,26 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { | ||||
| 	return response.TotalRemaining, nil | ||||
| } | ||||
|  | ||||
| func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { | ||||
| 	url := "https://api.aigc2d.com/dashboard/billing/credit_grants" | ||||
| 	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	response := APGC2DGPTUsageResponse{} | ||||
| 	err = json.Unmarshal(body, &response) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	channel.UpdateBalance(response.TotalAvailable) | ||||
| 	return response.TotalAvailable, nil | ||||
| } | ||||
|  | ||||
| func updateChannelBalance(channel *model.Channel) (float64, error) { | ||||
| 	baseURL := common.ChannelBaseURLs[channel.Type] | ||||
| 	if channel.BaseURL == "" { | ||||
| 		channel.BaseURL = baseURL | ||||
| 	} | ||||
| 	switch channel.Type { | ||||
| 	case common.ChannelTypeOpenAI: | ||||
| 		if channel.BaseURL != "" { | ||||
| @@ -161,12 +214,16 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { | ||||
| 		return 0, errors.New("尚未实现") | ||||
| 	case common.ChannelTypeCustom: | ||||
| 		baseURL = channel.BaseURL | ||||
| 	case common.ChannelTypeCloseAI: | ||||
| 		return updateChannelCloseAIBalance(channel) | ||||
| 	case common.ChannelTypeOpenAISB: | ||||
| 		return updateChannelOpenAISBBalance(channel) | ||||
| 	case common.ChannelTypeAIProxy: | ||||
| 		return updateChannelAIProxyBalance(channel) | ||||
| 	case common.ChannelTypeAPI2GPT: | ||||
| 		return updateChannelAPI2GPTBalance(channel) | ||||
| 	case common.ChannelTypeAIGC2D: | ||||
| 		return updateChannelAIGC2DBalance(channel) | ||||
| 	default: | ||||
| 		return 0, errors.New("尚未实现") | ||||
| 	} | ||||
|   | ||||
| @@ -1,19 +1,30 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func formatFloat(input float64) float64 { | ||||
| 	if input == float64(int64(input)) { | ||||
| 		return input | ||||
| 	} | ||||
| 	return float64(int64(input*10)) / 10 | ||||
| } | ||||
|  | ||||
| func testChannel(channel *model.Channel, request ChatRequest) error { | ||||
| 	switch channel.Type { | ||||
| 	case common.ChannelTypeAzure: | ||||
| @@ -24,6 +35,14 @@ func testChannel(channel *model.Channel, request ChatRequest) error { | ||||
| 	requestURL := common.ChannelBaseURLs[channel.Type] | ||||
| 	if channel.Type == common.ChannelTypeAzure { | ||||
| 		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 | ||||
| 		} | ||||
| 	} else if channel.Type == common.ChannelTypeChatbotUI { | ||||
| 		if channel.BaseURL != "" { | ||||
| 			requestURL = channel.BaseURL | ||||
| 		} | ||||
| 	} else { | ||||
| 		if channel.BaseURL != "" { | ||||
| 			requestURL = channel.BaseURL | ||||
| @@ -32,6 +51,74 @@ func testChannel(channel *model.Channel, request ChatRequest) error { | ||||
| 	} | ||||
|  | ||||
| 	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 := make(map[string]interface{}) | ||||
|  | ||||
| 		map1["prompt"] = prompt | ||||
| 		map1["systemMessage"] = systemMessage.Content | ||||
|  | ||||
| 		if request.Temperature != 0 { | ||||
| 			map1["temperature"] = formatFloat(request.Temperature) | ||||
| 		} | ||||
| 		if request.TopP != 0 { | ||||
| 			map1["top_p"] = formatFloat(request.TopP) | ||||
| 		} | ||||
|  | ||||
| 		// Convert map to json string | ||||
| 		jsonData, err = json.Marshal(map1) | ||||
| 	} else if channel.Type == common.ChannelTypeChatbotUI { | ||||
| 		// Get system message from Message json, Role == "system" | ||||
| 		var systemMessage string | ||||
|  | ||||
| 		for _, message := range request.Messages { | ||||
| 			if message.Role == "system" { | ||||
| 				systemMessage = message.Content | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Construct json data without adding escape character | ||||
| 		map1 := make(map[string]interface{}) | ||||
|  | ||||
| 		map1["prompt"] = systemMessage | ||||
| 		map1["temperature"] = formatFloat(request.Temperature) | ||||
| 		map1["key"] = "" | ||||
| 		map1["messages"] = request.Messages | ||||
| 		map1["model"] = map[string]interface{}{ | ||||
| 			"id": request.Model, | ||||
| 		} | ||||
|  | ||||
| 		// Convert map to json string | ||||
| 		jsonData, err = json.Marshal(map1) | ||||
|  | ||||
| 		//Print jsoinData to console | ||||
| 		log.Println(string(jsonData)) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| @@ -45,31 +132,159 @@ func testChannel(channel *model.Channel, request ChatRequest) error { | ||||
| 		req.Header.Set("Authorization", "Bearer "+channel.Key) | ||||
| 	} | ||||
| 	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) | ||||
| 	} | ||||
|  | ||||
| 	custom_http_headers := channel.CustomHttpHeaders | ||||
| 	if custom_http_headers != "" { | ||||
| 		var custom_http_headers_map map[string]string | ||||
| 		err := json.Unmarshal([]byte(custom_http_headers), &custom_http_headers_map) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		for key, value := range custom_http_headers_map { | ||||
| 			req.Header.Set(key, value) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 	var response TextResponse | ||||
| 	err = json.NewDecoder(resp.Body).Decode(&response) | ||||
|  | ||||
| 	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 streamResponseText = "" | ||||
|  | ||||
| 	scanner := bufio.NewScanner(resp.Body) | ||||
|  | ||||
| 	if channel.Type != common.ChannelTypeChatGPTWeb && channel.Type != common.ChannelTypeChatbotUI { | ||||
| 		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 + 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! | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if channel.Type == common.ChannelTypeChatGPTWeb { | ||||
| 			var chatResponse ChatGptWebChatResponse | ||||
| 			err = json.Unmarshal([]byte(data), &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.Usage.CompletionTokens == 0 { | ||||
| 		return errors.New(fmt.Sprintf("type %s, code %v, message %s", response.Error.Type, response.Error.Code, response.Error.Message)) | ||||
|  | ||||
| 			// 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 { | ||||
| 					streamResponseText += choice.Delta.Content | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} else if channel.Type == common.ChannelTypeChatbotUI { | ||||
| 			streamResponseText += data | ||||
| 		} else 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 | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	// Check if streaming is complete and streamResponseText is populated | ||||
| 	if streamResponseText == "" { | ||||
| 		return errors.New("Streaming not complete") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func buildTestRequest() *ChatRequest { | ||||
| 	testRequest := &ChatRequest{ | ||||
| 		Model:  "", // this will be set later | ||||
| 		MaxTokens: 1, | ||||
| 		Stream: true, | ||||
| 	} | ||||
| 	testMessage := Message{ | ||||
| 		Role:    "user", | ||||
| 		Content: "hi", | ||||
| 		Content: "Hello ChatGPT!", | ||||
| 	} | ||||
| 	testRequest.Messages = append(testRequest.Messages, testMessage) | ||||
| 	return testRequest | ||||
|   | ||||
							
								
								
									
										237
									
								
								controller/discord.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										237
									
								
								controller/discord.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,237 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"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, disgoauth.ScopeGuilds, disgoauth.ScopeGuildsJoin}, | ||||
| 	}) | ||||
|  | ||||
| 	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 | ||||
| 	} | ||||
|  | ||||
| 	// Add guild member. | ||||
| 	if common.DiscordGuildId != "" && discordUser.Id != "" && common.DiscordBotToken != "" && common.DiscordAllowJoiningGuild == "true" { | ||||
| 		url := fmt.Sprintf("https://discord.com/api/guilds/%s/members/%s", common.DiscordGuildId, discordUser.Id) | ||||
|  | ||||
| 		// Set JSON | ||||
| 		map1 := map[string]interface{}{ | ||||
| 			// accessToken remove "Bearer " | ||||
| 			"access_token": string(accessToken[7:]), | ||||
| 		} | ||||
|  | ||||
| 		// Convert map to JSON | ||||
| 		jsonData, _ := json.Marshal(map1) | ||||
|  | ||||
| 		req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) | ||||
|  | ||||
| 		// Set Header | ||||
| 		req.Header.Set("Authorization", fmt.Sprintf("Bot %s", common.DiscordBotToken)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 		// Create a new HTTP Client | ||||
| 		client := &http.Client{} | ||||
| 		resp, err := client.Do(req) | ||||
|  | ||||
| 		log.Print(resp.StatusCode) | ||||
|  | ||||
| 		if err != nil || (resp.StatusCode != 200 && resp.StatusCode != 201 && resp.StatusCode != 204) { | ||||
| 			// Print content | ||||
| 			stringBuff := new(bytes.Buffer) | ||||
| 			stringBuff.ReadFrom(resp.Body) | ||||
|  | ||||
| 			// Print error | ||||
| 			fmt.Println("Error: ", stringBuff.String()) | ||||
|  | ||||
| 			return nil, errors.New("You must join the discord server first or be verified member to be able to login!") | ||||
| 		} | ||||
|  | ||||
| 		// Close the response body | ||||
| 		defer resp.Body.Close() | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| } | ||||
| @@ -5,13 +5,14 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type GitHubOAuthResponse struct { | ||||
|   | ||||
| @@ -3,10 +3,11 @@ package controller | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetStatus(c *gin.Context) { | ||||
| @@ -19,6 +20,10 @@ func GetStatus(c *gin.Context) { | ||||
| 			"email_verification":          common.EmailVerificationEnabled, | ||||
| 			"github_oauth":                common.GitHubOAuthEnabled, | ||||
| 			"github_client_id":            common.GitHubClientId, | ||||
| 			"discord_oauth":               common.DiscordOAuthEnabled, | ||||
| 			"discord_client_id":           common.DiscordClientId, | ||||
| 			"discord_guild_id":            common.DiscordGuildId, | ||||
| 			"discord_allow_joining_guild": common.DiscordAllowJoiningGuild, | ||||
| 			"system_name":                 common.SystemName, | ||||
| 			"logo":                        common.Logo, | ||||
| 			"footer_html":                 common.Footer, | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| @@ -53,6 +54,15 @@ func init() { | ||||
| 	}) | ||||
| 	// https://platform.openai.com/docs/models/model-endpoint-compatibility | ||||
| 	openAIModels = []OpenAIModels{ | ||||
| 		{ | ||||
| 			Id:         "dall-e", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "openai", | ||||
| 			Permission: permission, | ||||
| 			Root:       "dall-e", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "gpt-3.5-turbo", | ||||
| 			Object:     "model", | ||||
| @@ -224,6 +234,42 @@ func init() { | ||||
| 			Root:       "text-moderation-stable", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "text-davinci-edit-001", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "openai", | ||||
| 			Permission: permission, | ||||
| 			Root:       "text-davinci-edit-001", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "code-davinci-edit-001", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "openai", | ||||
| 			Permission: permission, | ||||
| 			Root:       "code-davinci-edit-001", | ||||
| 			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) | ||||
| 	for _, model := range openAIModels { | ||||
|   | ||||
| @@ -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) { | ||||
| @@ -41,6 +42,14 @@ func UpdateOption(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	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": | ||||
| 		if option.Value == "true" && common.GitHubClientId == "" { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
|   | ||||
| @@ -1,34 +1,195 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 	// TODO: this part is not finished | ||||
| 	req, err := http.NewRequest(c.Request.Method, c.Request.RequestURI, c.Request.Body) | ||||
| 	imageModel := "dall-e" | ||||
|  | ||||
| 	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")) | ||||
|  | ||||
| 	custom_http_headers := c.GetString("custom_http_headers") | ||||
| 	if custom_http_headers != "" { | ||||
| 		var custom_http_headers_map map[string]string | ||||
| 		err := json.Unmarshal([]byte(custom_http_headers), &custom_http_headers_map) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return errorWrapper(err, "unmarshal_custom_http_headers_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
|  | ||||
| 		for key, value := range custom_http_headers_map { | ||||
| 			req.Header.Set(key, value) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "do_request_failed", http.StatusOK) | ||||
| 		return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
|  | ||||
| 	err = req.Body.Close() | ||||
| 	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 { | ||||
| 		c.Writer.Header().Set(k, v[0]) | ||||
| 	} | ||||
| 	c.Writer.WriteHeader(resp.StatusCode) | ||||
|  | ||||
| 	_, err = io.Copy(c.Writer, resp.Body) | ||||
| 	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() | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "close_response_body_failed", http.StatusOK) | ||||
| 		return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -6,12 +6,16 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"io" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| @@ -27,9 +31,12 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest) | ||||
| 		} | ||||
| 	} | ||||
| 	if relayMode == RelayModeModeration && textRequest.Model == "" { | ||||
| 	if relayMode == RelayModeModerations && textRequest.Model == "" { | ||||
| 		textRequest.Model = "text-moderation-latest" | ||||
| 	} | ||||
| 	if relayMode == RelayModeEmbeddings && textRequest.Model == "" { | ||||
| 		textRequest.Model = c.Param("model") | ||||
| 	} | ||||
| 	// request validation | ||||
| 	if textRequest.Model == "" { | ||||
| 		return errorWrapper(errors.New("model is required"), "required_field_missing", http.StatusBadRequest) | ||||
| @@ -37,18 +44,57 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 	switch relayMode { | ||||
| 	case RelayModeCompletions: | ||||
| 		if textRequest.Prompt == "" { | ||||
| 			return errorWrapper(errors.New("prompt is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 			return errorWrapper(errors.New("field prompt is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 		} | ||||
| 	case RelayModeChatCompletions: | ||||
| 		if len(textRequest.Messages) == 0 { | ||||
| 			return errorWrapper(errors.New("messages is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 		if textRequest.Messages == nil || len(textRequest.Messages) == 0 { | ||||
| 			return errorWrapper(errors.New("field messages is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 		} | ||||
| 	case RelayModeEmbeddings: | ||||
| 	case RelayModeModeration: | ||||
| 	case RelayModeModerations: | ||||
| 		if textRequest.Input == "" { | ||||
| 			return errorWrapper(errors.New("input is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 			return errorWrapper(errors.New("field input is required"), "required_field_missing", http.StatusBadRequest) | ||||
| 		} | ||||
| 	case RelayModeEdits: | ||||
| 		if textRequest.Instruction == "" { | ||||
| 			return errorWrapper(errors.New("field instruction is required"), "required_field_missing", 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[textRequest.Model] != "" { | ||||
| 			textRequest.Model = modelMap[textRequest.Model] | ||||
| 			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] | ||||
| 	requestURL := c.Request.URL.String() | ||||
| 	if c.GetString("base_url") != "" { | ||||
| @@ -73,6 +119,14 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		model_ = strings.TrimSuffix(model_, "-0314") | ||||
| 		model_ = strings.TrimSuffix(model_, "-0613") | ||||
| 		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] | ||||
| 		fullRequestURL = fmt.Sprintf("%s%s", baseURL, requestURL) | ||||
| 	} else if channelType == common.ChannelTypeChatbotUI { | ||||
| 		// remove /v1/chat/completions from request url | ||||
| 		requestURL := strings.Split(requestURL, "/v1/chat/completions")[0] | ||||
| 		fullRequestURL = fmt.Sprintf("%s%s", baseURL, requestURL) | ||||
| 	} else if channelType == common.ChannelTypePaLM { | ||||
| 		err := relayPaLM(textRequest, c) | ||||
| 		return err | ||||
| @@ -84,7 +138,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		promptTokens = countTokenMessages(textRequest.Messages, textRequest.Model) | ||||
| 	case RelayModeCompletions: | ||||
| 		promptTokens = countTokenInput(textRequest.Prompt, textRequest.Model) | ||||
| 	case RelayModeModeration: | ||||
| 	case RelayModeModerations: | ||||
| 		promptTokens = countTokenInput(textRequest.Input, textRequest.Model) | ||||
| 	} | ||||
| 	preConsumedTokens := common.PreConsumedQuota | ||||
| @@ -110,7 +164,135 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden) | ||||
| 		} | ||||
| 	} | ||||
| 	req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body) | ||||
| 	var requestBody io.Reader | ||||
| 	if isModelMapped { | ||||
| 		jsonStr, err := json.Marshal(textRequest) | ||||
| 		if err != nil { | ||||
| 			return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
| 		requestBody = bytes.NewBuffer(jsonStr) | ||||
| 	} else { | ||||
| 		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 := make(map[string]interface{}) | ||||
|  | ||||
| 		map1["prompt"] = prompt + "\nResponse as assistant, but do not include the role in response." | ||||
| 		map1["systemMessage"] = systemMessage.Content | ||||
|  | ||||
| 		if reqBody.Temperature != 0 { | ||||
| 			map1["temperature"] = formatFloat(reqBody.Temperature) | ||||
| 		} | ||||
| 		if reqBody.TopP != 0 { | ||||
| 			map1["top_p"] = formatFloat(reqBody.TopP) | ||||
| 		} | ||||
|  | ||||
| 		// 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) | ||||
| 	} else if channelType == common.ChannelTypeChatbotUI { | ||||
| 		// Get system message from Message json, Role == "system" | ||||
| 		var reqBody ChatRequest | ||||
|  | ||||
| 		// Parse requestBody into systemMessage | ||||
| 		err := json.NewDecoder(requestBody).Decode(&reqBody) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return errorWrapper(err, "decode_request_body_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
|  | ||||
| 		// Get system message from Message json, Role == "system" | ||||
| 		var systemMessage string | ||||
|  | ||||
| 		for _, message := range reqBody.Messages { | ||||
| 			if message.Role == "system" { | ||||
| 				systemMessage = message.Content | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Construct json data without adding escape character | ||||
| 		map1 := make(map[string]interface{}) | ||||
|  | ||||
| 		map1["prompt"] = systemMessage | ||||
| 		map1["temperature"] = formatFloat(reqBody.Temperature) | ||||
| 		map1["key"] = "" | ||||
| 		map1["messages"] = reqBody.Messages | ||||
| 		map1["model"] = map[string]interface{}{ | ||||
| 			"id": reqBody.Model, | ||||
| 		} | ||||
|  | ||||
| 		// 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) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
| @@ -123,12 +305,51 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 	} | ||||
| 	req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | ||||
| 	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) | ||||
| 	} | ||||
|  | ||||
| 	custom_http_headers := c.GetString("custom_http_headers") | ||||
| 	if custom_http_headers != "" { | ||||
| 		var custom_http_headers_map map[string]string | ||||
| 		err := json.Unmarshal([]byte(custom_http_headers), &custom_http_headers_map) | ||||
|  | ||||
| 		if err != nil { | ||||
| 			return errorWrapper(err, "unmarshal_custom_http_headers_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
|  | ||||
| 		for key, value := range custom_http_headers_map { | ||||
| 			req.Header.Set(key, value) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		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): %s", baseURL, textRequest.Model, buf.String()) | ||||
| 			return errorWrapper(err, "request_failed", resp.StatusCode) | ||||
| 		} | ||||
|  | ||||
| 		return errorWrapper(err, "request_failed", resp.StatusCode) | ||||
| 	} | ||||
| 	err = req.Body.Close() | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError) | ||||
| @@ -138,13 +359,16 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
| 	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 | ||||
|  | ||||
| 	defer func() { | ||||
| 		if consumeQuota { | ||||
| 			quota := 0 | ||||
| 			completionRatio := 1.333333 // default for gpt-3 | ||||
| 			completionRatio := 1.0 | ||||
| 			if strings.HasPrefix(textRequest.Model, "gpt-3.5") { | ||||
| 				completionRatio = 1.333333 | ||||
| 			} | ||||
| 			if strings.HasPrefix(textRequest.Model, "gpt-4") { | ||||
| 				completionRatio = 2 | ||||
| 			} | ||||
| @@ -170,6 +394,10 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			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) | ||||
| @@ -181,14 +409,19 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	if isStream { | ||||
| 	if isStream || channelType == common.ChannelTypeChatGPTWeb || channelType == common.ChannelTypeChatbotUI { | ||||
| 		dataChan := make(chan string) | ||||
| 		stopChan := make(chan bool) | ||||
|  | ||||
| 		scanner := bufio.NewScanner(resp.Body) | ||||
|  | ||||
| 		if channelType != common.ChannelTypeChatGPTWeb && channelType != common.ChannelTypeChatbotUI { | ||||
| 			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 { | ||||
| 				if i := strings.Index(string(data), "\n"); i >= 0 { | ||||
| 					return i + 2, data[0:i], nil | ||||
| 				} | ||||
|  | ||||
| @@ -198,13 +431,95 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
|  | ||||
| 				return 0, nil, nil | ||||
| 			}) | ||||
| 		dataChan := make(chan string) | ||||
| 		stopChan := make(chan bool) | ||||
| 		} | ||||
|  | ||||
| 		go func() { | ||||
| 			for scanner.Scan() { | ||||
| 				data := scanner.Text() | ||||
| 				if len(data) < 6 { // must be something wrong! | ||||
| 					common.SysError("invalid stream response: " + data) | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				if channelType == common.ChannelTypeChatGPTWeb { | ||||
| 					var chatResponse ChatGptWebChatResponse | ||||
| 					err = json.Unmarshal([]byte(data), &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 | ||||
| 					} | ||||
|  | ||||
| 					// 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 { | ||||
| 							streamResponseText += choice.Delta.Content | ||||
|  | ||||
| 							returnObj := map[string]interface{}{ | ||||
| 								"id":      chatResponse.ID, | ||||
| 								"object":  chatResponse.Detail.Object, | ||||
| 								"created": chatResponse.Detail.Created, | ||||
| 								"model":   chatResponse.Detail.Model, | ||||
| 								"choices": []map[string]interface{}{ | ||||
| 									// set finish_reason to null in json | ||||
| 									{ | ||||
| 										"finish_reason": nil, | ||||
| 										"index":         0, | ||||
| 										"delta": map[string]interface{}{ | ||||
| 											"content": choice.Delta.Content, | ||||
| 										}, | ||||
| 									}, | ||||
| 								}, | ||||
| 							} | ||||
|  | ||||
| 							jsonData, _ := json.Marshal(returnObj) | ||||
|  | ||||
| 							dataChan <- "data: " + string(jsonData) | ||||
| 						} | ||||
| 					} | ||||
| 				} else if channelType == common.ChannelTypeChatbotUI { | ||||
| 					returnObj := map[string]interface{}{ | ||||
| 						"id":      "chatcmpl-" + strconv.Itoa(int(time.Now().UnixNano())), | ||||
| 						"object":  "text_completion", | ||||
| 						"created": time.Now().Unix(), | ||||
| 						"model":   textRequest.Model, | ||||
| 						"choices": []map[string]interface{}{ | ||||
| 							// set finish_reason to null in json | ||||
| 							{ | ||||
| 								"finish_reason": nil, | ||||
| 								"index":         0, | ||||
| 								"delta": map[string]interface{}{ | ||||
| 									"content": data, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 					} | ||||
|  | ||||
| 					jsonData, _ := json.Marshal(returnObj) | ||||
|  | ||||
| 					dataChan <- "data: " + string(jsonData) | ||||
| 				} else { | ||||
| 					// 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 | ||||
| @@ -232,10 +547,14 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 								streamResponseText += choice.Text | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 					} | ||||
|  | ||||
| 				} | ||||
| 			} | ||||
| 			stopChan <- true | ||||
| 		}() | ||||
|  | ||||
| 		c.Writer.Header().Set("Content-Type", "text/event-stream") | ||||
| 		c.Writer.Header().Set("Cache-Control", "no-cache") | ||||
| 		c.Writer.Header().Set("Connection", "keep-alive") | ||||
| @@ -247,6 +566,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 				if strings.HasPrefix(data, "data: [DONE]") { | ||||
| 					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}) | ||||
| 				return true | ||||
| 			case <-stopChan: | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import ( | ||||
| 	"fmt" | ||||
| 	"github.com/pkoukk/tiktoken-go" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} | ||||
| @@ -25,6 +24,13 @@ func getTokenEncoder(model string) *tiktoken.Tiktoken { | ||||
| 	return tokenEncoder | ||||
| } | ||||
|  | ||||
| func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { | ||||
| 	if common.ApproximateTokenEnabled { | ||||
| 		return int(float64(len(text)) * 0.38) | ||||
| 	} | ||||
| 	return len(tokenEncoder.Encode(text, nil, nil)) | ||||
| } | ||||
|  | ||||
| func countTokenMessages(messages []Message, model string) int { | ||||
| 	tokenEncoder := getTokenEncoder(model) | ||||
| 	// Reference: | ||||
| @@ -34,12 +40,9 @@ func countTokenMessages(messages []Message, model string) int { | ||||
| 	// Every message follows <|start|>{role/name}\n{content}<|end|>\n | ||||
| 	var tokensPerMessage int | ||||
| 	var tokensPerName int | ||||
| 	if strings.HasPrefix(model, "gpt-3.5") { | ||||
| 	if model == "gpt-3.5-turbo-0301" { | ||||
| 		tokensPerMessage = 4 | ||||
| 		tokensPerName = -1 // If there's a name, the role is omitted | ||||
| 	} else if strings.HasPrefix(model, "gpt-4") { | ||||
| 		tokensPerMessage = 3 | ||||
| 		tokensPerName = 1 | ||||
| 	} else { | ||||
| 		tokensPerMessage = 3 | ||||
| 		tokensPerName = 1 | ||||
| @@ -47,11 +50,11 @@ func countTokenMessages(messages []Message, model string) int { | ||||
| 	tokenNum := 0 | ||||
| 	for _, message := range messages { | ||||
| 		tokenNum += tokensPerMessage | ||||
| 		tokenNum += len(tokenEncoder.Encode(message.Content, nil, nil)) | ||||
| 		tokenNum += len(tokenEncoder.Encode(message.Role, nil, nil)) | ||||
| 		tokenNum += getTokenNum(tokenEncoder, message.Content) | ||||
| 		tokenNum += getTokenNum(tokenEncoder, message.Role) | ||||
| 		if message.Name != nil { | ||||
| 			tokenNum += tokensPerName | ||||
| 			tokenNum += len(tokenEncoder.Encode(*message.Name, nil, nil)) | ||||
| 			tokenNum += getTokenNum(tokenEncoder, *message.Name) | ||||
| 		} | ||||
| 	} | ||||
| 	tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|> | ||||
| @@ -74,8 +77,7 @@ func countTokenInput(input any, model string) int { | ||||
|  | ||||
| func countTokenText(text string, model string) int { | ||||
| 	tokenEncoder := getTokenEncoder(model) | ||||
| 	token := tokenEncoder.Encode(text, nil, nil) | ||||
| 	return len(token) | ||||
| 	return getTokenNum(tokenEncoder, text) | ||||
| } | ||||
|  | ||||
| func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode { | ||||
|   | ||||
| @@ -2,10 +2,12 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type Message struct { | ||||
| @@ -19,28 +21,35 @@ const ( | ||||
| 	RelayModeChatCompletions | ||||
| 	RelayModeCompletions | ||||
| 	RelayModeEmbeddings | ||||
| 	RelayModeModeration | ||||
| 	RelayModeModerations | ||||
| 	RelayModeImagesGenerations | ||||
| 	RelayModeEdits | ||||
| ) | ||||
|  | ||||
| // https://platform.openai.com/docs/api-reference/chat | ||||
|  | ||||
| type GeneralOpenAIRequest struct { | ||||
| 	Model       string    `json:"model"` | ||||
| 	Messages    []Message `json:"messages"` | ||||
| 	Prompt      any       `json:"prompt"` | ||||
| 	Stream      bool      `json:"stream"` | ||||
| 	MaxTokens   int       `json:"max_tokens"` | ||||
| 	Temperature float64   `json:"temperature"` | ||||
| 	TopP        float64   `json:"top_p"` | ||||
| 	N           int       `json:"n"` | ||||
| 	Input       any       `json:"input"` | ||||
| 	Model       string    `json:"model,omitempty"` | ||||
| 	Messages    []Message `json:"messages,omitempty"` | ||||
| 	Prompt      any       `json:"prompt,omitempty"` | ||||
| 	Stream      bool      `json:"stream,omitempty"` | ||||
| 	MaxTokens   int       `json:"max_tokens,omitempty"` | ||||
| 	Temperature float64   `json:"temperature,omitempty"` | ||||
| 	TopP        float64   `json:"top_p,omitempty"` | ||||
| 	N           int       `json:"n,omitempty"` | ||||
| 	Input       any       `json:"input,omitempty"` | ||||
| 	Instruction string    `json:"instruction,omitempty"` | ||||
| 	Size        string    `json:"size,omitempty"` | ||||
| } | ||||
|  | ||||
| type ChatRequest struct { | ||||
| 	Model     string    `json:"model"` | ||||
| 	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 { | ||||
| @@ -51,6 +60,12 @@ type TextRequest struct { | ||||
| 	//Stream   bool      `json:"stream"` | ||||
| } | ||||
|  | ||||
| type ImageRequest struct { | ||||
| 	Prompt string `json:"prompt"` | ||||
| 	N      int    `json:"n"` | ||||
| 	Size   string `json:"size"` | ||||
| } | ||||
|  | ||||
| type Usage struct { | ||||
| 	PromptTokens     int `json:"prompt_tokens"` | ||||
| 	CompletionTokens int `json:"completion_tokens"` | ||||
| @@ -74,6 +89,13 @@ type TextResponse struct { | ||||
| 	Error OpenAIError `json:"error"` | ||||
| } | ||||
|  | ||||
| type ImageResponse struct { | ||||
| 	Created int `json:"created"` | ||||
| 	Data    []struct { | ||||
| 		Url string `json:"url"` | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type ChatCompletionsStreamResponse struct { | ||||
| 	Choices []struct { | ||||
| 		Delta struct { | ||||
| @@ -90,6 +112,32 @@ type CompletionsStreamResponse struct { | ||||
| 	} `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) { | ||||
| 	relayMode := RelayModeUnknown | ||||
| 	if strings.HasPrefix(c.Request.URL.Path, "/v1/chat/completions") { | ||||
| @@ -98,10 +146,14 @@ func Relay(c *gin.Context) { | ||||
| 		relayMode = RelayModeCompletions | ||||
| 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") { | ||||
| 		relayMode = RelayModeEmbeddings | ||||
| 	} else if strings.HasSuffix(c.Request.URL.Path, "embeddings") { | ||||
| 		relayMode = RelayModeEmbeddings | ||||
| 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { | ||||
| 		relayMode = RelayModeModeration | ||||
| 		relayMode = RelayModeModerations | ||||
| 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { | ||||
| 		relayMode = RelayModeImagesGenerations | ||||
| 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1/edits") { | ||||
| 		relayMode = RelayModeEdits | ||||
| 	} | ||||
| 	var err *OpenAIErrorWithStatusCode | ||||
| 	switch relayMode { | ||||
| @@ -111,16 +163,25 @@ func Relay(c *gin.Context) { | ||||
| 		err = relayTextHelper(c, relayMode) | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		retryTimesStr := c.Query("retry") | ||||
| 		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, | ||||
| 			}) | ||||
| 		} | ||||
| 		channelId := c.GetInt("channel_id") | ||||
| 		common.SysError(fmt.Sprintf("relay error (channel #%d): %s", channelId, err.Message)) | ||||
| 		// 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") | ||||
| 			channelName := c.GetString("channel_name") | ||||
| 			disableChannel(channelId, channelName, err.Message) | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetAllTokens(c *gin.Context) { | ||||
| @@ -125,6 +126,7 @@ func AddToken(c *gin.Context) { | ||||
| 		ExpiredTime:    token.ExpiredTime, | ||||
| 		RemainQuota:    token.RemainQuota, | ||||
| 		UnlimitedQuota: token.UnlimitedQuota, | ||||
| 		Models:         token.Models, | ||||
| 	} | ||||
| 	err = cleanToken.Insert() | ||||
| 	if err != nil { | ||||
| @@ -180,10 +182,10 @@ func UpdateToken(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	if token.Status == common.TokenStatusEnabled { | ||||
| 		if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() { | ||||
| 		if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": "令牌已过期,无法启用,请先修改令牌过期时间", | ||||
| 				"message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| @@ -203,6 +205,7 @@ func UpdateToken(c *gin.Context) { | ||||
| 		cleanToken.ExpiredTime = token.ExpiredTime | ||||
| 		cleanToken.RemainQuota = token.RemainQuota | ||||
| 		cleanToken.UnlimitedQuota = token.UnlimitedQuota | ||||
| 		cleanToken.Models = token.Models | ||||
| 	} | ||||
| 	err = cleanToken.Update() | ||||
| 	if err != nil { | ||||
|   | ||||
| @@ -3,12 +3,13 @@ package controller | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type LoginRequest struct { | ||||
| @@ -477,6 +478,16 @@ func DeleteUser(c *gin.Context) { | ||||
|  | ||||
| func DeleteSelf(c *gin.Context) { | ||||
| 	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) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
|   | ||||
							
								
								
									
										34
									
								
								english.dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								english.dockerfile
									
									
									
									
									
										Normal 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"] | ||||
							
								
								
									
										43
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								go.mod
									
									
									
									
									
								
							| @@ -9,28 +9,33 @@ require ( | ||||
| 	github.com/gin-contrib/sessions v0.0.5 | ||||
| 	github.com/gin-contrib/static v0.0.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/google/uuid v1.3.0 | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.1 | ||||
| 	golang.org/x/crypto v0.9.0 | ||||
| 	gorm.io/driver/mysql v1.4.3 | ||||
| 	gorm.io/driver/sqlite v1.4.3 | ||||
| 	gorm.io/gorm v1.24.0 | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.5 | ||||
| 	golang.org/x/crypto v0.11.0 | ||||
| 	gorm.io/driver/mysql v1.5.1 | ||||
| 	gorm.io/driver/sqlite v1.5.2 | ||||
| 	gorm.io/gorm v1.25.2 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/chenzhuoyu/iasm v0.9.0 // indirect | ||||
| 	github.com/knz/go-libedit v1.10.1 // indirect | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect | ||||
| 	github.com/bytedance/sonic v1.9.1 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.1.2 // indirect | ||||
| 	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect | ||||
| 	github.com/bytedance/sonic v1.10.0-rc2 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.2.0 // indirect | ||||
| 	github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // 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/gin-contrib/sse v0.1.0 // indirect | ||||
| 	github.com/go-playground/locales v0.14.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/gomodule/redigo v2.0.0+incompatible // indirect | ||||
| 	github.com/gorilla/context v1.1.1 // indirect | ||||
| @@ -38,20 +43,22 @@ require ( | ||||
| 	github.com/gorilla/sessions v1.2.1 // indirect | ||||
| 	github.com/jinzhu/inflection v1.0.0 // 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/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/mattn/go-isatty v0.0.19 // 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/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/ugorji/go/codec v1.2.11 // indirect | ||||
| 	golang.org/x/arch v0.3.0 // indirect | ||||
| 	golang.org/x/net v0.10.0 // indirect | ||||
| 	golang.org/x/sys v0.8.0 // indirect | ||||
| 	golang.org/x/text v0.9.0 // indirect | ||||
| 	google.golang.org/protobuf v1.30.0 // indirect | ||||
| 	golang.org/x/arch v0.4.0 // indirect | ||||
| 	golang.org/x/net v0.12.0 // indirect | ||||
| 	golang.org/x/sys v0.10.0 // indirect | ||||
| 	golang.org/x/text v0.11.0 // indirect | ||||
| 	google.golang.org/protobuf v1.31.0 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|   | ||||
							
								
								
									
										69
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								go.sum
									
									
									
									
									
								
							| @@ -3,11 +3,23 @@ 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.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= | ||||
| 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/bytedance/sonic v1.10.0-rc h1:3S5HeWxjX08CUqNrXtEittExpJsEKBNzrV5UnrzHxVQ= | ||||
| github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= | ||||
| github.com/bytedance/sonic v1.10.0-rc2 h1:oDfRZ+4m6AYCOC0GFeOCeYqvBmucy1isvouS2K0cPzo= | ||||
| github.com/bytedance/sonic v1.10.0-rc2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= | ||||
| 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.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-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-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= | ||||
| github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= | ||||
| github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= | ||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| @@ -16,6 +28,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/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.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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= | ||||
| github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= | ||||
| @@ -47,15 +61,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.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.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/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= | ||||
| 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.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.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= | ||||
| 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.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/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= | ||||
| github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= | ||||
| @@ -75,12 +97,18 @@ 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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= | ||||
| 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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||||
| 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.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.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= | ||||
| github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | ||||
| github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= | ||||
| github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | ||||
| 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.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= | ||||
| @@ -112,11 +140,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.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.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/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.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/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.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= | ||||
| github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= | ||||
| @@ -133,6 +171,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.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= | ||||
| 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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= | ||||
| @@ -144,34 +183,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.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= | ||||
| 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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= | ||||
| 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.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= | ||||
| 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-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-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-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.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= | ||||
| 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/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.3/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/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/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= | ||||
| 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/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= | ||||
| 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/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 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||
| @@ -188,9 +249,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| 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/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/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.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= | ||||
| 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= | ||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||
| rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= | ||||
|   | ||||
							
								
								
									
										87
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								i18n/en.json
									
									
									
									
									
								
							| @@ -36,7 +36,7 @@ | ||||
|   "通过令牌「%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 token has expired and cannot be enabled. Please modify the token expiration time first", | ||||
|   "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期": "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", | ||||
|   "无法保存会话信息,请重试": "Unable to save session information, please try again", | ||||
| @@ -107,6 +107,11 @@ | ||||
|   "已禁用": "Disabled", | ||||
|   "未知状态": "Unknown status", | ||||
|   " 秒": "s", | ||||
|   " 分钟 ": " m ", | ||||
|   " 小时 ": " h ", | ||||
|   " 天 ": " d ", | ||||
|   " 个月 ": " M ", | ||||
|   " 年 ": " y ", | ||||
|   "未测试": "Not tested", | ||||
|   "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test succeeded, time consumed ${time.toFixed(2)} s.", | ||||
|   "已成功开始测试所有已启用通道,请刷新页面查看结果。": "All enabled channels have been successfully tested, please refresh the page to view the results.", | ||||
| @@ -232,7 +237,8 @@ | ||||
|   "保存首页内容": "Save Home Page Content", | ||||
|   "在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面": "Enter new about content here, supports Markdown & HTML code. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the about page.", | ||||
|   "保存关于": "Save About", | ||||
|   "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Removal of One API copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.", | ||||
|   "移除 One API": "Removal of One API", | ||||
|   "的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。": " copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.", | ||||
|   "页脚": "Footer", | ||||
|   "在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码": "Enter the new footer here, leave blank to use the default footer, supports HTML code.", | ||||
|   "设置页脚": "Set Footer", | ||||
| @@ -438,6 +444,7 @@ | ||||
|   "显示名称": "Display Name", | ||||
|   "请输入新的显示名称": "Please enter a new display name", | ||||
|   "已绑定的 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", | ||||
|   "已绑定的微信账户": "WeChat Account Bound", | ||||
|   "已绑定的邮箱账户": "Email Account Bound", | ||||
| @@ -456,5 +463,79 @@ | ||||
|   "提示": "Prompt", | ||||
|   "补全": "Completion", | ||||
|   "消耗额度": "Used Quota", | ||||
|   "可选值": "Optional Values" | ||||
|   "可选值": "Optional Values", | ||||
|   "渠道不存在:%d": "Channel does not exist: %d", | ||||
|   "数据库一致性已被破坏,请联系管理员": "Database consistency has been broken, please contact the administrator", | ||||
|   "使用近似的方式估算 token 数以减少计算量": "Estimate the number of tokens in an approximate way to reduce computational load", | ||||
|   "请填写ChannelName和ChannelKey!": "Please fill in the ChannelName and ChannelKey!", | ||||
|   "请至少选择一个Model!": "Please select at least one Model!", | ||||
|   "加载首页内容失败": "Failed to load the homepage content", | ||||
|   "加载关于内容失败": "Failed to load the About content", | ||||
|   "兑换码更新成功!": "Redemption code updated successfully!", | ||||
|   "兑换码创建成功!": "Redemption code created successfully!", | ||||
|   "用户账户创建成功!": "User account created successfully!", | ||||
|   "生成数量": "Generate quantity", | ||||
|   "请输入生成数量": "Please enter the quantity to generate", | ||||
|   "创建新用户账户": "Create new user account", | ||||
|   "渠道更新成功!": "Channel updated successfully!", | ||||
|   "渠道创建成功!": "Channel created successfully!", | ||||
|   "请选择分组": "Please select a group", | ||||
|   "更新兑换码信息": "Update redemption code information", | ||||
|   "创建新的兑换码": "Create a new redemption code", | ||||
|   "请在系统设置页面编辑分组倍率以添加新的分组:": "Please edit the group ratio in the system settings page to add a new group:", | ||||
|   "未找到所请求的页面": "The requested page was not found", | ||||
|   "过期时间格式错误!": "Expiration time format error!", | ||||
|   "请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制": "Please enter the expiration time, the format is yyyy-MM-dd HH:mm:ss, -1 means no limit", | ||||
|   "此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:": "This is optional, it's a JSON text, the key is the model name requested by the user, and the value is the model name to be replaced, for example:", | ||||
|   "此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:", | ||||
|   "模型映射": "Model mapping", | ||||
|   "请输入默认 API 版本,例如:2023-03-15-preview,该配置可以被实际的请求查询参数所覆盖": "Please enter the default API version, for example: 2023-03-15-preview, this configuration can be overridden by the actual request query parameters", | ||||
|   "默认": "Default", | ||||
|   "图片演示": "Image demo", | ||||
|   "参数替换为你的部署名称(模型名称中的点会被剔除)": "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", | ||||
|   "请输入新的剩余额度": "Please enter the new remaining quota", | ||||
|   "请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code", | ||||
|   "请输入用户名": "Please enter username", | ||||
|   "请输入显示名称": "Please enter display name", | ||||
|   "请输入密码": "Please enter password", | ||||
|   "模型部署名称必须和模型名称保持一致": "The model deployment name must be consistent with the model name", | ||||
|   ",因为 One API 会把请求体中的 model": ", because One API will take the model in the request body", | ||||
|   "请输入 AZURE_OPENAI_ENDPOINT": "Please enter AZURE_OPENAI_ENDPOINT", | ||||
|   "请输入自定义渠道的 Base URL": "Please enter the Base URL of the custom channel", | ||||
|   "Homepage URL 填": "Fill in the Homepage 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", | ||||
|   "失败重试次数": "Number of failed retries", | ||||
|   "消费": "Consumption", | ||||
|   "管理": "Management", | ||||
|   "系统": "System", | ||||
|   "未知": "Unknown", | ||||
|   "One API 会把请求体中的 model": "One API will take the model in the request body", | ||||
|   ",因为": ", because", | ||||
|   "参数替换为你的部署名称(模型名称中的点会被剔除),": "Replace the parameter with your deployment name (dots in the model name will be removed), ", | ||||
|   "注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI": "Note that the generated token here is used for system management, not for requesting OpenAI", | ||||
|   "相关的服务,请知悉。": "related services, please be aware.", | ||||
|   "填入": "Fill in" | ||||
| } | ||||
|   | ||||
							
								
								
									
										17
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								main.go
									
									
									
									
									
								
							| @@ -2,10 +2,6 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"embed" | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-contrib/sessions/cookie" | ||||
| 	"github.com/gin-contrib/sessions/redis" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"one-api/common" | ||||
| 	"one-api/controller" | ||||
| 	"one-api/middleware" | ||||
| @@ -13,6 +9,11 @@ import ( | ||||
| 	"one-api/router" | ||||
| 	"os" | ||||
| 	"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 | ||||
| @@ -22,6 +23,8 @@ var buildFS embed.FS | ||||
| var indexPage []byte | ||||
|  | ||||
| func main() { | ||||
| 	godotenv.Load(".env") | ||||
|  | ||||
| 	common.SetupGinLog() | ||||
| 	common.SysLog("One API " + common.Version + " started") | ||||
| 	if os.Getenv("GIN_MODE") != "debug" { | ||||
| @@ -82,14 +85,8 @@ func main() { | ||||
| 	server.Use(middleware.CORS()) | ||||
|  | ||||
| 	// Initialize session store | ||||
| 	if common.RedisEnabled { | ||||
| 		opt := common.ParseRedisOption() | ||||
| 		store, _ := redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret)) | ||||
| 		server.Use(sessions.Sessions("session", store)) | ||||
| 	} else { | ||||
| 	store := cookie.NewStore([]byte(common.SessionSecret)) | ||||
| 	server.Use(sessions.Sessions("session", store)) | ||||
| 	} | ||||
|  | ||||
| 	router.SetRouter(server, buildFS, indexPage) | ||||
| 	var port = os.Getenv("PORT") | ||||
|   | ||||
| @@ -2,12 +2,13 @@ package middleware | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type ModelRequest struct { | ||||
| @@ -73,11 +74,26 @@ func Distribute() func(c *gin.Context) { | ||||
| 					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) | ||||
| 			if err != nil { | ||||
| 				message := "无可用渠道" | ||||
| 				if channel != nil { | ||||
| 					common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) | ||||
| 					message = "数据库一致性已被破坏,请联系管理员" | ||||
| 				} | ||||
| 				c.JSON(http.StatusServiceUnavailable, gin.H{ | ||||
| 					"error": gin.H{ | ||||
| 						"message": "无可用渠道", | ||||
| 						"message": message, | ||||
| 						"type":    "one_api_error", | ||||
| 					}, | ||||
| 				}) | ||||
| @@ -88,6 +104,9 @@ func Distribute() func(c *gin.Context) { | ||||
| 		c.Set("channel", channel.Type) | ||||
| 		c.Set("channel_id", channel.Id) | ||||
| 		c.Set("channel_name", channel.Name) | ||||
| 		c.Set("model_mapping", channel.ModelMapping) | ||||
| 		c.Set("custom_http_headers", channel.CustomHttpHeaders) | ||||
| 		c.Set("enable_ip_randomization", channel.EnableIpRandomization) | ||||
| 		c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) | ||||
| 		c.Set("base_url", channel.BaseURL) | ||||
| 		if channel.Type == common.ChannelTypeAzure { | ||||
|   | ||||
| @@ -24,6 +24,7 @@ func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	channel := Channel{} | ||||
| 	channel.Id = ability.ChannelId | ||||
| 	err = DB.First(&channel, "id = ?", ability.ChannelId).Error | ||||
| 	return &channel, err | ||||
| } | ||||
|   | ||||
| @@ -83,6 +83,18 @@ func CacheGetUserQuota(id int) (quota int, err error) { | ||||
| 	return quota, err | ||||
| } | ||||
|  | ||||
| func CacheUpdateUserQuota(id int) error { | ||||
| 	if !common.RedisEnabled { | ||||
| 		return nil | ||||
| 	} | ||||
| 	quota, err := GetUserQuota(id) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), UserId2QuotaCacheSeconds*time.Second) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func CacheIsUserEnabled(userId int) bool { | ||||
| 	if !common.RedisEnabled { | ||||
| 		return IsUserEnabled(userId) | ||||
| @@ -108,7 +120,7 @@ var channelSyncLock sync.RWMutex | ||||
| func InitChannelCache() { | ||||
| 	newChannelId2channel := make(map[int]*Channel) | ||||
| 	var channels []*Channel | ||||
| 	DB.Find(&channels) | ||||
| 	DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels) | ||||
| 	for _, channel := range channels { | ||||
| 		newChannelId2channel[channel.Id] = channel | ||||
| 	} | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| type Channel struct { | ||||
| @@ -22,6 +23,11 @@ type Channel struct { | ||||
| 	Models             string  `json:"models"` | ||||
| 	Group              string  `json:"group" gorm:"type:varchar(32);default:'default'"` | ||||
| 	UsedQuota          int64   `json:"used_quota" gorm:"bigint;default:0"` | ||||
| 	ModelMapping       string  `json:"model_mapping" gorm:"type:varchar(1024);default:''"` | ||||
|  | ||||
| 	// Additional fields, default value is false | ||||
| 	EnableIpRandomization bool   `json:"enable_ip_randomization"` | ||||
| 	CustomHttpHeaders     string `json:"custom_http_headers"` | ||||
| } | ||||
|  | ||||
| func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { | ||||
| @@ -36,7 +42,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { | ||||
| } | ||||
|  | ||||
| func SearchChannels(keyword string) (channels []*Channel, err error) { | ||||
| 	err = DB.Omit("key").Where("id = ? or name LIKE ? or key = ?", keyword, keyword+"%", keyword).Find(&channels).Error | ||||
| 	err = DB.Omit("key").Where("id = ? or name LIKE ? or `key` = ?", keyword, keyword+"%", keyword).Find(&channels).Error | ||||
| 	return channels, err | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -30,10 +30,12 @@ func InitOptionMap() { | ||||
| 	common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) | ||||
| 	common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) | ||||
| 	common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) | ||||
| 	common.OptionMap["DiscordOAuthEnabled"] = strconv.FormatBool(common.DiscordOAuthEnabled) | ||||
| 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) | ||||
| 	common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) | ||||
| 	common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) | ||||
| 	common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) | ||||
| 	common.OptionMap["ApproximateTokenEnabled"] = strconv.FormatBool(common.ApproximateTokenEnabled) | ||||
| 	common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) | ||||
| 	common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) | ||||
| 	common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) | ||||
| @@ -52,6 +54,11 @@ func InitOptionMap() { | ||||
| 	common.OptionMap["ServerAddress"] = "" | ||||
| 	common.OptionMap["GitHubClientId"] = "" | ||||
| 	common.OptionMap["GitHubClientSecret"] = "" | ||||
| 	common.OptionMap["DiscordClientId"] = "" | ||||
| 	common.OptionMap["DiscordClientSecret"] = "" | ||||
| 	common.OptionMap["DiscordGuildId"] = "" | ||||
| 	common.OptionMap["DiscordBotToken"] = "" | ||||
| 	common.OptionMap["DiscordAllowJoiningGuild"] = "" | ||||
| 	common.OptionMap["WeChatServerAddress"] = "" | ||||
| 	common.OptionMap["WeChatServerToken"] = "" | ||||
| 	common.OptionMap["WeChatAccountQRCodeImageURL"] = "" | ||||
| @@ -67,6 +74,7 @@ func InitOptionMap() { | ||||
| 	common.OptionMap["TopUpLink"] = common.TopUpLink | ||||
| 	common.OptionMap["ChatLink"] = common.ChatLink | ||||
| 	common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64) | ||||
| 	common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes) | ||||
| 	common.OptionMapRWMutex.Unlock() | ||||
| 	loadOptionsFromDatabase() | ||||
| } | ||||
| @@ -131,6 +139,8 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 			common.PasswordLoginEnabled = boolValue | ||||
| 		case "EmailVerificationEnabled": | ||||
| 			common.EmailVerificationEnabled = boolValue | ||||
| 		case "DiscordOAuthEnabled": | ||||
| 			common.DiscordOAuthEnabled = boolValue | ||||
| 		case "GitHubOAuthEnabled": | ||||
| 			common.GitHubOAuthEnabled = boolValue | ||||
| 		case "WeChatAuthEnabled": | ||||
| @@ -141,6 +151,8 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 			common.RegisterEnabled = boolValue | ||||
| 		case "AutomaticDisableChannelEnabled": | ||||
| 			common.AutomaticDisableChannelEnabled = boolValue | ||||
| 		case "ApproximateTokenEnabled": | ||||
| 			common.ApproximateTokenEnabled = boolValue | ||||
| 		case "LogConsumeEnabled": | ||||
| 			common.LogConsumeEnabled = boolValue | ||||
| 		case "DisplayInCurrencyEnabled": | ||||
| @@ -167,6 +179,16 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 		common.GitHubClientId = value | ||||
| 	case "GitHubClientSecret": | ||||
| 		common.GitHubClientSecret = value | ||||
| 	case "DiscordClientId": | ||||
| 		common.DiscordClientId = value | ||||
| 	case "DiscordGuildId": | ||||
| 		common.DiscordGuildId = value | ||||
| 	case "DiscordBotToken": | ||||
| 		common.DiscordBotToken = value | ||||
| 	case "DiscordAllowJoiningGuild": | ||||
| 		common.DiscordAllowJoiningGuild = value | ||||
| 	case "DiscordClientSecret": | ||||
| 		common.DiscordClientSecret = value | ||||
| 	case "Footer": | ||||
| 		common.Footer = value | ||||
| 	case "SystemName": | ||||
| @@ -193,6 +215,8 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 		common.QuotaRemindThreshold, _ = strconv.Atoi(value) | ||||
| 	case "PreConsumedQuota": | ||||
| 		common.PreConsumedQuota, _ = strconv.Atoi(value) | ||||
| 	case "RetryTimes": | ||||
| 		common.RetryTimes, _ = strconv.Atoi(value) | ||||
| 	case "ModelRatio": | ||||
| 		err = common.UpdateModelRatioByJSONString(value) | ||||
| 	case "GroupRatio": | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package model | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
| ) | ||||
|  | ||||
| @@ -48,26 +49,27 @@ func Redeem(key string, userId int) (quota int, err error) { | ||||
| 		return 0, errors.New("无效的 user id") | ||||
| 	} | ||||
| 	redemption := &Redemption{} | ||||
| 	err = DB.Where("`key` = ?", key).First(redemption).Error | ||||
|  | ||||
| 	err = DB.Transaction(func(tx *gorm.DB) error { | ||||
| 		err := DB.Where("`key` = ?", key).First(redemption).Error | ||||
| 		if err != nil { | ||||
| 		return 0, errors.New("无效的兑换码") | ||||
| 			return errors.New("无效的兑换码") | ||||
| 		} | ||||
| 		if redemption.Status != common.RedemptionCodeStatusEnabled { | ||||
| 		return 0, errors.New("该兑换码已被使用") | ||||
| 			return errors.New("该兑换码已被使用") | ||||
| 		} | ||||
| 	err = IncreaseUserQuota(userId, redemption.Quota) | ||||
| 		err = DB.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error | ||||
| 		if err != nil { | ||||
| 		return 0, err | ||||
| 			return err | ||||
| 		} | ||||
| 	go func() { | ||||
| 		redemption.RedeemedTime = common.GetTimestamp() | ||||
| 		redemption.Status = common.RedemptionCodeStatusUsed | ||||
| 		err := redemption.SelectUpdate() | ||||
| 		return redemption.SelectUpdate() | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 			common.SysError("failed to update redemption status: " + err.Error()) | ||||
| 		return 0, errors.New("兑换失败," + err.Error()) | ||||
| 	} | ||||
| 	RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) | ||||
| 	}() | ||||
| 	return redemption.Quota, nil | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,8 +3,9 @@ package model | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| type Token struct { | ||||
| @@ -19,6 +20,7 @@ type Token struct { | ||||
| 	RemainQuota    int    `json:"remain_quota" gorm:"default:0"` | ||||
| 	UnlimitedQuota bool   `json:"unlimited_quota" gorm:"default:false"` | ||||
| 	UsedQuota      int    `json:"used_quota" gorm:"default:0"` // used quota | ||||
| 	Models         string `json:"models"` | ||||
| } | ||||
|  | ||||
| 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 | ||||
| func (token *Token) Update() 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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,9 +3,10 @@ package model | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // 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 | ||||
| 	Email            string `json:"email" gorm:"index" validate:"max=50"` | ||||
| 	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"` | ||||
| 	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 | ||||
| @@ -169,6 +171,14 @@ func (user *User) FillUserByGitHubId() error { | ||||
| 	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 { | ||||
| 	if user.WeChatId == "" { | ||||
| 		return errors.New("WeChat id 为空!") | ||||
| @@ -197,6 +207,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool { | ||||
| 	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 { | ||||
| 	return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1 | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| package router | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-contrib/gzip" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"one-api/controller" | ||||
| 	"one-api/middleware" | ||||
|  | ||||
| 	"github.com/gin-contrib/gzip" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| 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.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) | ||||
| 		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/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) | ||||
| 		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.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) | ||||
|  | ||||
| 			selfRoute := userRoute.Group("/") | ||||
| @@ -35,7 +37,7 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 			{ | ||||
| 				selfRoute.GET("/self", controller.GetSelf) | ||||
| 				selfRoute.PUT("/self", controller.UpdateSelf) | ||||
| 				selfRoute.DELETE("/self", controller.DeleteSelf) | ||||
| 				selfRoute.DELETE("/self", middleware.TurnstileCheck(), controller.DeleteSelf) | ||||
| 				selfRoute.GET("/token", controller.GenerateAccessToken) | ||||
| 				selfRoute.GET("/aff", controller.GetAffCode) | ||||
| 				selfRoute.POST("/topup", controller.TopUp) | ||||
| @@ -60,11 +62,13 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 			optionRoute.PUT("/", controller.UpdateOption) | ||||
| 		} | ||||
| 		channelRoute := apiRouter.Group("/channel") | ||||
|  | ||||
| 		channelRoute.Use(middleware.UserAuth()).GET("/models", controller.ListModels) | ||||
|  | ||||
| 		channelRoute.Use(middleware.AdminAuth()) | ||||
| 		{ | ||||
| 			channelRoute.GET("/", controller.GetAllChannels) | ||||
| 			channelRoute.GET("/search", controller.SearchChannels) | ||||
| 			channelRoute.GET("/models", controller.ListModels) | ||||
| 			channelRoute.GET("/:id", controller.GetChannel) | ||||
| 			channelRoute.GET("/test", controller.TestAllChannels) | ||||
| 			channelRoute.GET("/test/:id", controller.TestChannel) | ||||
| @@ -74,6 +78,7 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 			channelRoute.PUT("/", controller.UpdateChannel) | ||||
| 			channelRoute.DELETE("/:id", controller.DeleteChannel) | ||||
| 		} | ||||
|  | ||||
| 		tokenRoute := apiRouter.Group("/token") | ||||
| 		tokenRoute.Use(middleware.UserAuth()) | ||||
| 		{ | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| ) | ||||
| @@ -14,6 +15,10 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { | ||||
| 	SetDashboardRouter(router) | ||||
| 	SetRelayRouter(router) | ||||
| 	frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") | ||||
| 	if common.IsMasterNode && frontendBaseUrl != "" { | ||||
| 		frontendBaseUrl = "" | ||||
| 		common.SysLog("FRONTEND_BASE_URL is ignored on master node") | ||||
| 	} | ||||
| 	if frontendBaseUrl == "" { | ||||
| 		SetWebRouter(router, buildFS, indexPage) | ||||
| 	} else { | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| package router | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"one-api/controller" | ||||
| 	"one-api/middleware" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func SetRelayRouter(router *gin.Engine) { | ||||
| @@ -19,11 +20,12 @@ func SetRelayRouter(router *gin.Engine) { | ||||
| 	{ | ||||
| 		relayV1Router.POST("/completions", controller.Relay) | ||||
| 		relayV1Router.POST("/chat/completions", controller.Relay) | ||||
| 		relayV1Router.POST("/edits", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/images/generations", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/edits", controller.Relay) | ||||
| 		relayV1Router.POST("/images/generations", controller.Relay) | ||||
| 		relayV1Router.POST("/images/edits", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/images/variations", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/embeddings", controller.Relay) | ||||
| 		relayV1Router.POST("/engines/:model/embeddings", controller.Relay) | ||||
| 		relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/audio/translations", controller.RelayNotImplemented) | ||||
| 		relayV1Router.GET("/files", controller.RelayNotImplemented) | ||||
|   | ||||
							
								
								
									
										1
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -22,5 +22,4 @@ npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| .idea | ||||
| package-lock.json | ||||
| yarn.lock | ||||
| @@ -10,8 +10,8 @@ npm start | ||||
| npm run build | ||||
| ``` | ||||
|  | ||||
| If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build, | ||||
| for example: `REACT_APP_SERVER=http://your.domain.com`. | ||||
| If you want to change the default server, please set `VITE_REACT_APP_SERVER` environment variables before build, | ||||
| 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. | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <!DOCTYPE html> | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
| @@ -14,5 +14,6 @@ | ||||
|   <body> | ||||
|     <noscript>You need to enable JavaScript to run this app.</noscript> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="./src/index.jsx"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										1812
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1812
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,24 +3,21 @@ | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "axios": "^0.27.2", | ||||
|     "axios": "^1.4.0", | ||||
|     "history": "^5.3.0", | ||||
|     "marked": "^4.1.1", | ||||
|     "marked": "^5.1.1", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-dropzone": "^14.2.3", | ||||
|     "react-router-dom": "^6.3.0", | ||||
|     "react-scripts": "5.0.1", | ||||
|     "react-toastify": "^9.0.8", | ||||
|     "react-turnstile": "^1.0.5", | ||||
|     "react-router-dom": "^6.14.1", | ||||
|     "react-toastify": "^9.1.3", | ||||
|     "react-turnstile": "^1.1.1", | ||||
|     "semantic-ui-css": "^2.5.0", | ||||
|     "semantic-ui-react": "^2.1.3" | ||||
|     "semantic-ui-react": "^2.1.4" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
|     "build": "react-scripts build", | ||||
|     "test": "react-scripts test", | ||||
|     "eject": "react-scripts eject" | ||||
|     "start": "vite preview", | ||||
|     "build": "vite build" | ||||
|   }, | ||||
|   "eslintConfig": { | ||||
|     "extends": [ | ||||
| @@ -41,7 +38,9 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "prettier": "^2.7.1" | ||||
|     "@vitejs/plugin-react": "^4.0.3", | ||||
|     "prettier": "3.0.0", | ||||
|     "vite": "^4.4.4" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "singleQuote": true, | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import AddUser from './pages/User/AddUser'; | ||||
| import { API, getLogo, getSystemName, showError, showNotice } from './helpers'; | ||||
| import PasswordResetForm from './components/PasswordResetForm'; | ||||
| import GitHubOAuth from './components/GitHubOAuth'; | ||||
| import DiscordOAuth from './components/DiscordOAuth'; | ||||
| import PasswordResetConfirm from './components/PasswordResetConfirm'; | ||||
| import { UserContext } from './context/User'; | ||||
| import { StatusContext } from './context/Status'; | ||||
| @@ -55,15 +56,6 @@ function App() { | ||||
|       } else { | ||||
|         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 { | ||||
|       showError('无法正常连接至服务器!'); | ||||
|     } | ||||
| @@ -239,6 +231,14 @@ function App() { | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|         path='/oauth/discord' | ||||
|         element={ | ||||
|           <Suspense fallback={<Loading></Loading>}> | ||||
|             <DiscordOAuth /> | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|         path='/setting' | ||||
|         element={ | ||||
| @@ -1,35 +1,51 @@ | ||||
| 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 { API, showError, showInfo, showSuccess, timestamp2string } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showSuccess, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
| 
 | ||||
| import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderGroup, renderNumber } from '../helpers/render'; | ||||
| 
 | ||||
| function renderTimestamp(timestamp) { | ||||
|   return ( | ||||
|     <> | ||||
|       {timestamp2string(timestamp)} | ||||
|     </> | ||||
|   ); | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
| 
 | ||||
| let type2label = undefined; | ||||
| 
 | ||||
| function renderType(type) { | ||||
|   if (!type2label) { | ||||
|     type2label = new Map; | ||||
|     type2label = new Map(); | ||||
|     for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { | ||||
|       type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; | ||||
|     } | ||||
|     type2label[0] = { value: 0, text: '未知类型', 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) { | ||||
|   switch (type) { | ||||
|     case 1: // OpenAI | ||||
|       return <span>${balance.toFixed(2)}</span>; | ||||
|     case 4: // CloseAI | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 8: // 自定义 | ||||
|       return <span>${balance.toFixed(2)}</span>; | ||||
|     case 5: // OpenAI-SB | ||||
| @@ -38,6 +54,8 @@ function renderBalance(type, balance) { | ||||
|       return <span>{renderNumber(balance)}</span>; | ||||
|     case 12: // API2GPT | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 13: // AIGC2D | ||||
|       return <span>{renderNumber(balance)}</span>; | ||||
|     default: | ||||
|       return <span>不支持</span>; | ||||
|   } | ||||
| @@ -58,8 +76,8 @@ const ChannelsTable = () => { | ||||
|       if (startIdx === 0) { | ||||
|         setChannels(data); | ||||
|       } else { | ||||
|         let newChannels = channels; | ||||
|         newChannels.push(...data); | ||||
|         let newChannels = [...channels]; | ||||
|         newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); | ||||
|         setChannels(newChannels); | ||||
|       } | ||||
|     } else { | ||||
| @@ -80,7 +98,7 @@ const ChannelsTable = () => { | ||||
| 
 | ||||
|   const refresh = async () => { | ||||
|     setLoading(true); | ||||
|     await loadChannels(0); | ||||
|     await loadChannels(activePage - 1); | ||||
|   }; | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @@ -127,7 +145,11 @@ const ChannelsTable = () => { | ||||
|   const renderStatus = (status) => { | ||||
|     switch (status) { | ||||
|       case 1: | ||||
|         return <Label basic color='green'>已启用</Label>; | ||||
|         return ( | ||||
|           <Label basic color='green'> | ||||
|             已启用 | ||||
|           </Label> | ||||
|         ); | ||||
|       case 2: | ||||
|         return ( | ||||
|           <Label basic color='red'> | ||||
| @@ -147,15 +169,35 @@ const ChannelsTable = () => { | ||||
|     let time = responseTime / 1000; | ||||
|     time = time.toFixed(2) + ' 秒'; | ||||
|     if (responseTime === 0) { | ||||
|       return <Label basic color='grey'>未测试</Label>; | ||||
|       return ( | ||||
|         <Label basic color='grey'> | ||||
|           未测试 | ||||
|         </Label> | ||||
|       ); | ||||
|     } else if (responseTime <= 1000) { | ||||
|       return <Label basic color='green'>{time}</Label>; | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           {time} | ||||
|         </Label> | ||||
|       ); | ||||
|     } else if (responseTime <= 3000) { | ||||
|       return <Label basic color='olive'>{time}</Label>; | ||||
|       return ( | ||||
|         <Label basic color='olive'> | ||||
|           {time} | ||||
|         </Label> | ||||
|       ); | ||||
|     } else if (responseTime <= 5000) { | ||||
|       return <Label basic color='yellow'>{time}</Label>; | ||||
|       return ( | ||||
|         <Label basic color='yellow'> | ||||
|           {time} | ||||
|         </Label> | ||||
|       ); | ||||
|     } else { | ||||
|       return <Label basic color='red'>{time}</Label>; | ||||
|       return ( | ||||
|         <Label basic color='red'> | ||||
|           {time} | ||||
|         </Label> | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| @@ -337,7 +379,7 @@ const ChannelsTable = () => { | ||||
|           {channels | ||||
|             .slice( | ||||
|               (activePage - 1) * ITEMS_PER_PAGE, | ||||
|               activePage * ITEMS_PER_PAGE | ||||
|               activePage * ITEMS_PER_PAGE, | ||||
|             ) | ||||
|             .map((channel, idx) => { | ||||
|               if (channel.deleted) return <></>; | ||||
| @@ -350,7 +392,11 @@ const ChannelsTable = () => { | ||||
|                   <Table.Cell>{renderStatus(channel.status)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       content={channel.test_time ? renderTimestamp(channel.test_time) : '未测试'} | ||||
|                       content={ | ||||
|                         channel.test_time | ||||
|                           ? renderTimestamp(channel.test_time) | ||||
|                           : '未测试' | ||||
|                       } | ||||
|                       key={channel.id} | ||||
|                       trigger={renderResponseTime(channel.response_time)} | ||||
|                       basic | ||||
| @@ -358,7 +404,11 @@ const ChannelsTable = () => { | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       content={channel.balance_updated_time ? renderTimestamp(channel.balance_updated_time) : '未更新'} | ||||
|                       content={ | ||||
|                         channel.balance_updated_time | ||||
|                           ? renderTimestamp(channel.balance_updated_time) | ||||
|                           : '未更新' | ||||
|                       } | ||||
|                       key={channel.id} | ||||
|                       trigger={renderBalance(channel.type, channel.balance)} | ||||
|                       basic | ||||
| @@ -410,7 +460,7 @@ const ChannelsTable = () => { | ||||
|                           manageChannel( | ||||
|                             channel.id, | ||||
|                             channel.status === 1 ? 'disable' : 'enable', | ||||
|                             idx | ||||
|                             idx, | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
| @@ -433,14 +483,24 @@ const ChannelsTable = () => { | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <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 size='small' loading={loading} onClick={testAllChannels}> | ||||
|                 测试所有已启用通道 | ||||
|               </Button> | ||||
|               <Button size='small' onClick={updateAllChannelsBalance} | ||||
|                       loading={loading || updatingBalance}>更新所有已启用通道余额</Button> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 onClick={updateAllChannelsBalance} | ||||
|                 loading={loading || updatingBalance} | ||||
|               > | ||||
|                 更新所有已启用通道余额 | ||||
|               </Button> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|                 activePage={activePage} | ||||
| @@ -452,7 +512,9 @@ const ChannelsTable = () => { | ||||
|                   (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.Row> | ||||
|         </Table.Footer> | ||||
							
								
								
									
										57
									
								
								web/src/components/DiscordOAuth.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/src/components/DiscordOAuth.jsx
									
									
									
									
									
										Normal 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; | ||||
| @@ -37,11 +37,8 @@ const Footer = () => { | ||||
|           ></div> | ||||
|         ) : ( | ||||
|           <div className='custom-footer'> | ||||
|             <a | ||||
|               href='https://github.com/songquanpeng/one-api' | ||||
|               target='_blank' | ||||
|             > | ||||
|               {systemName} {process.env.REACT_APP_VERSION}{' '} | ||||
|             <a href='https://github.com/songquanpeng/one-api' target='_blank'> | ||||
|               {systemName} {import.meta.env.VITE_REACT_APP_VERSION}{' '} | ||||
|             </a> | ||||
|             由{' '} | ||||
|             <a href='https://github.com/songquanpeng' target='_blank'> | ||||
| @@ -2,8 +2,22 @@ import React, { useContext, useState } from 'react'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { UserContext } from '../context/User'; | ||||
| 
 | ||||
| import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; | ||||
| import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; | ||||
| import { | ||||
|   Button, | ||||
|   Container, | ||||
|   Dropdown, | ||||
|   Icon, | ||||
|   Menu, | ||||
|   Segment, | ||||
| } from 'semantic-ui-react'; | ||||
| import { | ||||
|   API, | ||||
|   getLogo, | ||||
|   getSystemName, | ||||
|   isAdmin, | ||||
|   isMobile, | ||||
|   showSuccess, | ||||
| } from '../helpers'; | ||||
| import '../index.css'; | ||||
| 
 | ||||
| // Header Buttons | ||||
| @@ -11,58 +25,58 @@ let headerButtons = [ | ||||
|   { | ||||
|     name: '首页', | ||||
|     to: '/', | ||||
|     icon: 'home' | ||||
|     icon: 'home', | ||||
|   }, | ||||
|   { | ||||
|     name: '渠道', | ||||
|     to: '/channel', | ||||
|     icon: 'sitemap', | ||||
|     admin: true | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '令牌', | ||||
|     to: '/token', | ||||
|     icon: 'key' | ||||
|     icon: 'key', | ||||
|   }, | ||||
|   { | ||||
|     name: '兑换', | ||||
|     to: '/redemption', | ||||
|     icon: 'dollar sign', | ||||
|     admin: true | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '充值', | ||||
|     to: '/topup', | ||||
|     icon: 'cart' | ||||
|     icon: 'cart', | ||||
|   }, | ||||
|   { | ||||
|     name: '用户', | ||||
|     to: '/user', | ||||
|     icon: 'user', | ||||
|     admin: true | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '日志', | ||||
|     to: '/log', | ||||
|     icon: 'book' | ||||
|     icon: 'book', | ||||
|   }, | ||||
|   { | ||||
|     name: '设置', | ||||
|     to: '/setting', | ||||
|     icon: 'setting' | ||||
|     icon: 'setting', | ||||
|   }, | ||||
|   { | ||||
|     name: '关于', | ||||
|     to: '/about', | ||||
|     icon: 'info circle' | ||||
|   } | ||||
|     icon: 'info circle', | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| if (localStorage.getItem('chat_link')) { | ||||
|   headerButtons.splice(1, 0, { | ||||
|     name: '聊天', | ||||
|     to: '/chat', | ||||
|     icon: 'comments' | ||||
|     icon: 'comments', | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| @@ -123,18 +137,14 @@ const Header = () => { | ||||
|                   borderBottom: 'none', | ||||
|                   marginBottom: '0', | ||||
|                   borderTop: 'none', | ||||
|                 height: '51px' | ||||
|                   height: '51px', | ||||
|                 } | ||||
|               : { borderTop: 'none', height: '52px' } | ||||
|           } | ||||
|         > | ||||
|           <Container> | ||||
|             <Menu.Item as={Link} to='/'> | ||||
|               <img | ||||
|                 src={logo} | ||||
|                 alt='logo' | ||||
|                 style={{ marginRight: '0.75em' }} | ||||
|               /> | ||||
|               <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} /> | ||||
|               <div style={{ fontSize: '20px' }}> | ||||
|                 <b>{systemName}</b> | ||||
|               </div> | ||||
| @@ -12,7 +12,8 @@ import { | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate, useSearchParams } from 'react-router-dom'; | ||||
| 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 [inputs, setInputs] = useState({ | ||||
| @@ -24,19 +25,27 @@ const LoginForm = () => { | ||||
|   const [submitted, setSubmitted] = useState(false); | ||||
|   const { username, password } = inputs; | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   const [turnstileEnabled, setTurnstileEnabled] = useState(false); | ||||
|   const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); | ||||
|   const [turnstileToken, setTurnstileToken] = useState(''); | ||||
|   let navigate = useNavigate(); | ||||
| 
 | ||||
|   const [status, setStatus] = useState({}); | ||||
|   const logo = getLogo(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (searchParams.get("expired")) { | ||||
|     if (searchParams.get('expired')) { | ||||
|       showError('未登录或登录已过期,请重新登录!'); | ||||
|     } | ||||
|     let status = localStorage.getItem('status'); | ||||
|     if (status) { | ||||
|       status = JSON.parse(status); | ||||
|       setStatus(status); | ||||
| 
 | ||||
|       if (status.turnstile_check) { | ||||
|         setTurnstileEnabled(true); | ||||
|         setTurnstileSiteKey(status.turnstile_site_key); | ||||
|       } | ||||
|     } | ||||
|   }, []); | ||||
| 
 | ||||
| @@ -44,7 +53,13 @@ const LoginForm = () => { | ||||
| 
 | ||||
|   const onGitHubOAuthClicked = () => { | ||||
|     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%20guilds%20email%20guilds.join`, | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
| @@ -54,7 +69,7 @@ const LoginForm = () => { | ||||
| 
 | ||||
|   const onSubmitWeChatVerificationCode = async () => { | ||||
|     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; | ||||
|     if (success) { | ||||
| @@ -76,10 +91,18 @@ const LoginForm = () => { | ||||
|   async function handleSubmit(e) { | ||||
|     setSubmitted(true); | ||||
|     if (username && password) { | ||||
|       const res = await API.post('/api/user/login', { | ||||
|       if (turnstileEnabled && turnstileToken === '') { | ||||
|         showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const res = await API.post( | ||||
|         `/api/user/login?turnstile=${turnstileToken}`, | ||||
|         { | ||||
|           username, | ||||
|           password, | ||||
|       }); | ||||
|         }, | ||||
|       ); | ||||
|       const { success, message, data } = res.data; | ||||
|       if (success) { | ||||
|         userDispatch({ type: 'login', payload: data }); | ||||
| @@ -93,69 +116,83 @@ const LoginForm = () => { | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Grid textAlign="center" style={{ marginTop: '48px' }}> | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as="h2" color="" textAlign="center"> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src={logo} /> 用户登录 | ||||
|         </Header> | ||||
|         <Form size="large"> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon="user" | ||||
|               iconPosition="left" | ||||
|               placeholder="用户名" | ||||
|               name="username" | ||||
|               icon='user' | ||||
|               iconPosition='left' | ||||
|               placeholder='用户名' | ||||
|               name='username' | ||||
|               value={username} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon="lock" | ||||
|               iconPosition="left" | ||||
|               placeholder="密码" | ||||
|               name="password" | ||||
|               type="password" | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='密码' | ||||
|               name='password' | ||||
|               type='password' | ||||
|               value={password} | ||||
|               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> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|         <Message> | ||||
|           忘记密码? | ||||
|           <Link to="/reset" className="btn btn-link"> | ||||
|           <Link to='/reset' className='btn btn-link'> | ||||
|             点击重置 | ||||
|           </Link> | ||||
|           ; 没有账户? | ||||
|           <Link to="/register" className="btn btn-link"> | ||||
|           <Link to='/register' className='btn btn-link'> | ||||
|             点击注册 | ||||
|           </Link> | ||||
|         </Message> | ||||
|         {status.github_oauth || status.wechat_login ? ( | ||||
|         {status.github_oauth || status.wechat_login || status.discord_oauth ? ( | ||||
|           <> | ||||
|             <Divider horizontal>Or</Divider> | ||||
|             {status.github_oauth ? ( | ||||
|             {status.discord_oauth && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color="black" | ||||
|                 icon="github" | ||||
|                 color='blue' | ||||
|                 icon='discord' | ||||
|                 onClick={onDiscordOAuthClicked} | ||||
|               /> | ||||
|             )} | ||||
|             {status.github_oauth && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color='black' | ||||
|                 icon='github' | ||||
|                 onClick={onGitHubOAuthClicked} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             {status.wechat_login ? ( | ||||
|             {status.wechat_login && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color="green" | ||||
|                 icon="wechat" | ||||
|                 color='green' | ||||
|                 icon='wechat' | ||||
|                 onClick={onWeChatLoginClicked} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|           </> | ||||
|         ) : ( | ||||
| @@ -175,18 +212,18 @@ const LoginForm = () => { | ||||
|                   微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | ||||
|                 </p> | ||||
|               </div> | ||||
|               <Form size="large"> | ||||
|               <Form size='large'> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   placeholder="验证码" | ||||
|                   name="wechat_verification_code" | ||||
|                   placeholder='验证码' | ||||
|                   name='wechat_verification_code' | ||||
|                   value={inputs.wechat_verification_code} | ||||
|                   onChange={handleChange} | ||||
|                 /> | ||||
|                 <Button | ||||
|                   color="" | ||||
|                   color='' | ||||
|                   fluid | ||||
|                   size="large" | ||||
|                   size='large' | ||||
|                   onClick={onSubmitWeChatVerificationCode} | ||||
|                 > | ||||
|                   登录 | ||||
| @@ -1,21 +1,26 @@ | ||||
| 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 { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
| 
 | ||||
| function renderTimestamp(timestamp) { | ||||
|   return ( | ||||
|     <> | ||||
|       {timestamp2string(timestamp)} | ||||
|     </> | ||||
|   ); | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
| 
 | ||||
| const MODE_OPTIONS = [ | ||||
|   { key: 'all', text: '全部用户', value: 'all' }, | ||||
|   { key: 'self', text: '当前用户', value: 'self' } | ||||
|   { key: 'self', text: '当前用户', value: 'self' }, | ||||
| ]; | ||||
| 
 | ||||
| const LOG_OPTIONS = [ | ||||
| @@ -23,21 +28,46 @@ const LOG_OPTIONS = [ | ||||
|   { key: '1', text: '充值', value: 1 }, | ||||
|   { key: '2', text: '消费', value: 2 }, | ||||
|   { key: '3', text: '管理', value: 3 }, | ||||
|   { key: '4', text: '系统', value: 4 } | ||||
|   { key: '4', text: '系统', value: 4 }, | ||||
| ]; | ||||
| 
 | ||||
| function renderType(type) { | ||||
|   switch (type) { | ||||
|     case 1: | ||||
|       return <Label basic color='green'> 充值 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           {' '} | ||||
|           充值{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 2: | ||||
|       return <Label basic color='olive'> 消费 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='olive'> | ||||
|           {' '} | ||||
|           消费{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 3: | ||||
|       return <Label basic color='orange'> 管理 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='orange'> | ||||
|           {' '} | ||||
|           管理{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 4: | ||||
|       return <Label basic color='purple'> 系统 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='purple'> | ||||
|           {' '} | ||||
|           系统{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return <Label basic color='black'> 未知 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           {' '} | ||||
|           未知{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @@ -55,13 +85,14 @@ const LogsTable = () => { | ||||
|     token_name: '', | ||||
|     model_name: '', | ||||
|     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({ | ||||
|     quota: 0, | ||||
|     token: 0 | ||||
|     token: 0, | ||||
|   }); | ||||
| 
 | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
| @@ -71,7 +102,9 @@ const LogsTable = () => { | ||||
|   const getLogSelfStat = async () => { | ||||
|     let localStartTimestamp = Date.parse(start_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; | ||||
|     if (success) { | ||||
|       setStat(data); | ||||
| @@ -83,7 +116,9 @@ const LogsTable = () => { | ||||
|   const getLogStat = async () => { | ||||
|     let localStartTimestamp = Date.parse(start_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; | ||||
|     if (success) { | ||||
|       setStat(data); | ||||
| @@ -108,7 +143,7 @@ const LogsTable = () => { | ||||
|         setLogs(data); | ||||
|       } else { | ||||
|         let newLogs = [...logs]; | ||||
|         newLogs.push(...data); | ||||
|         newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); | ||||
|         setLogs(newLogs); | ||||
|       } | ||||
|     } else { | ||||
| @@ -129,7 +164,7 @@ const LogsTable = () => { | ||||
| 
 | ||||
|   const refresh = async () => { | ||||
|     setLoading(true); | ||||
|     setActivePage(1) | ||||
|     setActivePage(1); | ||||
|     await loadLogs(0); | ||||
|     if (isAdminUser) { | ||||
|       getLogStat().then(); | ||||
| @@ -190,28 +225,61 @@ const LogsTable = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment> | ||||
|         <Header as='h3'>使用明细(总消耗额度:{renderQuota(stat.quota)})</Header> | ||||
|         <Header as='h3'> | ||||
|           使用明细(总消耗额度:{renderQuota(stat.quota)}) | ||||
|         </Header> | ||||
|         <Form> | ||||
|           <Form.Group> | ||||
|             { | ||||
|               isAdminUser && ( | ||||
|                 <Form.Input fluid label={'用户名称'} width={2} value={username} | ||||
|                             placeholder={'可选值'} name='username' | ||||
|                             onChange={handleInputChange} /> | ||||
|               ) | ||||
|             } | ||||
|             <Form.Input fluid label={'令牌名称'} width={isAdminUser ? 2 : 3} value={token_name} | ||||
|                         placeholder={'可选值'} name='token_name' onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='模型名称' width={isAdminUser ? 2 : 3} value={model_name} placeholder='可选值' | ||||
|             {isAdminUser && ( | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 label={'用户名称'} | ||||
|                 width={2} | ||||
|                 value={username} | ||||
|                 placeholder={'可选值'} | ||||
|                 name='username' | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|             )} | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label={'令牌名称'} | ||||
|               width={isAdminUser ? 2 : 3} | ||||
|               value={token_name} | ||||
|               placeholder={'可选值'} | ||||
|               name='token_name' | ||||
|               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' | ||||
|               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' | ||||
|               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> | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Button fluid label='操作' width={2} onClick={refresh}> | ||||
|               查询 | ||||
|             </Form.Button> | ||||
|           </Form.Group> | ||||
|         </Form> | ||||
|         <Table basic compact size='small'> | ||||
| @@ -226,8 +294,8 @@ const LogsTable = () => { | ||||
|               > | ||||
|                 时间 | ||||
|               </Table.HeaderCell> | ||||
|               { | ||||
|                 isAdminUser && <Table.HeaderCell | ||||
|               {isAdminUser && ( | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('username'); | ||||
| @@ -236,7 +304,7 @@ const LogsTable = () => { | ||||
|                 > | ||||
|                   用户 | ||||
|                 </Table.HeaderCell> | ||||
|               } | ||||
|               )} | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
| @@ -307,24 +375,42 @@ const LogsTable = () => { | ||||
|             {logs | ||||
|               .slice( | ||||
|                 (activePage - 1) * ITEMS_PER_PAGE, | ||||
|                 activePage * ITEMS_PER_PAGE | ||||
|                 activePage * ITEMS_PER_PAGE, | ||||
|               ) | ||||
|               .map((log, idx) => { | ||||
|                 if (log.deleted) return <></>; | ||||
|                 return ( | ||||
|                   <Table.Row key={log.created_at}> | ||||
|                     <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell> | ||||
|                     { | ||||
|                       isAdminUser && ( | ||||
|                         <Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell> | ||||
|                       ) | ||||
|                     } | ||||
|                     <Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell> | ||||
|                     {isAdminUser && ( | ||||
|                       <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>{renderType(log.type)}</Table.Cell> | ||||
|                     <Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</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.model_name ? ( | ||||
|                         <Label basic>{log.model_name}</Label> | ||||
|                       ) : ( | ||||
|                         '' | ||||
|                       )} | ||||
|                     </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.Row> | ||||
|                 ); | ||||
| @@ -344,7 +430,9 @@ const LogsTable = () => { | ||||
|                     setLogType(value); | ||||
|                   }} | ||||
|                 /> | ||||
|                 <Button size='small' onClick={refresh} loading={loading}>刷新</Button> | ||||
|                 <Button size='small' onClick={refresh} loading={loading}> | ||||
|                   刷新 | ||||
|                 </Button> | ||||
|                 <Pagination | ||||
|                   floated='right' | ||||
|                   activePage={activePage} | ||||
| @@ -18,7 +18,9 @@ const OperationSetting = () => { | ||||
|     ChannelDisableThreshold: 0, | ||||
|     LogConsumeEnabled: '', | ||||
|     DisplayInCurrencyEnabled: '', | ||||
|     DisplayTokenStatEnabled: '' | ||||
|     DisplayTokenStatEnabled: '', | ||||
|     ApproximateTokenEnabled: '', | ||||
|     RetryTimes: 0, | ||||
|   }); | ||||
|   const [originInputs, setOriginInputs] = useState({}); | ||||
|   let [loading, setLoading] = useState(false); | ||||
| @@ -52,7 +54,7 @@ const OperationSetting = () => { | ||||
|     } | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -74,14 +76,22 @@ const OperationSetting = () => { | ||||
|   const submitConfig = async (group) => { | ||||
|     switch (group) { | ||||
|       case 'monitor': | ||||
|         if (originInputs['AutomaticDisableChannelEnabled'] !== inputs.AutomaticDisableChannelEnabled) { | ||||
|           await updateOption('AutomaticDisableChannelEnabled', inputs.AutomaticDisableChannelEnabled); | ||||
|         if ( | ||||
|           originInputs['ChannelDisableThreshold'] !== | ||||
|           inputs.ChannelDisableThreshold | ||||
|         ) { | ||||
|           await updateOption( | ||||
|             'ChannelDisableThreshold', | ||||
|             inputs.ChannelDisableThreshold, | ||||
|           ); | ||||
|         } | ||||
|         if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { | ||||
|           await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); | ||||
|         } | ||||
|         if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) { | ||||
|           await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold); | ||||
|         if ( | ||||
|           originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold | ||||
|         ) { | ||||
|           await updateOption( | ||||
|             'QuotaRemindThreshold', | ||||
|             inputs.QuotaRemindThreshold, | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|       case 'ratio': | ||||
| @@ -124,6 +134,9 @@ const OperationSetting = () => { | ||||
|         if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) { | ||||
|           await updateOption('QuotaPerUnit', inputs.QuotaPerUnit); | ||||
|         } | ||||
|         if (originInputs['RetryTimes'] !== inputs.RetryTimes) { | ||||
|           await updateOption('RetryTimes', inputs.RetryTimes); | ||||
|         } | ||||
|         break; | ||||
|     } | ||||
|   }; | ||||
| @@ -132,10 +145,8 @@ const OperationSetting = () => { | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
|         <Form loading={loading}> | ||||
|           <Header as='h3'> | ||||
|             通用设置 | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|           <Header as='h3'>通用设置</Header> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input | ||||
|               label='充值链接' | ||||
|               name='TopUpLink' | ||||
| @@ -164,6 +175,17 @@ const OperationSetting = () => { | ||||
|               step='0.01' | ||||
|               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 inline> | ||||
|             <Form.Checkbox | ||||
| @@ -184,14 +206,22 @@ const OperationSetting = () => { | ||||
|               name='DisplayTokenStatEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.ApproximateTokenEnabled === 'true'} | ||||
|               label='使用近似的方式估算 token 数以减少计算量' | ||||
|               name='ApproximateTokenEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('general').then(); | ||||
|           }}>保存通用设置</Form.Button> | ||||
|             }} | ||||
|           > | ||||
|             保存通用设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             监控设置 | ||||
|           </Header> | ||||
|           <Header as='h3'>监控设置</Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='最长响应时间' | ||||
| @@ -222,13 +252,15 @@ const OperationSetting = () => { | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('monitor').then(); | ||||
|           }}>保存监控设置</Form.Button> | ||||
|             }} | ||||
|           > | ||||
|             保存监控设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             额度设置 | ||||
|           </Header> | ||||
|           <Header as='h3'>额度设置</Header> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input | ||||
|               label='新用户初始额度' | ||||
| @@ -271,13 +303,15 @@ const OperationSetting = () => { | ||||
|               placeholder='例如:1000' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('quota').then(); | ||||
|           }}>保存额度设置</Form.Button> | ||||
|             }} | ||||
|           > | ||||
|             保存额度设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             倍率设置 | ||||
|           </Header> | ||||
|           <Header as='h3'>倍率设置</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='模型倍率' | ||||
| @@ -300,9 +334,13 @@ const OperationSetting = () => { | ||||
|               placeholder='为一个 JSON 文本,键为分组名称,值为倍率' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('ratio').then(); | ||||
|           }}>保存倍率设置</Form.Button> | ||||
|             }} | ||||
|           > | ||||
|             保存倍率设置 | ||||
|           </Form.Button> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
| @@ -1,5 +1,13 @@ | ||||
| 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 { marked } from 'marked'; | ||||
| 
 | ||||
| @@ -10,13 +18,13 @@ const OtherSetting = () => { | ||||
|     About: '', | ||||
|     SystemName: '', | ||||
|     Logo: '', | ||||
|     HomePageContent: '' | ||||
|     HomePageContent: '', | ||||
|   }); | ||||
|   let [loading, setLoading] = useState(false); | ||||
|   const [showUpdateModal, setShowUpdateModal] = useState(false); | ||||
|   const [updateData, setUpdateData] = useState({ | ||||
|     tag_name: '', | ||||
|     content: '' | ||||
|     content: '', | ||||
|   }); | ||||
| 
 | ||||
|   const getOptions = async () => { | ||||
| @@ -43,7 +51,7 @@ const OtherSetting = () => { | ||||
|     setLoading(true); | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -83,21 +91,20 @@ const OtherSetting = () => { | ||||
|   }; | ||||
| 
 | ||||
|   const openGitHubRelease = () => { | ||||
|     window.location = | ||||
|       'https://github.com/songquanpeng/one-api/releases/latest'; | ||||
|     window.location = 'https://github.com/songquanpeng/one-api/releases/latest'; | ||||
|   }; | ||||
| 
 | ||||
|   const checkUpdate = async () => { | ||||
|     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; | ||||
|     if (tag_name === process.env.REACT_APP_VERSION) { | ||||
|     if (tag_name === import.meta.env.VITE_REACT_APP_VERSION) { | ||||
|       showSuccess(`已是最新版本:${tag_name}`); | ||||
|     } else { | ||||
|       setUpdateData({ | ||||
|         tag_name: tag_name, | ||||
|         content: marked.parse(body) | ||||
|         content: marked.parse(body), | ||||
|       }); | ||||
|       setShowUpdateModal(true); | ||||
|     } | ||||
| @@ -153,7 +160,9 @@ const OtherSetting = () => { | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button> | ||||
|           <Form.Button onClick={() => submitOption('HomePageContent')}> | ||||
|             保存首页内容 | ||||
|           </Form.Button> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='关于' | ||||
| @@ -165,7 +174,10 @@ const OtherSetting = () => { | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitAbout}>保存关于</Form.Button> | ||||
|           <Message>移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。</Message> | ||||
|           <Message> | ||||
|             移除 One API | ||||
|             的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。 | ||||
|           </Message> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='页脚' | ||||
| @@ -1,6 +1,13 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; | ||||
| import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showNotice, | ||||
|   showSuccess, | ||||
| } from '../helpers'; | ||||
| import { useSearchParams } from 'react-router-dom'; | ||||
| 
 | ||||
| const PasswordResetConfirm = () => { | ||||
| @@ -38,7 +38,7 @@ const PasswordResetForm = () => { | ||||
|     } | ||||
|     setLoading(true); | ||||
|     const res = await API.get( | ||||
|       `/api/reset_password?email=${email}&turnstile=${turnstileToken}` | ||||
|       `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, | ||||
|     ); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -1,18 +1,40 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; | ||||
| import React, { useEffect, useState, useContext } from 'react'; | ||||
| import { | ||||
|   Button, | ||||
|   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 { UserContext } from '../context/User'; | ||||
| 
 | ||||
| const PersonalSetting = () => { | ||||
|   const [userDispatch] = useContext(UserContext); | ||||
|   let navigate = useNavigate(); | ||||
| 
 | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     wechat_verification_code: '', | ||||
|     email_verification_code: '', | ||||
|     email: '', | ||||
|     self_account_deletion_confirmation: '', | ||||
|   }); | ||||
|   const [status, setStatus] = useState({}); | ||||
|   const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); | ||||
|   const [showEmailBindModal, setShowEmailBindModal] = useState(false); | ||||
|   const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); | ||||
|   const [turnstileEnabled, setTurnstileEnabled] = useState(false); | ||||
|   const [turnstileSiteKey, setTurnstileSiteKey] = 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 () => { | ||||
|     if (inputs.wechat_verification_code === '') return; | ||||
|     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; | ||||
|     if (success) { | ||||
| @@ -73,7 +115,13 @@ const PersonalSetting = () => { | ||||
| 
 | ||||
|   const openGitHubOAuth = () => { | ||||
|     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}&response_type=code&redirect_uri=${window.location.origin}/oauth/discord&scope=identify%20guilds%20email%20guilds.join`, | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
| @@ -85,7 +133,7 @@ const PersonalSetting = () => { | ||||
|     } | ||||
|     setLoading(true); | ||||
|     const res = await API.get( | ||||
|       `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` | ||||
|       `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, | ||||
|     ); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -100,7 +148,7 @@ const PersonalSetting = () => { | ||||
|     if (inputs.email_verification_code === '') return; | ||||
|     setLoading(true); | ||||
|     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; | ||||
|     if (success) { | ||||
| @@ -116,17 +164,25 @@ const PersonalSetting = () => { | ||||
|     <div style={{ lineHeight: '40px' }}> | ||||
|       <Header as='h3'>通用设置</Header> | ||||
|       <Message> | ||||
|         注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。 | ||||
|         注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI | ||||
|         相关的服务,请知悉。 | ||||
|       </Message> | ||||
|       <Button as={Link} to={`/user/edit/`}> | ||||
|         更新个人信息 | ||||
|       </Button> | ||||
|       <Button onClick={generateAccessToken}>生成系统访问令牌</Button> | ||||
|       <Button onClick={getAffLink}>复制邀请链接</Button> | ||||
|       <Button | ||||
|         onClick={() => { | ||||
|           setShowAccountDeleteModal(true); | ||||
|         }} | ||||
|         color='red' | ||||
|       > | ||||
|         删除个人账户 | ||||
|       </Button> | ||||
|       <Divider /> | ||||
|       <Header as='h3'>账号绑定</Header> | ||||
|       { | ||||
|         status.wechat_login && ( | ||||
|       {status.wechat_login && ( | ||||
|         <Button | ||||
|           onClick={() => { | ||||
|             setShowWeChatBindModal(true); | ||||
| @@ -134,8 +190,7 @@ const PersonalSetting = () => { | ||||
|         > | ||||
|           绑定微信账号 | ||||
|         </Button> | ||||
|         ) | ||||
|       } | ||||
|       )} | ||||
|       <Modal | ||||
|         onClose={() => setShowWeChatBindModal(false)} | ||||
|         onOpen={() => setShowWeChatBindModal(true)} | ||||
| @@ -165,11 +220,12 @@ const PersonalSetting = () => { | ||||
|           </Modal.Description> | ||||
|         </Modal.Content> | ||||
|       </Modal> | ||||
|       { | ||||
|         status.github_oauth && ( | ||||
|       {status.github_oauth && ( | ||||
|         <Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       )} | ||||
|       {status.discord_oauth && ( | ||||
|         <Button onClick={openDiscordOAuth}>绑定 Discord 账号</Button> | ||||
|       )} | ||||
|       <Button | ||||
|         onClick={() => { | ||||
|           setShowEmailBindModal(true); | ||||
| @@ -230,6 +286,48 @@ const PersonalSetting = () => { | ||||
|           </Modal.Description> | ||||
|         </Modal.Content> | ||||
|       </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> | ||||
|   ); | ||||
| }; | ||||
| @@ -2,7 +2,6 @@ import { Navigate } from 'react-router-dom'; | ||||
| 
 | ||||
| import { history } from '../helpers'; | ||||
| 
 | ||||
| 
 | ||||
| function PrivateRoute({ children }) { | ||||
|   if (!localStorage.getItem('user')) { | ||||
|     return <Navigate to='/login' state={{ from: history.location }} />; | ||||
| @@ -1,29 +1,59 @@ | ||||
| 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 { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showSuccess, | ||||
|   showWarning, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
| 
 | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
| 
 | ||||
| function renderTimestamp(timestamp) { | ||||
|   return ( | ||||
|     <> | ||||
|       {timestamp2string(timestamp)} | ||||
|     </> | ||||
|   ); | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
| 
 | ||||
| function renderStatus(status) { | ||||
|   switch (status) { | ||||
|     case 1: | ||||
|       return <Label basic color='green'>未使用</Label>; | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           未使用 | ||||
|         </Label> | ||||
|       ); | ||||
|     case 2: | ||||
|       return <Label basic color='red'> 已禁用 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='red'> | ||||
|           {' '} | ||||
|           已禁用{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 3: | ||||
|       return <Label basic color='grey'> 已使用 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='grey'> | ||||
|           {' '} | ||||
|           已使用{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return <Label basic color='black'> 未知状态 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           {' '} | ||||
|           未知状态{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @@ -110,7 +140,9 @@ const RedemptionsTable = () => { | ||||
|       return; | ||||
|     } | ||||
|     setSearching(true); | ||||
|     const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`); | ||||
|     const res = await API.get( | ||||
|       `/api/redemption/search?keyword=${searchKeyword}`, | ||||
|     ); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       setRedemptions(data); | ||||
| @@ -212,18 +244,26 @@ const RedemptionsTable = () => { | ||||
|           {redemptions | ||||
|             .slice( | ||||
|               (activePage - 1) * ITEMS_PER_PAGE, | ||||
|               activePage * ITEMS_PER_PAGE | ||||
|               activePage * ITEMS_PER_PAGE, | ||||
|             ) | ||||
|             .map((redemption, idx) => { | ||||
|               if (redemption.deleted) return <></>; | ||||
|               return ( | ||||
|                 <Table.Row key={redemption.id}> | ||||
|                   <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>{renderQuota(redemption.quota)}</Table.Cell> | ||||
|                   <Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell> | ||||
|                   <Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {renderTimestamp(redemption.created_time)} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {redemption.redeemed_time | ||||
|                       ? renderTimestamp(redemption.redeemed_time) | ||||
|                       : '尚未兑换'}{' '} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <div> | ||||
|                       <Button | ||||
| @@ -233,7 +273,9 @@ const RedemptionsTable = () => { | ||||
|                           if (await copy(redemption.key)) { | ||||
|                             showSuccess('已复制到剪贴板!'); | ||||
|                           } else { | ||||
|                             showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。') | ||||
|                             showWarning( | ||||
|                               '无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。', | ||||
|                             ); | ||||
|                             setSearchKeyword(redemption.key); | ||||
|                           } | ||||
|                         }} | ||||
| @@ -256,7 +298,7 @@ const RedemptionsTable = () => { | ||||
|                           manageRedemption( | ||||
|                             redemption.id, | ||||
|                             redemption.status === 1 ? 'disable' : 'enable', | ||||
|                             idx | ||||
|                             idx, | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
| @@ -279,7 +321,12 @@ const RedemptionsTable = () => { | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <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> | ||||
|               <Pagination | ||||
| @@ -73,7 +73,7 @@ const RegisterForm = () => { | ||||
|       inputs.aff_code = affCode; | ||||
|       const res = await API.post( | ||||
|         `/api/user/register?turnstile=${turnstileToken}`, | ||||
|         inputs | ||||
|         inputs, | ||||
|       ); | ||||
|       const { success, message } = res.data; | ||||
|       if (success) { | ||||
| @@ -94,7 +94,7 @@ const RegisterForm = () => { | ||||
|     } | ||||
|     setLoading(true); | ||||
|     const res = await API.get( | ||||
|       `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}` | ||||
|       `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, | ||||
|     ); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -8,8 +8,14 @@ const SystemSetting = () => { | ||||
|     PasswordRegisterEnabled: '', | ||||
|     EmailVerificationEnabled: '', | ||||
|     GitHubOAuthEnabled: '', | ||||
|     DiscordOAuthEnabled: '', | ||||
|     GitHubClientId: '', | ||||
|     GitHubClientSecret: '', | ||||
|     DiscordClientId: '', | ||||
|     DiscordAllowJoiningGuild: 'false', | ||||
|     DiscordGuildId: '', | ||||
|     DiscordBotToken: '', | ||||
|     DiscordClientSecret: '', | ||||
|     Notice: '', | ||||
|     SMTPServer: '', | ||||
|     SMTPPort: '', | ||||
| @@ -56,6 +62,7 @@ const SystemSetting = () => { | ||||
|       case 'PasswordRegisterEnabled': | ||||
|       case 'EmailVerificationEnabled': | ||||
|       case 'GitHubOAuthEnabled': | ||||
|       case 'DiscordOAuthEnabled': | ||||
|       case 'WeChatAuthEnabled': | ||||
|       case 'TurnstileCheckEnabled': | ||||
|       case 'RegisterEnabled': | ||||
| @@ -66,7 +73,7 @@ const SystemSetting = () => { | ||||
|     } | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -82,6 +89,11 @@ const SystemSetting = () => { | ||||
|       name === 'Notice' || | ||||
|       name.startsWith('SMTP') || | ||||
|       name === 'ServerAddress' || | ||||
|       name === 'DiscordClientId' || | ||||
|       name === 'DiscordGuildId' || | ||||
|       name === 'DiscordAllowJoiningGuild' || | ||||
|       name === 'DiscordBotToken' || | ||||
|       name === 'DiscordClientSecret' || | ||||
|       name === 'GitHubClientId' || | ||||
|       name === 'GitHubClientSecret' || | ||||
|       name === 'WeChatServerAddress' || | ||||
| @@ -129,7 +141,7 @@ const SystemSetting = () => { | ||||
|     if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) { | ||||
|       await updateOption( | ||||
|         'WeChatServerAddress', | ||||
|         removeTrailingSlash(inputs.WeChatServerAddress) | ||||
|         removeTrailingSlash(inputs.WeChatServerAddress), | ||||
|       ); | ||||
|     } | ||||
|     if ( | ||||
| @@ -138,7 +150,7 @@ const SystemSetting = () => { | ||||
|     ) { | ||||
|       await updateOption( | ||||
|         'WeChatAccountQRCodeImageURL', | ||||
|         inputs.WeChatAccountQRCodeImageURL | ||||
|         inputs.WeChatAccountQRCodeImageURL, | ||||
|       ); | ||||
|     } | ||||
|     if ( | ||||
| @@ -161,6 +173,36 @@ 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); | ||||
|     } | ||||
|     if (originInputs['DiscordGuildId'] !== inputs.DiscordGuildId) { | ||||
|       await updateOption('DiscordGuildId', inputs.DiscordGuildId); | ||||
|     } | ||||
|     if ( | ||||
|       originInputs['DiscordBotToken'] !== inputs.DiscordBotToken && | ||||
|       inputs.DiscordBotToken !== '' | ||||
|     ) { | ||||
|       await updateOption('DiscordBotToken', inputs.DiscordBotToken); | ||||
|     } | ||||
|     if ( | ||||
|       originInputs['DiscordAllowJoiningGuild'] !== | ||||
|       inputs.DiscordAllowJoiningGuild | ||||
|     ) { | ||||
|       await updateOption( | ||||
|         'DiscordAllowJoiningGuild', | ||||
|         inputs.DiscordAllowJoiningGuild, | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const submitTurnstile = async () => { | ||||
|     if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) { | ||||
|       await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey); | ||||
| @@ -177,49 +219,55 @@ const SystemSetting = () => { | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
|         <Form loading={loading}> | ||||
|           <Header as='h3'>通用设置</Header> | ||||
|           <Header as='h3'>General Settings</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='服务器地址' | ||||
|               placeholder='例如:https://yourdomain.com' | ||||
|               label='Server Address' | ||||
|               placeholder='For example:https://yourdomain.com' | ||||
|               value={inputs.ServerAddress} | ||||
|               name='ServerAddress' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitServerAddress}> | ||||
|             更新服务器地址 | ||||
|             Update Server Address | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'>配置登录注册</Header> | ||||
|           <Header as='h3'>Configure Login/Registration</Header> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.PasswordLoginEnabled === 'true'} | ||||
|               label='允许通过密码进行登录' | ||||
|               label='Allow login via password' | ||||
|               name='PasswordLoginEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.PasswordRegisterEnabled === 'true'} | ||||
|               label='允许通过密码进行注册' | ||||
|               label='Allow registration via password' | ||||
|               name='PasswordRegisterEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.EmailVerificationEnabled === 'true'} | ||||
|               label='通过密码注册时需要进行邮箱验证' | ||||
|               label='Email verification is required when registering via password' | ||||
|               name='EmailVerificationEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.GitHubOAuthEnabled === 'true'} | ||||
|               label='允许通过 GitHub 账户登录 & 注册' | ||||
|               label='Allow login & registration via GitHub account' | ||||
|               name='GitHubOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.DiscordOAuthEnabled === 'true'} | ||||
|               label='允许通过 Discord 账户登录和注册' | ||||
|               name='DiscordOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.WeChatAuthEnabled === 'true'} | ||||
|               label='允许通过微信登录 & 注册' | ||||
|               label='Allow login & registration via WeChat' | ||||
|               name='WeChatAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
| @@ -227,82 +275,151 @@ const SystemSetting = () => { | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               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' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.TurnstileCheckEnabled === 'true'} | ||||
|               label='启用 Turnstile 用户校验' | ||||
|               label='Enable Turnstile user verification' | ||||
|               name='TurnstileCheckEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 SMTP | ||||
|             <Header.Subheader>用以支持系统的邮件发送</Header.Subheader> | ||||
|             Configure SMTP | ||||
|             <Header.Subheader> | ||||
|               To support the system email sending | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='SMTP 服务器地址' | ||||
|               label='SMTP Server Address' | ||||
|               name='SMTPServer' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPServer} | ||||
|               placeholder='例如:smtp.qq.com' | ||||
|               placeholder='For example: smtp.qq.com' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 端口' | ||||
|               label='SMTP Port' | ||||
|               name='SMTPPort' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPPort} | ||||
|               placeholder='默认: 587' | ||||
|               placeholder='Default: 587' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 账户' | ||||
|               label='SMTP Account' | ||||
|               name='SMTPAccount' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPAccount} | ||||
|               placeholder='通常是邮箱地址' | ||||
|               placeholder='Usually an email address' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='SMTP 发送者邮箱' | ||||
|               label='SMTP Sender email' | ||||
|               name='SMTPFrom' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPFrom} | ||||
|               placeholder='通常和邮箱地址保持一致' | ||||
|               placeholder='Usually consistent with the email address' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 访问凭证' | ||||
|               label='SMTP Access Credential' | ||||
|               name='SMTPToken' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPToken} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder='Sensitive information will not be displayed in the frontend' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button> | ||||
|           <Form.Button onClick={submitSMTP}>Save SMTP Settings</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 GitHub OAuth App | ||||
|             Configure Discord OAuth App | ||||
|             <Header.Subheader> | ||||
|               用以支持通过 GitHub 进行登录注册, | ||||
|               <a href='https://github.com/settings/developers' target='_blank'> | ||||
|                 点击此处 | ||||
|               To support login & registration via GitHub, | ||||
|               <a | ||||
|                 href='https://discord.com/developers/applications' | ||||
|                 target='_blank' | ||||
|               > | ||||
|                 Click here | ||||
|               </a> | ||||
|               管理你的 GitHub OAuth App | ||||
|               Manage your Discord OAuth App | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Message> | ||||
|             Homepage URL 填 <code>{inputs.ServerAddress}</code> | ||||
|             ,Authorization callback URL 填{' '} | ||||
|             Fill in the Homepage URL <code>{inputs.ServerAddress}</code> | ||||
|             ,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.Checkbox | ||||
|               label='Allow Joining Guild' | ||||
|               name='DiscordAllowJoiningGuild' | ||||
|               autoComplete='new-password' | ||||
|               checked={inputs.DiscordAllowJoiningGuild === 'true'} | ||||
|               onChange={(e, { name, checked }) => | ||||
|                 handleInputChange(e, { name, value: checked ? 'true' : 'false' }) | ||||
|               } | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Discord Guild ID' | ||||
|               name='DiscordGuildId' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.DiscordGuildId} | ||||
|               placeholder='Enter the ID of your Discord server' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Discord Bot Token' | ||||
|               name='DiscordBotToken' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.DiscordBotToken} | ||||
|               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> | ||||
|           </Message> | ||||
|           <Form.Group widths={3}> | ||||
| @@ -312,7 +429,7 @@ const SystemSetting = () => { | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GitHubClientId} | ||||
|               placeholder='输入你注册的 GitHub OAuth APP 的 ID' | ||||
|               placeholder='Enter your registered GitHub OAuth APP ID' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='GitHub Client Secret' | ||||
| @@ -321,65 +438,66 @@ const SystemSetting = () => { | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GitHubClientSecret} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder='Sensitive information will not be displayed in the frontend' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitGitHubOAuth}> | ||||
|             保存 GitHub OAuth 设置 | ||||
|             Save GitHub OAuth Settings | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 WeChat Server | ||||
|             Configure WeChat Server | ||||
|             <Header.Subheader> | ||||
|               用以支持通过微信进行登录注册, | ||||
|               To support login & registration via WeChat, | ||||
|               <a | ||||
|                 href='https://github.com/songquanpeng/wechat-server' | ||||
|                 target='_blank' | ||||
|               > | ||||
|                 点击此处 | ||||
|                 Click here | ||||
|               </a> | ||||
|               了解 WeChat Server | ||||
|               Learn about WeChat Server | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='WeChat Server 服务器地址' | ||||
|               label='WeChat Server Server Address' | ||||
|               name='WeChatServerAddress' | ||||
|               placeholder='例如:https://yourdomain.com' | ||||
|               placeholder='For example:https://yourdomain.com' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatServerAddress} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='WeChat Server 访问凭证' | ||||
|               label='WeChat Server Access Credential' | ||||
|               name='WeChatServerToken' | ||||
|               type='password' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatServerToken} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder='Sensitive information will not be displayed in the frontend' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='微信公众号二维码图片链接' | ||||
|               label='WeChat Public Account QR Code Image Link' | ||||
|               name='WeChatAccountQRCodeImageURL' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatAccountQRCodeImageURL} | ||||
|               placeholder='输入一个图片链接' | ||||
|               placeholder='Enter an image link' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitWeChat}> | ||||
|             保存 WeChat Server 设置 | ||||
|             Save WeChat Server Settings | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Turnstile | ||||
|             Configure Turnstile | ||||
|             <Header.Subheader> | ||||
|               用以支持用户校验, | ||||
|               To support user verification, | ||||
|               <a href='https://dash.cloudflare.com/' target='_blank'> | ||||
|                 点击此处 | ||||
|                 Click here | ||||
|               </a> | ||||
|               管理你的 Turnstile Sites,推荐选择 Invisible Widget Type | ||||
|               Manage your Turnstile Sites, recommend selecting Invisible Widget | ||||
|               Type | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
| @@ -389,7 +507,7 @@ const SystemSetting = () => { | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.TurnstileSiteKey} | ||||
|               placeholder='输入你注册的 Turnstile Site Key' | ||||
|               placeholder='Enter your registered Turnstile Site Key' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Turnstile Secret Key' | ||||
| @@ -398,11 +516,11 @@ const SystemSetting = () => { | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.TurnstileSecretKey} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder='Sensitive information will not be displayed in the frontend' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitTurnstile}> | ||||
|             保存 Turnstile 设置 | ||||
|             Save Turnstile Settings | ||||
|           </Form.Button> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
| @@ -1,31 +1,66 @@ | ||||
| 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 { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showSuccess, | ||||
|   showWarning, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
| 
 | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
| 
 | ||||
| function renderTimestamp(timestamp) { | ||||
|   return ( | ||||
|     <> | ||||
|       {timestamp2string(timestamp)} | ||||
|     </> | ||||
|   ); | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
| 
 | ||||
| function renderStatus(status) { | ||||
|   switch (status) { | ||||
|     case 1: | ||||
|       return <Label basic color='green'>已启用</Label>; | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           已启用 | ||||
|         </Label> | ||||
|       ); | ||||
|     case 2: | ||||
|       return <Label basic color='red'> 已禁用 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='red'> | ||||
|           {' '} | ||||
|           已禁用{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 3: | ||||
|       return <Label basic color='yellow'> 已过期 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='yellow'> | ||||
|           {' '} | ||||
|           已过期{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 4: | ||||
|       return <Label basic color='grey'> 已耗尽 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='grey'> | ||||
|           {' '} | ||||
|           已耗尽{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return <Label basic color='black'> 未知状态 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           {' '} | ||||
|           未知状态{' '} | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @@ -45,8 +80,8 @@ const TokensTable = () => { | ||||
|       if (startIdx === 0) { | ||||
|         setTokens(data); | ||||
|       } else { | ||||
|         let newTokens = tokens; | ||||
|         newTokens.push(...data); | ||||
|         let newTokens = [...tokens]; | ||||
|         newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); | ||||
|         setTokens(newTokens); | ||||
|       } | ||||
|     } else { | ||||
| @@ -67,8 +102,8 @@ const TokensTable = () => { | ||||
| 
 | ||||
|   const refresh = async () => { | ||||
|     setLoading(true); | ||||
|     await loadTokens(0); | ||||
|   } | ||||
|     await loadTokens(activePage - 1); | ||||
|   }; | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     loadTokens(0) | ||||
| @@ -221,7 +256,7 @@ const TokensTable = () => { | ||||
|           {tokens | ||||
|             .slice( | ||||
|               (activePage - 1) * ITEMS_PER_PAGE, | ||||
|               activePage * ITEMS_PER_PAGE | ||||
|               activePage * ITEMS_PER_PAGE, | ||||
|             ) | ||||
|             .map((token, idx) => { | ||||
|               if (token.deleted) return <></>; | ||||
| @@ -230,20 +265,30 @@ const TokensTable = () => { | ||||
|                   <Table.Cell>{token.name ? token.name : '无'}</Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(token.status)}</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>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell> | ||||
|                   <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; | ||||
|                           let key = 'sk-' + token.key; | ||||
|                           if (await copy(key)) { | ||||
|                             showSuccess('已复制到剪贴板!'); | ||||
|                           } else { | ||||
|                             showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); | ||||
|                             showWarning( | ||||
|                               '无法复制到剪贴板,请手动复制,已将令牌填入搜索框。', | ||||
|                             ); | ||||
|                             setSearchKeyword(key); | ||||
|                           } | ||||
|                         }} | ||||
| @@ -275,7 +320,7 @@ const TokensTable = () => { | ||||
|                           manageToken( | ||||
|                             token.id, | ||||
|                             token.status === 1 ? 'disable' : 'enable', | ||||
|                             idx | ||||
|                             idx, | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
| @@ -301,7 +346,9 @@ const TokensTable = () => { | ||||
|               <Button size='small' as={Link} to='/token/add' loading={loading}> | ||||
|                 添加新的令牌 | ||||
|               </Button> | ||||
|               <Button size='small' onClick={refresh} loading={loading}>刷新</Button> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 刷新 | ||||
|               </Button> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|                 activePage={activePage} | ||||
| @@ -1,10 +1,22 @@ | ||||
| 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 { API, showError, showSuccess } from '../helpers'; | ||||
| 
 | ||||
| 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) { | ||||
|   switch (role) { | ||||
| @@ -65,7 +77,7 @@ const UsersTable = () => { | ||||
|     (async () => { | ||||
|       const res = await API.post('/api/user/manage', { | ||||
|         username, | ||||
|         action | ||||
|         action, | ||||
|       }); | ||||
|       const { success, message } = res.data; | ||||
|       if (success) { | ||||
| @@ -183,14 +195,6 @@ const UsersTable = () => { | ||||
|             > | ||||
|               分组 | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
|                 sortUser('email'); | ||||
|               }} | ||||
|             > | ||||
|               邮箱地址 | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
| @@ -223,7 +227,7 @@ const UsersTable = () => { | ||||
|           {users | ||||
|             .slice( | ||||
|               (activePage - 1) * ITEMS_PER_PAGE, | ||||
|               activePage * ITEMS_PER_PAGE | ||||
|               activePage * ITEMS_PER_PAGE, | ||||
|             ) | ||||
|             .map((user, idx) => { | ||||
|               if (user.deleted) return <></>; | ||||
| @@ -233,20 +237,35 @@ const UsersTable = () => { | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       content={user.email ? user.email : '未绑定邮箱地址'} | ||||
|                       key={user.display_name} | ||||
|                       header={user.display_name ? user.display_name : user.username} | ||||
|                       key={user.username} | ||||
|                       header={ | ||||
|                         user.display_name ? user.display_name : user.username | ||||
|                       } | ||||
|                       trigger={<span>{renderText(user.username, 10)}</span>} | ||||
|                       hoverable | ||||
|                     /> | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell>{renderGroup(user.group)}</Table.Cell> | ||||
|                   {/*<Table.Cell>*/} | ||||
|                   {/*  {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/} | ||||
|                   {/*</Table.Cell>*/} | ||||
|                   <Table.Cell> | ||||
|                     {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <Popup content='剩余额度' trigger={<Label>{renderQuota(user.quota)}</Label>} /> | ||||
|                     <Popup content='已用额度' trigger={<Label>{renderQuota(user.used_quota)}</Label>} /> | ||||
|                     <Popup content='请求次数' trigger={<Label>{renderNumber(user.request_count)}</Label>} /> | ||||
|                     <Popup | ||||
|                       content='剩余额度' | ||||
|                       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>{renderRole(user.role)}</Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(user.status)}</Table.Cell> | ||||
| @@ -274,7 +293,11 @@ const UsersTable = () => { | ||||
|                       </Button> | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative disabled={user.role === 100}> | ||||
|                           <Button | ||||
|                             size='small' | ||||
|                             negative | ||||
|                             disabled={user.role === 100} | ||||
|                           > | ||||
|                             删除 | ||||
|                           </Button> | ||||
|                         } | ||||
| @@ -297,7 +320,7 @@ const UsersTable = () => { | ||||
|                           manageUser( | ||||
|                             user.username, | ||||
|                             user.status === 1 ? 'disable' : 'enable', | ||||
|                             idx | ||||
|                             idx, | ||||
|                           ); | ||||
|                         }} | ||||
|                         disabled={user.role === 100} | ||||
| @@ -320,7 +343,7 @@ const UsersTable = () => { | ||||
| 
 | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan='8'> | ||||
|             <Table.HeaderCell colSpan='7'> | ||||
|               <Button size='small' as={Link} to='/user/add' loading={loading}> | ||||
|                 添加新的用户 | ||||
|               </Button> | ||||
| @@ -9,5 +9,10 @@ export const CHANNEL_OPTIONS = [ | ||||
|   { key: 7, text: 'OhMyGPT', value: 7, color: 'purple' }, | ||||
|   { key: 9, text: 'AI.LS', value: 9, color: 'yellow' }, | ||||
|   { 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: 14, text: 'Chanzhaoyu/chatgpt-web', value: 14, color: 'purple' }, | ||||
|   { key: 14, text: 'mckaywrigley/chatbot-ui', value: 15, color: 'orange' }, | ||||
| ]; | ||||
| @@ -3,5 +3,5 @@ export const toastConstants = { | ||||
|   INFO_TIMEOUT: 3000, | ||||
|   ERROR_TIMEOUT: 5000, | ||||
|   WARNING_TIMEOUT: 10000, | ||||
|   NOTICE_TIMEOUT: 20000 | ||||
|   NOTICE_TIMEOUT: 20000, | ||||
| }; | ||||
| @@ -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'     | ||||
| }; | ||||
							
								
								
									
										19
									
								
								web/src/constants/user.constants.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								web/src/constants/user.constants.jsx
									
									
									
									
									
										Normal 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', | ||||
| }; | ||||
| @@ -1,19 +1,19 @@ | ||||
| // contexts/User/index.jsx | ||||
| 
 | ||||
| import React from "react" | ||||
| import { reducer, initialState } from "./reducer" | ||||
| import React from 'react'; | ||||
| import { reducer, initialState } from './reducer'; | ||||
| 
 | ||||
| export const UserContext = React.createContext({ | ||||
|   state: initialState, | ||||
|   dispatch: () => null | ||||
| }) | ||||
|   dispatch: () => null, | ||||
| }); | ||||
| 
 | ||||
| export const UserProvider = ({ children }) => { | ||||
|   const [state, dispatch] = React.useReducer(reducer, initialState) | ||||
|   const [state, dispatch] = React.useReducer(reducer, initialState); | ||||
| 
 | ||||
|   return ( | ||||
|     <UserContext.Provider value={[state, dispatch]}> | ||||
|       {children} | ||||
|     </UserContext.Provider> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
| @@ -3,12 +3,12 @@ export const reducer = (state, action) => { | ||||
|     case 'login': | ||||
|       return { | ||||
|         ...state, | ||||
|         user: action.payload | ||||
|         user: action.payload, | ||||
|       }; | ||||
|     case 'logout': | ||||
|       return { | ||||
|         ...state, | ||||
|         user: undefined | ||||
|         user: undefined, | ||||
|       }; | ||||
| 
 | ||||
|     default: | ||||
| @@ -17,5 +17,5 @@ export const reducer = (state, action) => { | ||||
| }; | ||||
| 
 | ||||
| export const initialState = { | ||||
|   user: undefined | ||||
|   user: undefined, | ||||
| }; | ||||
| @@ -2,12 +2,12 @@ import { showError } from './utils'; | ||||
| import axios from 'axios'; | ||||
| 
 | ||||
| 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( | ||||
|   (response) => response, | ||||
|   (error) => { | ||||
|     showError(error); | ||||
|   } | ||||
|   }, | ||||
| ); | ||||
| @@ -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 {}; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								web/src/helpers/auth-header.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								web/src/helpers/auth-header.jsx
									
									
									
									
									
										Normal 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 {}; | ||||
|   } | ||||
| } | ||||
| @@ -13,7 +13,8 @@ export function renderGroup(group) { | ||||
|   } | ||||
|   let groups = group.split(','); | ||||
|   groups.sort(); | ||||
|   return <> | ||||
|   return ( | ||||
|     <> | ||||
|       {groups.map((group) => { | ||||
|         if (group === 'vip' || group === 'pro') { | ||||
|           return <Label color='yellow'>{group}</Label>; | ||||
| @@ -22,7 +23,8 @@ export function renderGroup(group) { | ||||
|         } | ||||
|         return <Label>{group}</Label>; | ||||
|       })} | ||||
|   </>; | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function renderNumber(num) { | ||||
| @@ -24,7 +24,7 @@ export function getSystemName() { | ||||
| export function getLogo() { | ||||
|   let logo = localStorage.getItem('logo'); | ||||
|   if (!logo) return '/logo.png'; | ||||
|   return logo | ||||
|   return logo; | ||||
| } | ||||
| 
 | ||||
| export function getFooterHTML() { | ||||
| @@ -147,17 +147,7 @@ export function timestamp2string(timestamp) { | ||||
|     second = '0' + second; | ||||
|   } | ||||
|   return ( | ||||
|     year + | ||||
|     '-' + | ||||
|     month + | ||||
|     '-' + | ||||
|     day + | ||||
|     ' ' + | ||||
|     hour + | ||||
|     ':' + | ||||
|     minute + | ||||
|     ':' + | ||||
|     second | ||||
|     year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| @@ -2,7 +2,8 @@ body { | ||||
|   margin: 0; | ||||
|   padding-top: 55px; | ||||
|   overflow-y: scroll; | ||||
|     font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif; | ||||
|   font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', | ||||
|     sans-serif; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
|   scrollbar-width: none; | ||||
| @@ -13,7 +14,8 @@ body::-webkit-scrollbar { | ||||
| } | ||||
|  | ||||
| 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 { | ||||
|   | ||||
| @@ -27,5 +27,5 @@ root.render( | ||||
|         </BrowserRouter> | ||||
|       </UserProvider> | ||||
|     </StatusProvider> | ||||
|   </React.StrictMode> | ||||
|   </React.StrictMode>, | ||||
| ); | ||||
| @@ -31,8 +31,8 @@ const About = () => { | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       { | ||||
|         aboutLoaded && about === '' ? <> | ||||
|       {aboutLoaded && about === '' ? ( | ||||
|         <> | ||||
|           <Segment> | ||||
|             <Header as='h3'>关于</Header> | ||||
|             <p>可在设置页面设置关于内容,支持 HTML & Markdown</p> | ||||
| @@ -41,20 +41,26 @@ const About = () => { | ||||
|               https://github.com/songquanpeng/one-api | ||||
|             </a> | ||||
|           </Segment> | ||||
|         </> : <> | ||||
|           { | ||||
|             about.startsWith('https://') ? <iframe | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           {about.startsWith('https://') ? ( | ||||
|             <iframe | ||||
|               src={about} | ||||
|               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> | ||||
|           } | ||||
|           )} | ||||
|         </> | ||||
|       } | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| export default About; | ||||
| @@ -1,288 +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, showInfo, showSuccess } from '../../helpers'; | ||||
| import { CHANNEL_OPTIONS } from '../../constants'; | ||||
|  | ||||
| const EditChannel = () => { | ||||
|   const params = useParams(); | ||||
|   const channelId = params.id; | ||||
|   const isEdit = channelId !== undefined; | ||||
|   const [loading, setLoading] = useState(isEdit); | ||||
|   const originInputs = { | ||||
|     name: '', | ||||
|     type: 1, | ||||
|     key: '', | ||||
|     base_url: '', | ||||
|     other: '', | ||||
|     models: [], | ||||
|     groups: ['default'] | ||||
|   }; | ||||
|   const [batch, setBatch] = useState(false); | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
|   const [modelOptions, setModelOptions] = useState([]); | ||||
|   const [groupOptions, setGroupOptions] = useState([]); | ||||
|   const [basicModels, setBasicModels] = useState([]); | ||||
|   const [fullModels, setFullModels] = useState([]); | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
|  | ||||
|   const loadChannel = async () => { | ||||
|     let res = await API.get(`/api/channel/${channelId}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (data.models === '') { | ||||
|         data.models = []; | ||||
|       } else { | ||||
|         data.models = data.models.split(','); | ||||
|       } | ||||
|       if (data.group === '') { | ||||
|         data.groups = []; | ||||
|       } else { | ||||
|         data.groups = data.group.split(','); | ||||
|       } | ||||
|       setInputs(data); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   }; | ||||
|  | ||||
|   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 fetchGroups = async () => { | ||||
|     try { | ||||
|       let res = await API.get(`/api/group/`); | ||||
|       setGroupOptions(res.data.data.map((group) => ({ | ||||
|         key: group, | ||||
|         text: group, | ||||
|         value: group | ||||
|       }))); | ||||
|     } catch (error) { | ||||
|       showError(error.message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (isEdit) { | ||||
|       loadChannel().then(); | ||||
|     } | ||||
|     fetchModels().then(); | ||||
|     fetchGroups().then(); | ||||
|   }, []); | ||||
|  | ||||
|   const submit = async () => { | ||||
|     if (!isEdit && (inputs.name === '' || inputs.key === '')) { | ||||
|       showInfo('请填写渠道名称和渠道密钥!'); | ||||
|       return; | ||||
|     } | ||||
|     if (inputs.models.length === 0) { | ||||
|       showInfo('请至少选择一个模型!'); | ||||
|       return; | ||||
|     } | ||||
|     let localInputs = inputs; | ||||
|     if (localInputs.base_url.endsWith('/')) { | ||||
|       localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); | ||||
|     } | ||||
|     if (localInputs.type === 3 && localInputs.other === '') { | ||||
|       localInputs.other = '2023-03-15-preview'; | ||||
|     } | ||||
|     let res; | ||||
|     localInputs.models = localInputs.models.join(','); | ||||
|     localInputs.group = localInputs.groups.join(','); | ||||
|     if (isEdit) { | ||||
|       res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) }); | ||||
|     } else { | ||||
|       res = await API.post(`/api/channel/`, 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.Select | ||||
|               label='类型' | ||||
|               name='type' | ||||
|               options={CHANNEL_OPTIONS} | ||||
|               value={inputs.type} | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           { | ||||
|             inputs.type === 3 && ( | ||||
|               <> | ||||
|                 <Message> | ||||
|                   注意,<strong>模型部署名称必须和模型名称保持一致</strong>,因为 One API 会把请求体中的 model | ||||
|                   参数替换为你的部署名称(模型名称中的点会被剔除),<a target='_blank' | ||||
|                                                                     href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。 | ||||
|                 </Message> | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='AZURE_OPENAI_ENDPOINT' | ||||
|                     name='base_url' | ||||
|                     placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'} | ||||
|                     onChange={handleInputChange} | ||||
|                     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.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.Input | ||||
|               label='名称' | ||||
|               name='name' | ||||
|               placeholder={'请输入名称'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.name} | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='分组' | ||||
|               placeholder={'请选择分组'} | ||||
|               name='groups' | ||||
|               fluid | ||||
|               multiple | ||||
|               selection | ||||
|               allowAdditions | ||||
|               additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.groups} | ||||
|               autoComplete='new-password' | ||||
|               options={groupOptions} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='模型' | ||||
|               placeholder={'请选择该通道所支持的模型'} | ||||
|               name='models' | ||||
|               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> | ||||
|           { | ||||
|             batch ? <Form.Field> | ||||
|               <Form.TextArea | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 placeholder={'请输入密钥,一行一个'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field> : <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 placeholder={'请输入密钥'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|           } | ||||
|           { | ||||
|             !isEdit && ( | ||||
|               <Form.Checkbox | ||||
|                 checked={batch} | ||||
|                 label='批量创建' | ||||
|                 name='batch' | ||||
|                 onChange={() => setBatch(!batch)} | ||||
|               /> | ||||
|             ) | ||||
|           } | ||||
|           <Button positive onClick={submit}>提交</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default EditChannel; | ||||
							
								
								
									
										476
									
								
								web/src/pages/Channel/EditChannel.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										476
									
								
								web/src/pages/Channel/EditChannel.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,476 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Header, | ||||
|   Input, | ||||
|   Message, | ||||
|   Segment, | ||||
| } from 'semantic-ui-react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import { | ||||
|   API, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showSuccess, | ||||
|   verifyJSON, | ||||
| } from '../../helpers'; | ||||
| import { CHANNEL_OPTIONS } from '../../constants'; | ||||
|  | ||||
| const MODEL_MAPPING_EXAMPLE = { | ||||
|   'gpt-3.5-turbo-0301': 'gpt-3.5-turbo', | ||||
|   'gpt-4-0314': 'gpt-4', | ||||
|   'gpt-4-32k-0314': 'gpt-4-32k', | ||||
| }; | ||||
|  | ||||
| const CUSTOM_HTTP_HEADERS_EXAMPLE = { | ||||
|   'X-OpenAI-Organization': 'OpenAI', | ||||
| }; | ||||
|  | ||||
| const EditChannel = () => { | ||||
|   const params = useParams(); | ||||
|   const channelId = params.id; | ||||
|   const isEdit = channelId !== undefined; | ||||
|   const [loading, setLoading] = useState(isEdit); | ||||
|   const originInputs = { | ||||
|     name: '', | ||||
|     type: 1, | ||||
|     key: '', | ||||
|     base_url: '', | ||||
|     other: '', | ||||
|     model_mapping: '', | ||||
|     custom_http_headers: '', | ||||
|     models: [], | ||||
|     groups: ['default'], | ||||
|     enable_ip_randomization: false, | ||||
|   }; | ||||
|   const [batch, setBatch] = useState(false); | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
|   const [modelOptions, setModelOptions] = useState([]); | ||||
|   const [groupOptions, setGroupOptions] = useState([]); | ||||
|   const [basicModels, setBasicModels] = useState([]); | ||||
|   const [fullModels, setFullModels] = useState([]); | ||||
|   const [customModel, setCustomModel] = useState(''); | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     console.log(name, value); | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
|  | ||||
|   const loadChannel = async (modelOptions) => { | ||||
|     let res = await API.get(`/api/channel/${channelId}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (data.models === '') { | ||||
|         data.models = []; | ||||
|       } else { | ||||
|         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 === '') { | ||||
|         data.groups = []; | ||||
|       } else { | ||||
|         data.groups = data.group.split(','); | ||||
|       } | ||||
|       if (data.model_mapping !== '') { | ||||
|         data.model_mapping = JSON.stringify( | ||||
|           JSON.parse(data.model_mapping), | ||||
|           null, | ||||
|           2, | ||||
|         ); | ||||
|       } | ||||
|       if (data.custom_http_headers !== '') { | ||||
|         data.custom_http_headers = JSON.stringify( | ||||
|           JSON.parse(data.custom_http_headers), | ||||
|           null, | ||||
|           2, | ||||
|         ); | ||||
|       } | ||||
|       setInputs(data); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   }; | ||||
|  | ||||
|   const fetchModels = async () => { | ||||
|     try { | ||||
|       let res = await API.get(`/api/channel/models`); | ||||
|  | ||||
|       setFullModels(res.data.data.map((model) => model.id)); | ||||
|       setBasicModels( | ||||
|         res.data.data | ||||
|           .filter((model) => !model.id.startsWith('gpt-4')) | ||||
|           .map((model) => model.id), | ||||
|       ); | ||||
|  | ||||
|       const allModels = res.data.data.map((model) => ({ | ||||
|         key: model.id, | ||||
|         text: model.id, | ||||
|         value: model.id, | ||||
|       })); | ||||
|  | ||||
|       setModelOptions(allModels); | ||||
|  | ||||
|       return allModels; | ||||
|     } catch (error) { | ||||
|       showError(error.message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const fetchGroups = async () => { | ||||
|     try { | ||||
|       let res = await API.get(`/api/group/`); | ||||
|       setGroupOptions( | ||||
|         res.data.data.map((group) => ({ | ||||
|           key: group, | ||||
|           text: group, | ||||
|           value: group, | ||||
|         })), | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       showError(error.message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(async () => { | ||||
|     const models = await fetchModels(); | ||||
|     await fetchGroups(); | ||||
|     if (isEdit) { | ||||
|       await loadChannel(models); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const submit = async () => { | ||||
|     if (!isEdit && (inputs.name === '' || inputs.key === '')) { | ||||
|       showInfo('请填写渠道名称和渠道密钥!'); | ||||
|       return; | ||||
|     } | ||||
|     if (inputs.models.length === 0) { | ||||
|       showInfo('请至少选择一个模型!'); | ||||
|       return; | ||||
|     } | ||||
|     if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) { | ||||
|       showInfo('模型映射必须是合法的 JSON 格式!'); | ||||
|       return; | ||||
|     } | ||||
|     if ( | ||||
|       inputs.custom_http_headers !== '' && | ||||
|       !verifyJSON(inputs.custom_http_headers) | ||||
|     ) { | ||||
|       showInfo('自定义 HTTP 头必须是合法的 JSON 格式!'); | ||||
|       return; | ||||
|     } | ||||
|     let localInputs = inputs; | ||||
|     if (localInputs.base_url.endsWith('/')) { | ||||
|       localInputs.base_url = localInputs.base_url.slice( | ||||
|         0, | ||||
|         localInputs.base_url.length - 1, | ||||
|       ); | ||||
|     } | ||||
|     if (localInputs.type === 3 && localInputs.other === '') { | ||||
|       localInputs.other = '2023-03-15-preview'; | ||||
|     } | ||||
|     let res; | ||||
|     localInputs.models = localInputs.models.join(','); | ||||
|     localInputs.group = localInputs.groups.join(','); | ||||
|     if (isEdit) { | ||||
|       res = await API.put(`/api/channel/`, { | ||||
|         ...localInputs, | ||||
|         id: parseInt(channelId), | ||||
|       }); | ||||
|     } else { | ||||
|       res = await API.post(`/api/channel/`, 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.Select | ||||
|               label='类型' | ||||
|               name='type' | ||||
|               required | ||||
|               options={CHANNEL_OPTIONS} | ||||
|               value={inputs.type} | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           {inputs.type === 3 && ( | ||||
|             <> | ||||
|               <Message> | ||||
|                 注意,<strong>模型部署名称必须和模型名称保持一致</strong>,因为 | ||||
|                 One API 会把请求体中的 model | ||||
|                 参数替换为你的部署名称(模型名称中的点会被剔除), | ||||
|                 <a | ||||
|                   target='_blank' | ||||
|                   href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271' | ||||
|                 > | ||||
|                   图片演示 | ||||
|                 </a> | ||||
|                 。 | ||||
|               </Message> | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='AZURE_OPENAI_ENDPOINT' | ||||
|                   name='base_url' | ||||
|                   placeholder={ | ||||
|                     '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com' | ||||
|                   } | ||||
|                   onChange={handleInputChange} | ||||
|                   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.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.Input | ||||
|               label='名称' | ||||
|               required | ||||
|               name='name' | ||||
|               placeholder={'请输入名称'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.name} | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='分组' | ||||
|               placeholder={'请选择分组'} | ||||
|               name='groups' | ||||
|               required | ||||
|               fluid | ||||
|               multiple | ||||
|               selection | ||||
|               allowAdditions | ||||
|               additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.groups} | ||||
|               autoComplete='new-password' | ||||
|               options={groupOptions} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <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> | ||||
|             <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> | ||||
|           <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.TextArea | ||||
|               label='模型映射' | ||||
|               placeholder={`此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:\n${JSON.stringify( | ||||
|                 MODEL_MAPPING_EXAMPLE, | ||||
|                 null, | ||||
|                 2, | ||||
|               )}`} | ||||
|               name='model_mapping' | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.model_mapping} | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.TextArea | ||||
|               label='自定义 HTTP 头' | ||||
|               placeholder={`此项可选,为一个 JSON 文本,键为 HTTP 头名称,值为 HTTP 头内容,例如:\n${JSON.stringify( | ||||
|                 CUSTOM_HTTP_HEADERS_EXAMPLE, | ||||
|                 null, | ||||
|                 2, | ||||
|               )}`} | ||||
|               name='custom_http_headers' | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.custom_http_headers} | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           {batch ? ( | ||||
|             <Form.Field> | ||||
|               <Form.TextArea | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 placeholder={'请输入密钥,一行一个'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 style={{ | ||||
|                   minHeight: 150, | ||||
|                   fontFamily: 'JetBrains Mono, Consolas', | ||||
|                 }} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|           ) : ( | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 required | ||||
|                 placeholder={'请输入密钥'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|           )} | ||||
|           {!isEdit && ( | ||||
|             <Form.Checkbox | ||||
|               checked={batch} | ||||
|               label='批量创建' | ||||
|               name='batch' | ||||
|               onChange={() => setBatch(!batch)} | ||||
|             /> | ||||
|           )} | ||||
|           <Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}> | ||||
|             提交 | ||||
|           </Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default EditChannel; | ||||
| @@ -11,5 +11,4 @@ const Chat = () => { | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| export default Chat; | ||||
| @@ -52,8 +52,8 @@ const Home = () => { | ||||
|   }, []); | ||||
|   return ( | ||||
|     <> | ||||
|       { | ||||
|         homePageContentLoaded && homePageContent === '' ? <> | ||||
|       {homePageContentLoaded && homePageContent === '' ? ( | ||||
|         <> | ||||
|           <Segment> | ||||
|             <Header as='h3'>系统状况</Header> | ||||
|             <Grid columns={2} stackable> | ||||
| @@ -97,6 +97,12 @@ const Home = () => { | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         Discord 身份验证: | ||||
|                         {statusState?.status?.discord_oauth === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         微信身份验证: | ||||
|                         {statusState?.status?.wechat_login === true | ||||
| @@ -115,16 +121,22 @@ const Home = () => { | ||||
|               </Grid.Column> | ||||
|             </Grid> | ||||
|           </Segment> | ||||
|         </> : <> | ||||
|           { | ||||
|             homePageContent.startsWith('https://') ? <iframe | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           {homePageContent.startsWith('https://') ? ( | ||||
|             <iframe | ||||
|               src={homePageContent} | ||||
|               style={{ width: '100%', height: '100vh', border: 'none' }} | ||||
|             /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div> | ||||
|           } | ||||
|             /> | ||||
|           ) : ( | ||||
|             <div | ||||
|               style={{ fontSize: 'larger' }} | ||||
|               dangerouslySetInnerHTML={{ __html: homePageContent }} | ||||
|             ></div> | ||||
|           )} | ||||
|         </> | ||||
|       } | ||||
| 
 | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @@ -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; | ||||
							
								
								
									
										18
									
								
								web/src/pages/NotFound/index.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/pages/NotFound/index.jsx
									
									
									
									
									
										Normal 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; | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user