mirror of
				https://github.com/songquanpeng/one-api.git
				synced 2025-10-29 04:43:41 +08:00 
			
		
		
		
	Compare commits
	
		
			77 Commits
		
	
	
		
			v0.6.10-al
			...
			v0.6.11-al
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | c67b167f4f | ||
|  | c351e196e6 | ||
|  | a316ed7abc | ||
|  | 0895d8660e | ||
|  | be1ed114f4 | ||
|  | eb6da573a3 | ||
|  | 0a6273fc08 | ||
|  | 5997fce454 | ||
|  | 0df6d7a131 | ||
|  | 93fdb60de5 | ||
|  | 4db834da95 | ||
|  | 6818ed5ca8 | ||
|  | 7be3b5547d | ||
|  | 2d7ea61d67 | ||
|  | 83b34be067 | ||
|  | d5d879afdc | ||
|  | 0f205a3aa3 | ||
|  | 76c3f87351 | ||
|  | 6d9a92f8f7 | ||
|  | 835f0e0d67 | ||
|  | a6981f0d51 | ||
|  | 678d613179 | ||
|  | be089a072b | ||
|  | 45d10aa3df | ||
|  | 9cdd48ac22 | ||
|  | 310e7120e5 | ||
|  | 3d29713268 | ||
|  | f2c7c424e9 | ||
|  | 38a42bb265 | ||
|  | fa2e8f44b1 | ||
|  | 9f74101543 | ||
|  | 28a271a896 | ||
|  | e8ea87fff3 | ||
|  | abe2d2dba8 | ||
|  | 4bcaa064d6 | ||
|  | 52d81e0e24 | ||
|  | dc8c3bc69e | ||
|  | b4e69df802 | ||
|  | d9f74bdff3 | ||
|  | fa2a772731 | ||
|  | 4f68f3e1b3 | ||
|  | 0bab887b2d | ||
|  | 0230d36643 | ||
|  | bad57d049a | ||
|  | dc470ce82e | ||
|  | ea0721d525 | ||
|  | d0402f9086 | ||
|  | 1fead8e7f7 | ||
|  | 09911a301d | ||
|  | f95e6b78b8 | ||
|  | 605bb06667 | ||
|  | d88e07fd9a | ||
|  | 3915ce9814 | ||
|  | 999defc88b | ||
|  | b51c47bc77 | ||
|  | 4f25cde132 | ||
|  | d89e9d7e44 | ||
|  | a858292b54 | ||
|  | ff589b5e4a | ||
|  | 95e8c16338 | ||
|  | 381172cb36 | ||
|  | 59eae186a3 | ||
|  | ce52f355bb | ||
|  | cb9d0a74c9 | ||
|  | 49ffb1c60d | ||
|  | 2f16649896 | ||
|  | af3aa57bd6 | ||
|  | e9f117ff72 | ||
|  | 6bb5247bd6 | ||
|  | 305ce14fe3 | ||
|  | 36c8f4f15c | ||
|  | 45b51ea0ee | ||
|  | 7c8628bd95 | ||
|  | 6ab87f8a08 | ||
|  | 833fa7ad6f | ||
|  | 6eb0770a89 | ||
|  | 92cd46d64f | 
							
								
								
									
										10
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,19 +1,17 @@ | ||||
| name: CI | ||||
|  | ||||
| # This setup assumes that you run the unit tests with code coverage in the same | ||||
| # workflow that will also print the coverage report as comment to the pull request.  | ||||
| # workflow that will also print the coverage report as comment to the pull request. | ||||
| # Therefore, you need to trigger this workflow when a pull request is (re)opened or | ||||
| # when new code is pushed to the branch of the pull request. In addition, you also | ||||
| # need to trigger this workflow when new code is pushed to the main branch because  | ||||
| # need to trigger this workflow when new code is pushed to the main branch because | ||||
| # we need to upload the code coverage results as artifact for the main branch as | ||||
| # well since it will be the baseline code coverage. | ||||
| #  | ||||
| # | ||||
| # We do not want to trigger the workflow for pushes to *any* branch because this | ||||
| # would trigger our jobs twice on pull requests (once from "push" event and once | ||||
| # from "pull_request->synchronize") | ||||
| on: | ||||
|   pull_request: | ||||
|     types: [opened, reopened, synchronize] | ||||
|   push: | ||||
|     branches: | ||||
|       - 'main' | ||||
| @@ -31,7 +29,7 @@ jobs: | ||||
|         with: | ||||
|           go-version: ^1.22 | ||||
|  | ||||
|       # When you execute your unit tests, make sure to use the "-coverprofile" flag to write a  | ||||
|       # When you execute your unit tests, make sure to use the "-coverprofile" flag to write a | ||||
|       # coverage profile to a file. You will need the name of the file (e.g. "coverage.txt") | ||||
|       # in the next step as well as the next job. | ||||
|       - name: Test | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -9,4 +9,5 @@ logs | ||||
| data | ||||
| /web/node_modules | ||||
| cmd.md | ||||
| .env | ||||
| .env | ||||
| /one-api | ||||
|   | ||||
							
								
								
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -4,21 +4,17 @@ WORKDIR /web | ||||
| COPY ./VERSION . | ||||
| COPY ./web . | ||||
|  | ||||
| WORKDIR /web/default | ||||
| RUN npm install | ||||
| RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build | ||||
| RUN npm install --prefix /web/default & \ | ||||
|     npm install --prefix /web/berry & \ | ||||
|     npm install --prefix /web/air & \ | ||||
|     wait | ||||
|  | ||||
| WORKDIR /web/berry | ||||
| RUN npm install | ||||
| RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build | ||||
| RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/default/VERSION) npm run build --prefix /web/default & \ | ||||
|     DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/berry/VERSION) npm run build --prefix /web/berry & \ | ||||
|     DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/air/VERSION) npm run build --prefix /web/air & \ | ||||
|     wait | ||||
|  | ||||
| WORKDIR /web/air | ||||
| RUN npm install | ||||
| RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build | ||||
|  | ||||
| FROM golang:alpine AS builder2 | ||||
|  | ||||
| RUN apk add --no-cache g++ | ||||
| FROM golang AS builder2 | ||||
|  | ||||
| ENV GO111MODULE=on \ | ||||
|     CGO_ENABLED=1 \ | ||||
|   | ||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							| @@ -115,8 +115,8 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用  | ||||
| 21. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 22. 支持用户管理,支持**多种用户登录注册方式**: | ||||
|     + 邮箱登录注册(支持注册邮箱白名单)以及通过邮箱进行密码重置。 | ||||
|     + 支持使用飞书进行授权登录。 | ||||
|     + [GitHub 开放授权](https://github.com/settings/applications/new)。 | ||||
|     + 支持[飞书授权登录](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/authorize/get)([这里有 One API 的实现细节阐述供参考](https://iamazing.cn/page/feishu-oauth-login))。 | ||||
|     + 支持 [GitHub 授权登录](https://github.com/settings/applications/new)。 | ||||
|     + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 | ||||
| 23. 支持主题切换,设置环境变量 `THEME` 即可,默认为 `default`,欢迎 PR 更多主题,具体参考[此处](./web/README.md)。 | ||||
| 24. 配合 [Message Pusher](https://github.com/songquanpeng/message-pusher) 可将报警信息推送到多种 App 上。 | ||||
| @@ -175,6 +175,10 @@ sudo service nginx restart | ||||
|  | ||||
| 初始账号用户名为 `root`,密码为 `123456`。 | ||||
|  | ||||
| ### 通过宝塔面板进行一键部署 | ||||
| 1. 安装宝塔面板9.2.0及以上版本,前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_oneapi) 官网,选择正式版的脚本下载安装; | ||||
| 2. 安装后登录宝塔面板,在左侧菜单栏中点击 `Docker`,首次进入会提示安装 `Docker` 服务,点击立即安装,按提示完成安装; | ||||
| 3. 安装完成后在应用商店中搜索 `One-API`,点击安装,配置域名等基本信息即可完成安装; | ||||
|  | ||||
| ### 基于 Docker Compose 进行部署 | ||||
|  | ||||
| @@ -218,7 +222,7 @@ docker-compose ps | ||||
| 3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器。 | ||||
| 4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis,无论主从。 | ||||
| 5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。 | ||||
| 6. 从服务器上**分别**装好 Redis,设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。 | ||||
| 6. 从服务器上**分别**装好 Redis,设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟(Redis 集群或者哨兵模式的支持请参考环境变量说明)。 | ||||
| 7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis,并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。 | ||||
|  | ||||
| 环境变量的具体使用方法详见[此处](#环境变量)。 | ||||
| @@ -347,6 +351,11 @@ graph LR | ||||
| 1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为缓存使用。 | ||||
|    + 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153` | ||||
|    + 如果数据库访问延迟很低,没有必要启用 Redis,启用后反而会出现数据滞后的问题。 | ||||
|    + 如果需要使用哨兵或者集群模式: | ||||
|      + 则需要把该环境变量设置为节点列表,例如:`localhost:49153,localhost:49154,localhost:49155`。 | ||||
|      + 除此之外还需要设置以下环境变量: | ||||
|        + `REDIS_PASSWORD`:Redis 集群或者哨兵模式下的密码设置。 | ||||
|        + `REDIS_MASTER_NAME`:Redis 哨兵模式下主节点的名称。 | ||||
| 2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。 | ||||
|    + 例子:`SESSION_SECRET=random_string` | ||||
| 3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite,请使用 MySQL 或 PostgreSQL。 | ||||
| @@ -400,6 +409,8 @@ graph LR | ||||
| 26. `METRIC_SUCCESS_RATE_THRESHOLD`:请求成功率阈值,默认为 `0.8`。 | ||||
| 27. `INITIAL_ROOT_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量值的 root 用户令牌。 | ||||
| 28. `INITIAL_ROOT_ACCESS_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量的 root 用户创建系统管理令牌。 | ||||
| 29. `ENFORCE_INCLUDE_USAGE`:是否强制在 stream 模型下返回 usage,默认不开启,可选值为 `true` 和 `false`。 | ||||
| 30. `TEST_PROMPT`:测试模型时的用户 prompt,默认为 `Print your model name exactly and do not output without any other text.`。 | ||||
|  | ||||
| ### 命令行参数 | ||||
| 1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`。 | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| package config | ||||
|  | ||||
| import ( | ||||
| 	"github.com/songquanpeng/one-api/common/env" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/env" | ||||
|  | ||||
| 	"github.com/google/uuid" | ||||
| ) | ||||
|  | ||||
| @@ -160,3 +161,6 @@ var OnlyOneLogFile = env.Bool("ONLY_ONE_LOG_FILE", false) | ||||
| var RelayProxy = env.String("RELAY_PROXY", "") | ||||
| var UserContentRequestProxy = env.String("USER_CONTENT_REQUEST_PROXY", "") | ||||
| var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30) | ||||
|  | ||||
| var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false) | ||||
| var TestPrompt = env.String("TEST_PROMPT", "Print your model name exactly and do not output without any other text.") | ||||
|   | ||||
| @@ -20,4 +20,5 @@ const ( | ||||
| 	BaseURL           = "base_url" | ||||
| 	AvailableModels   = "available_models" | ||||
| 	KeyRequestBody    = "key_request_body" | ||||
| 	SystemPrompt      = "system_prompt" | ||||
| ) | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| package helper | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/songquanpeng/one-api/common/random" | ||||
| 	"html/template" | ||||
| 	"log" | ||||
| 	"net" | ||||
| @@ -11,6 +10,10 @@ import ( | ||||
| 	"runtime" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/random" | ||||
| ) | ||||
|  | ||||
| func OpenBrowser(url string) { | ||||
| @@ -106,6 +109,18 @@ func GenRequestID() string { | ||||
| 	return GetTimeString() + random.GetRandomNumberString(8) | ||||
| } | ||||
|  | ||||
| func SetRequestID(ctx context.Context, id string) context.Context { | ||||
| 	return context.WithValue(ctx, RequestIdKey, id) | ||||
| } | ||||
|  | ||||
| func GetRequestID(ctx context.Context) string { | ||||
| 	rawRequestId := ctx.Value(RequestIdKey) | ||||
| 	if rawRequestId == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return rawRequestId.(string) | ||||
| } | ||||
|  | ||||
| func GetResponseID(c *gin.Context) string { | ||||
| 	logID := c.GetString(RequestIdKey) | ||||
| 	return fmt.Sprintf("chatcmpl-%s", logID) | ||||
|   | ||||
| @@ -13,3 +13,8 @@ func GetTimeString() string { | ||||
| 	now := time.Now() | ||||
| 	return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) | ||||
| } | ||||
|  | ||||
| // CalcElapsedTime return the elapsed time in milliseconds (ms) | ||||
| func CalcElapsedTime(start time.Time) int64 { | ||||
| 	return time.Now().Sub(start).Milliseconds() | ||||
| } | ||||
|   | ||||
| @@ -7,19 +7,25 @@ import ( | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| ) | ||||
|  | ||||
| type loggerLevel string | ||||
|  | ||||
| const ( | ||||
| 	loggerDEBUG = "DEBUG" | ||||
| 	loggerINFO  = "INFO" | ||||
| 	loggerWarn  = "WARN" | ||||
| 	loggerError = "ERR" | ||||
| 	loggerDEBUG loggerLevel = "DEBUG" | ||||
| 	loggerINFO  loggerLevel = "INFO" | ||||
| 	loggerWarn  loggerLevel = "WARN" | ||||
| 	loggerError loggerLevel = "ERROR" | ||||
| 	loggerFatal loggerLevel = "FATAL" | ||||
| ) | ||||
|  | ||||
| var setupLogOnce sync.Once | ||||
| @@ -44,27 +50,26 @@ func SetupLogger() { | ||||
| } | ||||
|  | ||||
| func SysLog(s string) { | ||||
| 	t := time.Now() | ||||
| 	_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) | ||||
| 	logHelper(nil, loggerINFO, s) | ||||
| } | ||||
|  | ||||
| func SysLogf(format string, a ...any) { | ||||
| 	SysLog(fmt.Sprintf(format, a...)) | ||||
| 	logHelper(nil, loggerINFO, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func SysError(s string) { | ||||
| 	t := time.Now() | ||||
| 	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) | ||||
| 	logHelper(nil, loggerError, s) | ||||
| } | ||||
|  | ||||
| func SysErrorf(format string, a ...any) { | ||||
| 	SysError(fmt.Sprintf(format, a...)) | ||||
| 	logHelper(nil, loggerError, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func Debug(ctx context.Context, msg string) { | ||||
| 	if config.DebugEnabled { | ||||
| 		logHelper(ctx, loggerDEBUG, msg) | ||||
| 	if !config.DebugEnabled { | ||||
| 		return | ||||
| 	} | ||||
| 	logHelper(ctx, loggerDEBUG, msg) | ||||
| } | ||||
|  | ||||
| func Info(ctx context.Context, msg string) { | ||||
| @@ -80,37 +85,65 @@ func Error(ctx context.Context, msg string) { | ||||
| } | ||||
|  | ||||
| func Debugf(ctx context.Context, format string, a ...any) { | ||||
| 	Debug(ctx, fmt.Sprintf(format, a...)) | ||||
| 	logHelper(ctx, loggerDEBUG, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func Infof(ctx context.Context, format string, a ...any) { | ||||
| 	Info(ctx, fmt.Sprintf(format, a...)) | ||||
| 	logHelper(ctx, loggerINFO, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func Warnf(ctx context.Context, format string, a ...any) { | ||||
| 	Warn(ctx, fmt.Sprintf(format, a...)) | ||||
| 	logHelper(ctx, loggerWarn, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func Errorf(ctx context.Context, format string, a ...any) { | ||||
| 	Error(ctx, fmt.Sprintf(format, a...)) | ||||
| 	logHelper(ctx, loggerError, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func logHelper(ctx context.Context, level string, msg string) { | ||||
| func FatalLog(s string) { | ||||
| 	logHelper(nil, loggerFatal, s) | ||||
| } | ||||
|  | ||||
| func FatalLogf(format string, a ...any) { | ||||
| 	logHelper(nil, loggerFatal, fmt.Sprintf(format, a...)) | ||||
| } | ||||
|  | ||||
| func logHelper(ctx context.Context, level loggerLevel, msg string) { | ||||
| 	writer := gin.DefaultErrorWriter | ||||
| 	if level == loggerINFO { | ||||
| 		writer = gin.DefaultWriter | ||||
| 	} | ||||
| 	id := ctx.Value(helper.RequestIdKey) | ||||
| 	if id == nil { | ||||
| 		id = helper.GenRequestID() | ||||
| 	var requestId string | ||||
| 	if ctx != nil { | ||||
| 		rawRequestId := helper.GetRequestID(ctx) | ||||
| 		if rawRequestId != "" { | ||||
| 			requestId = fmt.Sprintf(" | %s", rawRequestId) | ||||
| 		} | ||||
| 	} | ||||
| 	lineInfo, funcName := getLineInfo() | ||||
| 	now := time.Now() | ||||
| 	_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) | ||||
| 	_, _ = fmt.Fprintf(writer, "[%s] %v%s%s %s%s \n", level, now.Format("2006/01/02 - 15:04:05"), requestId, lineInfo, funcName, msg) | ||||
| 	SetupLogger() | ||||
| 	if level == loggerFatal { | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func FatalLog(v ...any) { | ||||
| 	t := time.Now() | ||||
| 	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) | ||||
| 	os.Exit(1) | ||||
| func getLineInfo() (string, string) { | ||||
| 	funcName := "[unknown] " | ||||
| 	pc, file, line, ok := runtime.Caller(3) | ||||
| 	if ok { | ||||
| 		if fn := runtime.FuncForPC(pc); fn != nil { | ||||
| 			parts := strings.Split(fn.Name(), ".") | ||||
| 			funcName = "[" + parts[len(parts)-1] + "] " | ||||
| 		} | ||||
| 	} else { | ||||
| 		file = "unknown" | ||||
| 		line = 0 | ||||
| 	} | ||||
| 	parts := strings.Split(file, "one-api/") | ||||
| 	if len(parts) > 1 { | ||||
| 		file = parts[1] | ||||
| 	} | ||||
| 	return fmt.Sprintf(" | %s:%d", file, line), funcName | ||||
| } | ||||
|   | ||||
| @@ -2,13 +2,15 @@ package common | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/go-redis/redis/v8" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"os" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| var RDB *redis.Client | ||||
| var RDB redis.Cmdable | ||||
| var RedisEnabled = true | ||||
|  | ||||
| // InitRedisClient This function is called after init() | ||||
| @@ -23,13 +25,23 @@ func InitRedisClient() (err error) { | ||||
| 		logger.SysLog("SYNC_FREQUENCY not set, Redis is disabled") | ||||
| 		return nil | ||||
| 	} | ||||
| 	logger.SysLog("Redis is enabled") | ||||
| 	opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) | ||||
| 	if err != nil { | ||||
| 		logger.FatalLog("failed to parse Redis connection string: " + err.Error()) | ||||
| 	redisConnString := os.Getenv("REDIS_CONN_STRING") | ||||
| 	if os.Getenv("REDIS_MASTER_NAME") == "" { | ||||
| 		logger.SysLog("Redis is enabled") | ||||
| 		opt, err := redis.ParseURL(redisConnString) | ||||
| 		if err != nil { | ||||
| 			logger.FatalLog("failed to parse Redis connection string: " + err.Error()) | ||||
| 		} | ||||
| 		RDB = redis.NewClient(opt) | ||||
| 	} else { | ||||
| 		// cluster mode | ||||
| 		logger.SysLog("Redis cluster mode enabled") | ||||
| 		RDB = redis.NewUniversalClient(&redis.UniversalOptions{ | ||||
| 			Addrs:      strings.Split(redisConnString, ","), | ||||
| 			Password:   os.Getenv("REDIS_PASSWORD"), | ||||
| 			MasterName: os.Getenv("REDIS_MASTER_NAME"), | ||||
| 		}) | ||||
| 	} | ||||
| 	RDB = redis.NewClient(opt) | ||||
|  | ||||
| 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||||
| 	defer cancel() | ||||
|  | ||||
|   | ||||
| @@ -3,9 +3,10 @@ package render | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func StringData(c *gin.Context, str string) { | ||||
|   | ||||
| @@ -5,16 +5,18 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/common/random" | ||||
| 	"github.com/songquanpeng/one-api/controller" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type GitHubOAuthResponse struct { | ||||
| @@ -81,6 +83,7 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) { | ||||
| } | ||||
|  | ||||
| func GitHubOAuth(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	session := sessions.Default(c) | ||||
| 	state := c.Query("state") | ||||
| 	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { | ||||
| @@ -136,7 +139,7 @@ func GitHubOAuth(c *gin.Context) { | ||||
| 			user.Role = model.RoleCommonUser | ||||
| 			user.Status = model.UserStatusEnabled | ||||
|  | ||||
| 			if err := user.Insert(0); err != nil { | ||||
| 			if err := user.Insert(ctx, 0); err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
| 					"message": err.Error(), | ||||
|   | ||||
| @@ -5,15 +5,17 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/controller" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type LarkOAuthResponse struct { | ||||
| @@ -40,7 +42,7 @@ func getLarkUserInfoByCode(code string) (*LarkUser, error) { | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	req, err := http.NewRequest("POST", "https://passport.feishu.cn/suite/passport/oauth/token", bytes.NewBuffer(jsonData)) | ||||
| 	req, err := http.NewRequest("POST", "https://open.feishu.cn/open-apis/authen/v2/oauth/token", bytes.NewBuffer(jsonData)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @@ -79,6 +81,7 @@ func getLarkUserInfoByCode(code string) (*LarkUser, error) { | ||||
| } | ||||
|  | ||||
| func LarkOAuth(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	session := sessions.Default(c) | ||||
| 	state := c.Query("state") | ||||
| 	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { | ||||
| @@ -125,7 +128,7 @@ func LarkOAuth(c *gin.Context) { | ||||
| 			user.Role = model.RoleCommonUser | ||||
| 			user.Status = model.UserStatusEnabled | ||||
|  | ||||
| 			if err := user.Insert(0); err != nil { | ||||
| 			if err := user.Insert(ctx, 0); err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
| 					"message": err.Error(), | ||||
|   | ||||
| @@ -5,15 +5,17 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/controller" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type OidcResponse struct { | ||||
| @@ -87,6 +89,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) { | ||||
| } | ||||
|  | ||||
| func OidcAuth(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	session := sessions.Default(c) | ||||
| 	state := c.Query("state") | ||||
| 	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { | ||||
| @@ -142,7 +145,7 @@ func OidcAuth(c *gin.Context) { | ||||
| 			} else { | ||||
| 				user.DisplayName = "OIDC User" | ||||
| 			} | ||||
| 			err := user.Insert(0) | ||||
| 			err := user.Insert(ctx, 0) | ||||
| 			if err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
|   | ||||
| @@ -4,14 +4,16 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/controller" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type wechatLoginResponse struct { | ||||
| @@ -52,6 +54,7 @@ func getWeChatIdByCode(code string) (string, error) { | ||||
| } | ||||
|  | ||||
| func WeChatAuth(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	if !config.WeChatAuthEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"message": "管理员未开启通过微信登录以及注册", | ||||
| @@ -87,7 +90,7 @@ func WeChatAuth(c *gin.Context) { | ||||
| 			user.Role = model.RoleCommonUser | ||||
| 			user.Status = model.UserStatusEnabled | ||||
|  | ||||
| 			if err := user.Insert(0); err != nil { | ||||
| 			if err := user.Insert(ctx, 0); err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
| 					"message": err.Error(), | ||||
|   | ||||
| @@ -4,16 +4,17 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/client" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"github.com/songquanpeng/one-api/monitor" | ||||
| 	"github.com/songquanpeng/one-api/relay/channeltype" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
| @@ -101,6 +102,16 @@ type SiliconFlowUsageResponse struct { | ||||
| 	} `json:"data"` | ||||
| } | ||||
|  | ||||
| type DeepSeekUsageResponse struct { | ||||
| 	IsAvailable  bool `json:"is_available"` | ||||
| 	BalanceInfos []struct { | ||||
| 		Currency        string `json:"currency"` | ||||
| 		TotalBalance    string `json:"total_balance"` | ||||
| 		GrantedBalance  string `json:"granted_balance"` | ||||
| 		ToppedUpBalance string `json:"topped_up_balance"` | ||||
| 	} `json:"balance_infos"` | ||||
| } | ||||
|  | ||||
| // GetAuthHeader get auth header | ||||
| func GetAuthHeader(token string) http.Header { | ||||
| 	h := http.Header{} | ||||
| @@ -237,7 +248,36 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { | ||||
| 	if response.Code != 20000 { | ||||
| 		return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message) | ||||
| 	} | ||||
| 	balance, err := strconv.ParseFloat(response.Data.Balance, 64) | ||||
| 	balance, err := strconv.ParseFloat(response.Data.TotalBalance, 64) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	channel.UpdateBalance(balance) | ||||
| 	return balance, nil | ||||
| } | ||||
|  | ||||
| func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { | ||||
| 	url := "https://api.deepseek.com/user/balance" | ||||
| 	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	response := DeepSeekUsageResponse{} | ||||
| 	err = json.Unmarshal(body, &response) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	index := -1 | ||||
| 	for i, balanceInfo := range response.BalanceInfos { | ||||
| 		if balanceInfo.Currency == "CNY" { | ||||
| 			index = i | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 	if index == -1 { | ||||
| 		return 0, errors.New("currency CNY not found") | ||||
| 	} | ||||
| 	balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| @@ -271,6 +311,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { | ||||
| 		return updateChannelAIGC2DBalance(channel) | ||||
| 	case channeltype.SiliconFlow: | ||||
| 		return updateChannelSiliconFlowBalance(channel) | ||||
| 	case channeltype.DeepSeek: | ||||
| 		return updateChannelDeepSeekBalance(channel) | ||||
| 	default: | ||||
| 		return 0, errors.New("尚未实现") | ||||
| 	} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| @@ -15,14 +16,17 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/common/message" | ||||
| 	"github.com/songquanpeng/one-api/middleware" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"github.com/songquanpeng/one-api/monitor" | ||||
| 	relay "github.com/songquanpeng/one-api/relay" | ||||
| 	"github.com/songquanpeng/one-api/relay" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/channeltype" | ||||
| 	"github.com/songquanpeng/one-api/relay/controller" | ||||
| 	"github.com/songquanpeng/one-api/relay/meta" | ||||
| @@ -35,18 +39,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest { | ||||
| 		model = "gpt-3.5-turbo" | ||||
| 	} | ||||
| 	testRequest := &relaymodel.GeneralOpenAIRequest{ | ||||
| 		MaxTokens: 2, | ||||
| 		Model:     model, | ||||
| 		Model: model, | ||||
| 	} | ||||
| 	testMessage := relaymodel.Message{ | ||||
| 		Role:    "user", | ||||
| 		Content: "hi", | ||||
| 		Content: config.TestPrompt, | ||||
| 	} | ||||
| 	testRequest.Messages = append(testRequest.Messages, testMessage) | ||||
| 	return testRequest | ||||
| } | ||||
|  | ||||
| func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (err error, openaiErr *relaymodel.Error) { | ||||
| func parseTestResponse(resp string) (*openai.TextResponse, string, error) { | ||||
| 	var response openai.TextResponse | ||||
| 	err := json.Unmarshal([]byte(resp), &response) | ||||
| 	if err != nil { | ||||
| 		return nil, "", err | ||||
| 	} | ||||
| 	if len(response.Choices) == 0 { | ||||
| 		return nil, "", errors.New("response has no choices") | ||||
| 	} | ||||
| 	stringContent, ok := response.Choices[0].Content.(string) | ||||
| 	if !ok { | ||||
| 		return nil, "", errors.New("response content is not string") | ||||
| 	} | ||||
| 	return &response, stringContent, nil | ||||
| } | ||||
|  | ||||
| func testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) { | ||||
| 	startTime := time.Now() | ||||
| 	w := httptest.NewRecorder() | ||||
| 	c, _ := gin.CreateTestContext(w) | ||||
| 	c.Request = &http.Request{ | ||||
| @@ -66,7 +86,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques | ||||
| 	apiType := channeltype.ToAPIType(channel.Type) | ||||
| 	adaptor := relay.GetAdaptor(apiType) | ||||
| 	if adaptor == nil { | ||||
| 		return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil | ||||
| 		return "", fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil | ||||
| 	} | ||||
| 	adaptor.Init(meta) | ||||
| 	modelName := request.Model | ||||
| @@ -84,41 +104,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques | ||||
| 	request.Model = modelName | ||||
| 	convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 		return "", err, nil | ||||
| 	} | ||||
| 	jsonData, err := json.Marshal(convertedRequest) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 		return "", err, nil | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		logContent := fmt.Sprintf("渠道 %s 测试成功,响应:%s", channel.Name, responseMessage) | ||||
| 		if err != nil || openaiErr != nil { | ||||
| 			errorMessage := "" | ||||
| 			if err != nil { | ||||
| 				errorMessage = err.Error() | ||||
| 			} else { | ||||
| 				errorMessage = openaiErr.Message | ||||
| 			} | ||||
| 			logContent = fmt.Sprintf("渠道 %s 测试失败,错误:%s", channel.Name, errorMessage) | ||||
| 		} | ||||
| 		go model.RecordTestLog(ctx, &model.Log{ | ||||
| 			ChannelId:   channel.Id, | ||||
| 			ModelName:   modelName, | ||||
| 			Content:     logContent, | ||||
| 			ElapsedTime: helper.CalcElapsedTime(startTime), | ||||
| 		}) | ||||
| 	}() | ||||
| 	logger.SysLog(string(jsonData)) | ||||
| 	requestBody := bytes.NewBuffer(jsonData) | ||||
| 	c.Request.Body = io.NopCloser(requestBody) | ||||
| 	resp, err := adaptor.DoRequest(c, meta, requestBody) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 		return "", err, nil | ||||
| 	} | ||||
| 	if resp != nil && resp.StatusCode != http.StatusOK { | ||||
| 		err := controller.RelayErrorHandler(resp) | ||||
| 		return fmt.Errorf("status code %d: %s", resp.StatusCode, err.Error.Message), &err.Error | ||||
| 		errorMessage := err.Error.Message | ||||
| 		if errorMessage != "" { | ||||
| 			errorMessage = ", error message: " + errorMessage | ||||
| 		} | ||||
| 		return "", fmt.Errorf("http status code: %d%s", resp.StatusCode, errorMessage), &err.Error | ||||
| 	} | ||||
| 	usage, respErr := adaptor.DoResponse(c, resp, meta) | ||||
| 	if respErr != nil { | ||||
| 		return fmt.Errorf("%s", respErr.Error.Message), &respErr.Error | ||||
| 		return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error | ||||
| 	} | ||||
| 	if usage == nil { | ||||
| 		return errors.New("usage is nil"), nil | ||||
| 		return "", errors.New("usage is nil"), nil | ||||
| 	} | ||||
| 	rawResponse := w.Body.String() | ||||
| 	_, responseMessage, err = parseTestResponse(rawResponse) | ||||
| 	if err != nil { | ||||
| 		return "", err, nil | ||||
| 	} | ||||
| 	result := w.Result() | ||||
| 	// print result.Body | ||||
| 	respBody, err := io.ReadAll(result.Body) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 		return "", err, nil | ||||
| 	} | ||||
| 	logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) | ||||
| 	return nil, nil | ||||
| 	return responseMessage, nil, nil | ||||
| } | ||||
|  | ||||
| func TestChannel(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	id, err := strconv.Atoi(c.Param("id")) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| @@ -135,10 +183,10 @@ func TestChannel(c *gin.Context) { | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	model := c.Query("model") | ||||
| 	testRequest := buildTestRequest(model) | ||||
| 	modelName := c.Query("model") | ||||
| 	testRequest := buildTestRequest(modelName) | ||||
| 	tik := time.Now() | ||||
| 	err, _ = testChannel(channel, testRequest) | ||||
| 	responseMessage, err, _ := testChannel(ctx, channel, testRequest) | ||||
| 	tok := time.Now() | ||||
| 	milliseconds := tok.Sub(tik).Milliseconds() | ||||
| 	if err != nil { | ||||
| @@ -148,18 +196,18 @@ func TestChannel(c *gin.Context) { | ||||
| 	consumedTime := float64(milliseconds) / 1000.0 | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 			"time":    consumedTime, | ||||
| 			"model":   model, | ||||
| 			"success":   false, | ||||
| 			"message":   err.Error(), | ||||
| 			"time":      consumedTime, | ||||
| 			"modelName": modelName, | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"time":    consumedTime, | ||||
| 		"model":   model, | ||||
| 		"success":   true, | ||||
| 		"message":   responseMessage, | ||||
| 		"time":      consumedTime, | ||||
| 		"modelName": modelName, | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
| @@ -167,7 +215,7 @@ func TestChannel(c *gin.Context) { | ||||
| var testAllChannelsLock sync.Mutex | ||||
| var testAllChannelsRunning bool = false | ||||
|  | ||||
| func testChannels(notify bool, scope string) error { | ||||
| func testChannels(ctx context.Context, notify bool, scope string) error { | ||||
| 	if config.RootUserEmail == "" { | ||||
| 		config.RootUserEmail = model.GetRootUserEmail() | ||||
| 	} | ||||
| @@ -191,7 +239,7 @@ func testChannels(notify bool, scope string) error { | ||||
| 			isChannelEnabled := channel.Status == model.ChannelStatusEnabled | ||||
| 			tik := time.Now() | ||||
| 			testRequest := buildTestRequest("") | ||||
| 			err, openaiErr := testChannel(channel, testRequest) | ||||
| 			_, err, openaiErr := testChannel(ctx, channel, testRequest) | ||||
| 			tok := time.Now() | ||||
| 			milliseconds := tok.Sub(tik).Milliseconds() | ||||
| 			if isChannelEnabled && milliseconds > disableThreshold { | ||||
| @@ -225,11 +273,12 @@ func testChannels(notify bool, scope string) error { | ||||
| } | ||||
|  | ||||
| func TestChannels(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	scope := c.Query("scope") | ||||
| 	if scope == "" { | ||||
| 		scope = "all" | ||||
| 	} | ||||
| 	err := testChannels(true, scope) | ||||
| 	err := testChannels(ctx, true, scope) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| @@ -245,10 +294,11 @@ func TestChannels(c *gin.Context) { | ||||
| } | ||||
|  | ||||
| func AutomaticallyTestChannels(frequency int) { | ||||
| 	ctx := context.Background() | ||||
| 	for { | ||||
| 		time.Sleep(time.Duration(frequency) * time.Minute) | ||||
| 		logger.SysLog("testing all channels") | ||||
| 		_ = testChannels(false, "all") | ||||
| 		_ = testChannels(ctx, false, "all") | ||||
| 		logger.SysLog("channel test finished") | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -60,7 +60,7 @@ func Relay(c *gin.Context) { | ||||
| 	channelName := c.GetString(ctxkey.ChannelName) | ||||
| 	group := c.GetString(ctxkey.Group) | ||||
| 	originalModel := c.GetString(ctxkey.OriginalModel) | ||||
| 	go processChannelRelayError(ctx, userId, channelId, channelName, bizErr) | ||||
| 	go processChannelRelayError(ctx, userId, channelId, channelName, *bizErr) | ||||
| 	requestId := c.GetString(helper.RequestIdKey) | ||||
| 	retryTimes := config.RetryTimes | ||||
| 	if !shouldRetry(c, bizErr.StatusCode) { | ||||
| @@ -87,8 +87,7 @@ func Relay(c *gin.Context) { | ||||
| 		channelId := c.GetInt(ctxkey.ChannelId) | ||||
| 		lastFailedChannelId = channelId | ||||
| 		channelName := c.GetString(ctxkey.ChannelName) | ||||
| 		// BUG: bizErr is in race condition | ||||
| 		go processChannelRelayError(ctx, userId, channelId, channelName, bizErr) | ||||
| 		go processChannelRelayError(ctx, userId, channelId, channelName, *bizErr) | ||||
| 	} | ||||
| 	if bizErr != nil { | ||||
| 		if bizErr.StatusCode == http.StatusTooManyRequests { | ||||
| @@ -122,7 +121,7 @@ func shouldRetry(c *gin.Context, statusCode int) bool { | ||||
| 	return true | ||||
| } | ||||
|  | ||||
| func processChannelRelayError(ctx context.Context, userId int, channelId int, channelName string, err *model.ErrorWithStatusCode) { | ||||
| func processChannelRelayError(ctx context.Context, userId int, channelId int, channelName string, err model.ErrorWithStatusCode) { | ||||
| 	logger.Errorf(ctx, "relay error (channel id %d, user id: %d): %s", channelId, userId, err.Message) | ||||
| 	// https://platform.openai.com/docs/guides/error-codes/api-errors | ||||
| 	if monitor.ShouldDisableChannel(&err.Error, err.StatusCode) { | ||||
|   | ||||
| @@ -109,6 +109,7 @@ func Logout(c *gin.Context) { | ||||
| } | ||||
|  | ||||
| func Register(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	if !config.RegisterEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"message": "管理员关闭了新用户注册", | ||||
| @@ -166,7 +167,7 @@ func Register(c *gin.Context) { | ||||
| 	if config.EmailVerificationEnabled { | ||||
| 		cleanUser.Email = user.Email | ||||
| 	} | ||||
| 	if err := cleanUser.Insert(inviterId); err != nil { | ||||
| 	if err := cleanUser.Insert(ctx, inviterId); err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| @@ -362,6 +363,7 @@ func GetSelf(c *gin.Context) { | ||||
| } | ||||
|  | ||||
| func UpdateUser(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	var updatedUser model.User | ||||
| 	err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) | ||||
| 	if err != nil || updatedUser.Id == 0 { | ||||
| @@ -416,7 +418,7 @@ func UpdateUser(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	if originUser.Quota != updatedUser.Quota { | ||||
| 		model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota))) | ||||
| 		model.RecordLog(ctx, originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota))) | ||||
| 	} | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| @@ -535,6 +537,7 @@ func DeleteSelf(c *gin.Context) { | ||||
| } | ||||
|  | ||||
| func CreateUser(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	var user model.User | ||||
| 	err := json.NewDecoder(c.Request.Body).Decode(&user) | ||||
| 	if err != nil || user.Username == "" || user.Password == "" { | ||||
| @@ -568,7 +571,7 @@ func CreateUser(c *gin.Context) { | ||||
| 		Password:    user.Password, | ||||
| 		DisplayName: user.DisplayName, | ||||
| 	} | ||||
| 	if err := cleanUser.Insert(0); err != nil { | ||||
| 	if err := cleanUser.Insert(ctx, 0); err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| @@ -747,6 +750,7 @@ type topUpRequest struct { | ||||
| } | ||||
|  | ||||
| func TopUp(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	req := topUpRequest{} | ||||
| 	err := c.ShouldBindJSON(&req) | ||||
| 	if err != nil { | ||||
| @@ -757,7 +761,7 @@ func TopUp(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	id := c.GetInt("id") | ||||
| 	quota, err := model.Redeem(req.Key, id) | ||||
| 	quota, err := model.Redeem(ctx, req.Key, id) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| @@ -780,6 +784,7 @@ type adminTopUpRequest struct { | ||||
| } | ||||
|  | ||||
| func AdminTopUp(c *gin.Context) { | ||||
| 	ctx := c.Request.Context() | ||||
| 	req := adminTopUpRequest{} | ||||
| 	err := c.ShouldBindJSON(&req) | ||||
| 	if err != nil { | ||||
| @@ -800,7 +805,7 @@ func AdminTopUp(c *gin.Context) { | ||||
| 	if req.Remark == "" { | ||||
| 		req.Remark = fmt.Sprintf("通过 API 充值 %s", common.LogQuota(int64(req.Quota))) | ||||
| 	} | ||||
| 	model.RecordTopupLog(req.UserId, req.Remark, req.Quota) | ||||
| 	model.RecordTopupLog(ctx, req.UserId, req.Remark, req.Quota) | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
|   | ||||
							
								
								
									
										12
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
									
									
									
									
								
							| @@ -25,12 +25,13 @@ require ( | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.7 | ||||
| 	github.com/smartystreets/goconvey v1.8.1 | ||||
| 	github.com/stretchr/testify v1.9.0 | ||||
| 	golang.org/x/crypto v0.24.0 | ||||
| 	golang.org/x/crypto v0.31.0 | ||||
| 	golang.org/x/image v0.18.0 | ||||
| 	golang.org/x/sync v0.10.0 | ||||
| 	google.golang.org/api v0.187.0 | ||||
| 	gorm.io/driver/mysql v1.5.6 | ||||
| 	gorm.io/driver/postgres v1.5.7 | ||||
| 	gorm.io/driver/sqlite v1.5.5 | ||||
| 	gorm.io/driver/sqlite v1.5.1 | ||||
| 	gorm.io/gorm v1.25.10 | ||||
| ) | ||||
|  | ||||
| @@ -82,7 +83,7 @@ require ( | ||||
| 	github.com/kr/text v0.2.0 // indirect | ||||
| 	github.com/leodido/go-urn v1.4.0 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v1.14.16 // 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.2.2 // indirect | ||||
| @@ -99,9 +100,8 @@ require ( | ||||
| 	golang.org/x/arch v0.8.0 // indirect | ||||
| 	golang.org/x/net v0.26.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.21.0 // indirect | ||||
| 	golang.org/x/sync v0.7.0 // indirect | ||||
| 	golang.org/x/sys v0.21.0 // indirect | ||||
| 	golang.org/x/text v0.16.0 // indirect | ||||
| 	golang.org/x/sys v0.28.0 // indirect | ||||
| 	golang.org/x/text v0.21.0 // indirect | ||||
| 	golang.org/x/time v0.5.0 // indirect | ||||
| 	google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect | ||||
| 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d // indirect | ||||
|   | ||||
							
								
								
									
										24
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								go.sum
									
									
									
									
									
								
							| @@ -163,8 +163,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||||
| github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||||
| github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= | ||||
| github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= | ||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
| @@ -222,8 +222,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= | ||||
| golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||
| golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= | ||||
| golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= | ||||
| golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||||
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||||
| golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= | ||||
| golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= | ||||
| @@ -244,20 +244,20 @@ golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht | ||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= | ||||
| golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= | ||||
| golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| 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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= | ||||
| golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||||
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= | ||||
| golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | ||||
| golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | ||||
| golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= | ||||
| golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= | ||||
| golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| @@ -306,8 +306,8 @@ gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= | ||||
| gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= | ||||
| gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= | ||||
| gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= | ||||
| gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= | ||||
| gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= | ||||
| gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4= | ||||
| gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ= | ||||
| gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= | ||||
| gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= | ||||
| gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= | ||||
|   | ||||
| @@ -2,13 +2,15 @@ package middleware | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"github.com/songquanpeng/one-api/relay/channeltype" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| ) | ||||
|  | ||||
| type ModelRequest struct { | ||||
| @@ -17,6 +19,7 @@ type ModelRequest struct { | ||||
|  | ||||
| func Distribute() func(c *gin.Context) { | ||||
| 	return func(c *gin.Context) { | ||||
| 		ctx := c.Request.Context() | ||||
| 		userId := c.GetInt(ctxkey.Id) | ||||
| 		userGroup, _ := model.CacheGetUserGroup(userId) | ||||
| 		c.Set(ctxkey.Group, userGroup) | ||||
| @@ -52,6 +55,7 @@ func Distribute() func(c *gin.Context) { | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 		logger.Debugf(ctx, "user id %d, user group: %s, request model: %s, using channel #%d", userId, userGroup, requestModel, channel.Id) | ||||
| 		SetupContextForSelectedChannel(c, channel, requestModel) | ||||
| 		c.Next() | ||||
| 	} | ||||
| @@ -61,6 +65,9 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode | ||||
| 	c.Set(ctxkey.Channel, channel.Type) | ||||
| 	c.Set(ctxkey.ChannelId, channel.Id) | ||||
| 	c.Set(ctxkey.ChannelName, channel.Name) | ||||
| 	if channel.SystemPrompt != nil && *channel.SystemPrompt != "" { | ||||
| 		c.Set(ctxkey.SystemPrompt, *channel.SystemPrompt) | ||||
| 	} | ||||
| 	c.Set(ctxkey.ModelMapping, channel.GetModelMapping()) | ||||
| 	c.Set(ctxkey.OriginalModel, modelName) // for retry | ||||
| 	c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) | ||||
|   | ||||
							
								
								
									
										27
									
								
								middleware/gzip.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								middleware/gzip.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| package middleware | ||||
|  | ||||
| import ( | ||||
| 	"compress/gzip" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | ||||
| func GzipDecodeMiddleware() gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		if c.GetHeader("Content-Encoding") == "gzip" { | ||||
| 			gzipReader, err := gzip.NewReader(c.Request.Body) | ||||
| 			if err != nil { | ||||
| 				c.AbortWithStatus(http.StatusBadRequest) | ||||
| 				return | ||||
| 			} | ||||
| 			defer gzipReader.Close() | ||||
|  | ||||
| 			// Replace the request body with the decompressed data | ||||
| 			c.Request.Body = io.NopCloser(gzipReader) | ||||
| 		} | ||||
|  | ||||
| 		// Continue processing the request | ||||
| 		c.Next() | ||||
| 	} | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| package middleware | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| ) | ||||
|  | ||||
| @@ -10,7 +10,7 @@ func RequestId() func(c *gin.Context) { | ||||
| 	return func(c *gin.Context) { | ||||
| 		id := helper.GenRequestID() | ||||
| 		c.Set(helper.RequestIdKey, id) | ||||
| 		ctx := context.WithValue(c.Request.Context(), helper.RequestIdKey, id) | ||||
| 		ctx := helper.SetRequestID(c.Request.Context(), id) | ||||
| 		c.Request = c.Request.WithContext(ctx) | ||||
| 		c.Header(helper.RequestIdKey, id) | ||||
| 		c.Next() | ||||
|   | ||||
| @@ -37,6 +37,7 @@ type Channel struct { | ||||
| 	ModelMapping       *string `json:"model_mapping" gorm:"type:varchar(1024);default:''"` | ||||
| 	Priority           *int64  `json:"priority" gorm:"bigint;default:0"` | ||||
| 	Config             string  `json:"config"` | ||||
| 	SystemPrompt       *string `json:"system_prompt" gorm:"type:text"` | ||||
| } | ||||
|  | ||||
| type ChannelConfig struct { | ||||
|   | ||||
							
								
								
									
										87
									
								
								model/log.go
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								model/log.go
									
									
									
									
									
								
							| @@ -4,26 +4,31 @@ import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| type Log struct { | ||||
| 	Id               int    `json:"id"` | ||||
| 	UserId           int    `json:"user_id" gorm:"index"` | ||||
| 	CreatedAt        int64  `json:"created_at" gorm:"bigint;index:idx_created_at_type"` | ||||
| 	Type             int    `json:"type" gorm:"index:idx_created_at_type"` | ||||
| 	Content          string `json:"content"` | ||||
| 	Username         string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` | ||||
| 	TokenName        string `json:"token_name" gorm:"index;default:''"` | ||||
| 	ModelName        string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` | ||||
| 	Quota            int    `json:"quota" gorm:"default:0"` | ||||
| 	PromptTokens     int    `json:"prompt_tokens" gorm:"default:0"` | ||||
| 	CompletionTokens int    `json:"completion_tokens" gorm:"default:0"` | ||||
| 	ChannelId        int    `json:"channel" gorm:"index"` | ||||
| 	Id                int    `json:"id"` | ||||
| 	UserId            int    `json:"user_id" gorm:"index"` | ||||
| 	CreatedAt         int64  `json:"created_at" gorm:"bigint;index:idx_created_at_type"` | ||||
| 	Type              int    `json:"type" gorm:"index:idx_created_at_type"` | ||||
| 	Content           string `json:"content"` | ||||
| 	Username          string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` | ||||
| 	TokenName         string `json:"token_name" gorm:"index;default:''"` | ||||
| 	ModelName         string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` | ||||
| 	Quota             int    `json:"quota" gorm:"default:0"` | ||||
| 	PromptTokens      int    `json:"prompt_tokens" gorm:"default:0"` | ||||
| 	CompletionTokens  int    `json:"completion_tokens" gorm:"default:0"` | ||||
| 	ChannelId         int    `json:"channel" gorm:"index"` | ||||
| 	RequestId         string `json:"request_id" gorm:"default:''"` | ||||
| 	ElapsedTime       int64  `json:"elapsed_time" gorm:"default:0"` // unit is ms | ||||
| 	IsStream          bool   `json:"is_stream" gorm:"default:false"` | ||||
| 	SystemPromptReset bool   `json:"system_prompt_reset" gorm:"default:false"` | ||||
| } | ||||
|  | ||||
| const ( | ||||
| @@ -32,9 +37,21 @@ const ( | ||||
| 	LogTypeConsume | ||||
| 	LogTypeManage | ||||
| 	LogTypeSystem | ||||
| 	LogTypeTest | ||||
| ) | ||||
|  | ||||
| func RecordLog(userId int, logType int, content string) { | ||||
| func recordLogHelper(ctx context.Context, log *Log) { | ||||
| 	requestId := helper.GetRequestID(ctx) | ||||
| 	log.RequestId = requestId | ||||
| 	err := LOG_DB.Create(log).Error | ||||
| 	if err != nil { | ||||
| 		logger.Error(ctx, "failed to record log: "+err.Error()) | ||||
| 		return | ||||
| 	} | ||||
| 	logger.Infof(ctx, "record log: %+v", log) | ||||
| } | ||||
|  | ||||
| func RecordLog(ctx context.Context, userId int, logType int, content string) { | ||||
| 	if logType == LogTypeConsume && !config.LogConsumeEnabled { | ||||
| 		return | ||||
| 	} | ||||
| @@ -45,13 +62,10 @@ func RecordLog(userId int, logType int, content string) { | ||||
| 		Type:      logType, | ||||
| 		Content:   content, | ||||
| 	} | ||||
| 	err := LOG_DB.Create(log).Error | ||||
| 	if err != nil { | ||||
| 		logger.SysError("failed to record log: " + err.Error()) | ||||
| 	} | ||||
| 	recordLogHelper(ctx, log) | ||||
| } | ||||
|  | ||||
| func RecordTopupLog(userId int, content string, quota int) { | ||||
| func RecordTopupLog(ctx context.Context, userId int, content string, quota int) { | ||||
| 	log := &Log{ | ||||
| 		UserId:    userId, | ||||
| 		Username:  GetUsernameById(userId), | ||||
| @@ -60,34 +74,23 @@ func RecordTopupLog(userId int, content string, quota int) { | ||||
| 		Content:   content, | ||||
| 		Quota:     quota, | ||||
| 	} | ||||
| 	err := LOG_DB.Create(log).Error | ||||
| 	if err != nil { | ||||
| 		logger.SysError("failed to record log: " + err.Error()) | ||||
| 	} | ||||
| 	recordLogHelper(ctx, log) | ||||
| } | ||||
|  | ||||
| func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int64, content string) { | ||||
| 	logger.Info(ctx, fmt.Sprintf("record consume log: userId=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content)) | ||||
| func RecordConsumeLog(ctx context.Context, log *Log) { | ||||
| 	if !config.LogConsumeEnabled { | ||||
| 		return | ||||
| 	} | ||||
| 	log := &Log{ | ||||
| 		UserId:           userId, | ||||
| 		Username:         GetUsernameById(userId), | ||||
| 		CreatedAt:        helper.GetTimestamp(), | ||||
| 		Type:             LogTypeConsume, | ||||
| 		Content:          content, | ||||
| 		PromptTokens:     promptTokens, | ||||
| 		CompletionTokens: completionTokens, | ||||
| 		TokenName:        tokenName, | ||||
| 		ModelName:        modelName, | ||||
| 		Quota:            int(quota), | ||||
| 		ChannelId:        channelId, | ||||
| 	} | ||||
| 	err := LOG_DB.Create(log).Error | ||||
| 	if err != nil { | ||||
| 		logger.Error(ctx, "failed to record log: "+err.Error()) | ||||
| 	} | ||||
| 	log.Username = GetUsernameById(log.UserId) | ||||
| 	log.CreatedAt = helper.GetTimestamp() | ||||
| 	log.Type = LogTypeConsume | ||||
| 	recordLogHelper(ctx, log) | ||||
| } | ||||
|  | ||||
| func RecordTestLog(ctx context.Context, log *Log) { | ||||
| 	log.CreatedAt = helper.GetTimestamp() | ||||
| 	log.Type = LogTypeTest | ||||
| 	recordLogHelper(ctx, log) | ||||
| } | ||||
|  | ||||
| func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) { | ||||
|   | ||||
| @@ -1,11 +1,14 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -48,7 +51,7 @@ func GetRedemptionById(id int) (*Redemption, error) { | ||||
| 	return &redemption, err | ||||
| } | ||||
|  | ||||
| func Redeem(key string, userId int) (quota int64, err error) { | ||||
| func Redeem(ctx context.Context, key string, userId int) (quota int64, err error) { | ||||
| 	if key == "" { | ||||
| 		return 0, errors.New("未提供兑换码") | ||||
| 	} | ||||
| @@ -82,7 +85,7 @@ func Redeem(key string, userId int) (quota int64, err error) { | ||||
| 	if err != nil { | ||||
| 		return 0, errors.New("兑换失败," + err.Error()) | ||||
| 	} | ||||
| 	RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) | ||||
| 	RecordLog(ctx, userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) | ||||
| 	return redemption.Quota, nil | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,19 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/blacklist" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/common/random" | ||||
| 	"gorm.io/gorm" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -92,7 +95,7 @@ func GetUserById(id int, selectAll bool) (*User, error) { | ||||
| 	if selectAll { | ||||
| 		err = DB.First(&user, "id = ?", id).Error | ||||
| 	} else { | ||||
| 		err = DB.Omit("password").First(&user, "id = ?", id).Error | ||||
| 		err = DB.Omit("password", "access_token").First(&user, "id = ?", id).Error | ||||
| 	} | ||||
| 	return &user, err | ||||
| } | ||||
| @@ -114,7 +117,7 @@ func DeleteUserById(id int) (err error) { | ||||
| 	return user.Delete() | ||||
| } | ||||
|  | ||||
| func (user *User) Insert(inviterId int) error { | ||||
| func (user *User) Insert(ctx context.Context, inviterId int) error { | ||||
| 	var err error | ||||
| 	if user.Password != "" { | ||||
| 		user.Password, err = common.Password2Hash(user.Password) | ||||
| @@ -130,16 +133,16 @@ func (user *User) Insert(inviterId int) error { | ||||
| 		return result.Error | ||||
| 	} | ||||
| 	if config.QuotaForNewUser > 0 { | ||||
| 		RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(config.QuotaForNewUser))) | ||||
| 		RecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(config.QuotaForNewUser))) | ||||
| 	} | ||||
| 	if inviterId != 0 { | ||||
| 		if config.QuotaForInvitee > 0 { | ||||
| 			_ = IncreaseUserQuota(user.Id, config.QuotaForInvitee) | ||||
| 			RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(config.QuotaForInvitee))) | ||||
| 			RecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(config.QuotaForInvitee))) | ||||
| 		} | ||||
| 		if config.QuotaForInviter > 0 { | ||||
| 			_ = IncreaseUserQuota(inviterId, config.QuotaForInviter) | ||||
| 			RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(config.QuotaForInviter))) | ||||
| 			RecordLog(ctx, inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(config.QuotaForInviter))) | ||||
| 		} | ||||
| 	} | ||||
| 	// create default token | ||||
|   | ||||
| @@ -34,7 +34,7 @@ func ShouldDisableChannel(err *model.Error, statusCode int) bool { | ||||
| 		strings.Contains(lowerMessage, "credit") || | ||||
| 		strings.Contains(lowerMessage, "balance") || | ||||
| 		strings.Contains(lowerMessage, "permission denied") || | ||||
|   	strings.Contains(lowerMessage, "organization has been restricted") || // groq | ||||
| 		strings.Contains(lowerMessage, "organization has been restricted") || // groq | ||||
| 		strings.Contains(lowerMessage, "已欠费") { | ||||
| 		return true | ||||
| 	} | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import ( | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/palm" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/proxy" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/replicate" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/tencent" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/vertexai" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/xunfei" | ||||
| @@ -61,6 +62,8 @@ func GetAdaptor(apiType int) adaptor.Adaptor { | ||||
| 		return &vertexai.Adaptor{} | ||||
| 	case apitype.Proxy: | ||||
| 		return &proxy.Adaptor{} | ||||
| 	case apitype.Replicate: | ||||
| 		return &replicate.Adaptor{} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,23 @@ | ||||
| package ali | ||||
|  | ||||
| var ModelList = []string{ | ||||
| 	"qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", | ||||
| 	"text-embedding-v1", | ||||
| 	"qwen-turbo", "qwen-turbo-latest", | ||||
| 	"qwen-plus", "qwen-plus-latest", | ||||
| 	"qwen-max", "qwen-max-latest", | ||||
| 	"qwen-max-longcontext", | ||||
| 	"qwen-vl-max", "qwen-vl-max-latest", "qwen-vl-plus", "qwen-vl-plus-latest", | ||||
| 	"qwen-vl-ocr", "qwen-vl-ocr-latest", | ||||
| 	"qwen-audio-turbo", | ||||
| 	"qwen-math-plus", "qwen-math-plus-latest", "qwen-math-turbo", "qwen-math-turbo-latest", | ||||
| 	"qwen-coder-plus", "qwen-coder-plus-latest", "qwen-coder-turbo", "qwen-coder-turbo-latest", | ||||
| 	"qwq-32b-preview", "qwen2.5-72b-instruct", "qwen2.5-32b-instruct", "qwen2.5-14b-instruct", "qwen2.5-7b-instruct", "qwen2.5-3b-instruct", "qwen2.5-1.5b-instruct", "qwen2.5-0.5b-instruct", | ||||
| 	"qwen2-72b-instruct", "qwen2-57b-a14b-instruct", "qwen2-7b-instruct", "qwen2-1.5b-instruct", "qwen2-0.5b-instruct", | ||||
| 	"qwen1.5-110b-chat", "qwen1.5-72b-chat", "qwen1.5-32b-chat", "qwen1.5-14b-chat", "qwen1.5-7b-chat", "qwen1.5-1.8b-chat", "qwen1.5-0.5b-chat", | ||||
| 	"qwen-72b-chat", "qwen-14b-chat", "qwen-7b-chat", "qwen-1.8b-chat", "qwen-1.8b-longcontext-chat", | ||||
| 	"qwen2-vl-7b-instruct", "qwen2-vl-2b-instruct", "qwen-vl-v1", "qwen-vl-chat-v1", | ||||
| 	"qwen2-audio-instruct", "qwen-audio-chat", | ||||
| 	"qwen2.5-math-72b-instruct", "qwen2.5-math-7b-instruct", "qwen2.5-math-1.5b-instruct", "qwen2-math-72b-instruct", "qwen2-math-7b-instruct", "qwen2-math-1.5b-instruct", | ||||
| 	"qwen2.5-coder-32b-instruct", "qwen2.5-coder-14b-instruct", "qwen2.5-coder-7b-instruct", "qwen2.5-coder-3b-instruct", "qwen2.5-coder-1.5b-instruct", "qwen2.5-coder-0.5b-instruct", | ||||
| 	"text-embedding-v1", "text-embedding-v3", "text-embedding-v2", "text-embedding-async-v2", "text-embedding-async-v1", | ||||
| 	"ali-stable-diffusion-xl", "ali-stable-diffusion-v1.5", "wanx-v1", | ||||
| } | ||||
|   | ||||
| @@ -9,5 +9,4 @@ var ModelList = []string{ | ||||
| 	"claude-3-5-sonnet-20240620", | ||||
| 	"claude-3-5-sonnet-20241022", | ||||
| 	"claude-3-5-sonnet-latest", | ||||
| 	"claude-3-5-haiku-20241022", | ||||
| } | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	channelhelper "github.com/songquanpeng/one-api/relay/adaptor" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| @@ -24,7 +23,15 @@ func (a *Adaptor) Init(meta *meta.Meta) { | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { | ||||
| 	version := helper.AssignOrDefault(meta.Config.APIVersion, config.GeminiVersion) | ||||
| 	var defaultVersion string | ||||
| 	switch meta.ActualModelName { | ||||
| 	case "gemini-2.0-flash-exp", | ||||
| 		"gemini-2.0-flash-thinking-exp", | ||||
| 		"gemini-2.0-flash-thinking-exp-01-21": | ||||
| 		defaultVersion = "v1beta" | ||||
| 	} | ||||
|  | ||||
| 	version := helper.AssignOrDefault(meta.Config.APIVersion, defaultVersion) | ||||
| 	action := "" | ||||
| 	switch meta.Mode { | ||||
| 	case relaymode.Embeddings: | ||||
| @@ -36,6 +43,7 @@ func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { | ||||
| 	if meta.IsStream { | ||||
| 		action = "streamGenerateContent?alt=sse" | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Sprintf("%s/%s/models/%s:%s", meta.BaseURL, version, meta.ActualModelName, action), nil | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,5 +3,9 @@ package gemini | ||||
| // https://ai.google.dev/models/gemini | ||||
|  | ||||
| var ModelList = []string{ | ||||
| 	"gemini-pro", "gemini-1.0-pro", "gemini-1.5-flash", "gemini-1.5-pro", "text-embedding-004", "aqa", | ||||
| 	"gemini-pro", "gemini-1.0-pro", | ||||
| 	"gemini-1.5-flash", "gemini-1.5-pro", | ||||
| 	"text-embedding-004", "aqa", | ||||
| 	"gemini-2.0-flash-exp", | ||||
| 	"gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-thinking-exp-01-21", | ||||
| } | ||||
|   | ||||
| @@ -55,6 +55,10 @@ func ConvertRequest(textRequest model.GeneralOpenAIRequest) *ChatRequest { | ||||
| 				Category:  "HARM_CATEGORY_DANGEROUS_CONTENT", | ||||
| 				Threshold: config.GeminiSafetySetting, | ||||
| 			}, | ||||
| 			{ | ||||
| 				Category:  "HARM_CATEGORY_CIVIC_INTEGRITY", | ||||
| 				Threshold: config.GeminiSafetySetting, | ||||
| 			}, | ||||
| 		}, | ||||
| 		GenerationConfig: ChatGenerationConfig{ | ||||
| 			Temperature:     textRequest.Temperature, | ||||
| @@ -247,7 +251,14 @@ func responseGeminiChat2OpenAI(response *ChatResponse) *openai.TextResponse { | ||||
| 			if candidate.Content.Parts[0].FunctionCall != nil { | ||||
| 				choice.Message.ToolCalls = getToolCalls(&candidate) | ||||
| 			} else { | ||||
| 				choice.Message.Content = candidate.Content.Parts[0].Text | ||||
| 				var builder strings.Builder | ||||
| 				for _, part := range candidate.Content.Parts { | ||||
| 					if i > 0 { | ||||
| 						builder.WriteString("\n") | ||||
| 					} | ||||
| 					builder.WriteString(part.Text) | ||||
| 				} | ||||
| 				choice.Message.Content = builder.String() | ||||
| 			} | ||||
| 		} else { | ||||
| 			choice.Message.Content = "" | ||||
|   | ||||
| @@ -31,8 +31,8 @@ func ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest { | ||||
| 			TopP:             request.TopP, | ||||
| 			FrequencyPenalty: request.FrequencyPenalty, | ||||
| 			PresencePenalty:  request.PresencePenalty, | ||||
| 			NumPredict:  	  request.MaxTokens, | ||||
| 			NumCtx:  	  request.NumCtx, | ||||
| 			NumPredict:       request.MaxTokens, | ||||
| 			NumCtx:           request.NumCtx, | ||||
| 		}, | ||||
| 		Stream: request.Stream, | ||||
| 	} | ||||
| @@ -122,7 +122,7 @@ func StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusC | ||||
| 	for scanner.Scan() { | ||||
| 		data := scanner.Text() | ||||
| 		if strings.HasPrefix(data, "}") { | ||||
| 		    data = strings.TrimPrefix(data, "}") + "}" | ||||
| 			data = strings.TrimPrefix(data, "}") + "}" | ||||
| 		} | ||||
|  | ||||
| 		var ollamaResponse ChatResponse | ||||
|   | ||||
| @@ -9,6 +9,7 @@ var ModelList = []string{ | ||||
| 	"gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", | ||||
| 	"gpt-4o", "gpt-4o-2024-05-13", | ||||
| 	"gpt-4o-2024-08-06", | ||||
| 	"gpt-4o-2024-11-20", | ||||
| 	"chatgpt-4o-latest", | ||||
| 	"gpt-4o-mini", "gpt-4o-mini-2024-07-18", | ||||
| 	"gpt-4-vision-preview", | ||||
| @@ -20,4 +21,7 @@ var ModelList = []string{ | ||||
| 	"dall-e-2", "dall-e-3", | ||||
| 	"whisper-1", | ||||
| 	"tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106", | ||||
| 	"o1", "o1-2024-12-17", | ||||
| 	"o1-preview", "o1-preview-2024-09-12", | ||||
| 	"o1-mini", "o1-mini-2024-09-12", | ||||
| } | ||||
|   | ||||
| @@ -2,15 +2,16 @@ package openai | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/relay/channeltype" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func ResponseText2Usage(responseText string, modeName string, promptTokens int) *model.Usage { | ||||
| func ResponseText2Usage(responseText string, modelName string, promptTokens int) *model.Usage { | ||||
| 	usage := &model.Usage{} | ||||
| 	usage.PromptTokens = promptTokens | ||||
| 	usage.CompletionTokens = CountTokenText(responseText, modeName) | ||||
| 	usage.CompletionTokens = CountTokenText(responseText, modelName) | ||||
| 	usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens | ||||
| 	return usage | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,16 @@ | ||||
| package openai | ||||
|  | ||||
| import "github.com/songquanpeng/one-api/relay/model" | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| ) | ||||
|  | ||||
| func ErrorWrapper(err error, code string, statusCode int) *model.ErrorWithStatusCode { | ||||
| 	logger.Error(context.TODO(), fmt.Sprintf("[%s]%+v", code, err)) | ||||
|  | ||||
| 	Error := model.Error{ | ||||
| 		Message: err.Error(), | ||||
| 		Type:    "one_api_error", | ||||
|   | ||||
							
								
								
									
										136
									
								
								relay/adaptor/replicate/adaptor.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								relay/adaptor/replicate/adaptor.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| package replicate | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/pkg/errors" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/meta" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| 	"github.com/songquanpeng/one-api/relay/relaymode" | ||||
| ) | ||||
|  | ||||
| type Adaptor struct { | ||||
| 	meta *meta.Meta | ||||
| } | ||||
|  | ||||
| // ConvertImageRequest implements adaptor.Adaptor. | ||||
| func (*Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { | ||||
| 	return DrawImageRequest{ | ||||
| 		Input: ImageInput{ | ||||
| 			Steps:           25, | ||||
| 			Prompt:          request.Prompt, | ||||
| 			Guidance:        3, | ||||
| 			Seed:            int(time.Now().UnixNano()), | ||||
| 			SafetyTolerance: 5, | ||||
| 			NImages:         1, // replicate will always return 1 image | ||||
| 			Width:           1440, | ||||
| 			Height:          1440, | ||||
| 			AspectRatio:     "1:1", | ||||
| 		}, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) { | ||||
| 	if !request.Stream { | ||||
| 		// TODO: support non-stream mode | ||||
| 		return nil, errors.Errorf("replicate models only support stream mode now, please set stream=true") | ||||
| 	} | ||||
|  | ||||
| 	// Build the prompt from OpenAI messages | ||||
| 	var promptBuilder strings.Builder | ||||
| 	for _, message := range request.Messages { | ||||
| 		switch msgCnt := message.Content.(type) { | ||||
| 		case string: | ||||
| 			promptBuilder.WriteString(message.Role) | ||||
| 			promptBuilder.WriteString(": ") | ||||
| 			promptBuilder.WriteString(msgCnt) | ||||
| 			promptBuilder.WriteString("\n") | ||||
| 		default: | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	replicateRequest := ReplicateChatRequest{ | ||||
| 		Input: ChatInput{ | ||||
| 			Prompt:           promptBuilder.String(), | ||||
| 			MaxTokens:        request.MaxTokens, | ||||
| 			Temperature:      1.0, | ||||
| 			TopP:             1.0, | ||||
| 			PresencePenalty:  0.0, | ||||
| 			FrequencyPenalty: 0.0, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	// Map optional fields | ||||
| 	if request.Temperature != nil { | ||||
| 		replicateRequest.Input.Temperature = *request.Temperature | ||||
| 	} | ||||
| 	if request.TopP != nil { | ||||
| 		replicateRequest.Input.TopP = *request.TopP | ||||
| 	} | ||||
| 	if request.PresencePenalty != nil { | ||||
| 		replicateRequest.Input.PresencePenalty = *request.PresencePenalty | ||||
| 	} | ||||
| 	if request.FrequencyPenalty != nil { | ||||
| 		replicateRequest.Input.FrequencyPenalty = *request.FrequencyPenalty | ||||
| 	} | ||||
| 	if request.MaxTokens > 0 { | ||||
| 		replicateRequest.Input.MaxTokens = request.MaxTokens | ||||
| 	} else if request.MaxTokens == 0 { | ||||
| 		replicateRequest.Input.MaxTokens = 500 | ||||
| 	} | ||||
|  | ||||
| 	return replicateRequest, nil | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) Init(meta *meta.Meta) { | ||||
| 	a.meta = meta | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { | ||||
| 	if !slices.Contains(ModelList, meta.OriginModelName) { | ||||
| 		return "", errors.Errorf("model %s not supported", meta.OriginModelName) | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Sprintf("https://api.replicate.com/v1/models/%s/predictions", meta.OriginModelName), nil | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error { | ||||
| 	adaptor.SetupCommonRequestHeader(c, req, meta) | ||||
| 	req.Header.Set("Authorization", "Bearer "+meta.APIKey) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) { | ||||
| 	logger.Info(c, "send request to replicate") | ||||
| 	return adaptor.DoRequestHelper(a, c, meta, requestBody) | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) { | ||||
| 	switch meta.Mode { | ||||
| 	case relaymode.ImagesGenerations: | ||||
| 		err, usage = ImageHandler(c, resp) | ||||
| 	case relaymode.ChatCompletions: | ||||
| 		err, usage = ChatHandler(c, resp) | ||||
| 	default: | ||||
| 		err = openai.ErrorWrapper(errors.New("not implemented"), "not_implemented", http.StatusInternalServerError) | ||||
| 	} | ||||
|  | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) GetModelList() []string { | ||||
| 	return ModelList | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) GetChannelName() string { | ||||
| 	return "replicate" | ||||
| } | ||||
							
								
								
									
										191
									
								
								relay/adaptor/replicate/chat.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								relay/adaptor/replicate/chat.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| package replicate | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/pkg/errors" | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/render" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/meta" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| ) | ||||
|  | ||||
| func ChatHandler(c *gin.Context, resp *http.Response) ( | ||||
| 	srvErr *model.ErrorWithStatusCode, usage *model.Usage) { | ||||
| 	if resp.StatusCode != http.StatusCreated { | ||||
| 		payload, _ := io.ReadAll(resp.Body) | ||||
| 		return openai.ErrorWrapper( | ||||
| 				errors.Errorf("bad_status_code [%d]%s", resp.StatusCode, string(payload)), | ||||
| 				"bad_status_code", http.StatusInternalServerError), | ||||
| 			nil | ||||
| 	} | ||||
|  | ||||
| 	respBody, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return openai.ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	respData := new(ChatResponse) | ||||
| 	if err = json.Unmarshal(respBody, respData); err != nil { | ||||
| 		return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	for { | ||||
| 		err = func() error { | ||||
| 			// get task | ||||
| 			taskReq, err := http.NewRequestWithContext(c.Request.Context(), | ||||
| 				http.MethodGet, respData.URLs.Get, nil) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "new request") | ||||
| 			} | ||||
|  | ||||
| 			taskReq.Header.Set("Authorization", "Bearer "+meta.GetByContext(c).APIKey) | ||||
| 			taskResp, err := http.DefaultClient.Do(taskReq) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "get task") | ||||
| 			} | ||||
| 			defer taskResp.Body.Close() | ||||
|  | ||||
| 			if taskResp.StatusCode != http.StatusOK { | ||||
| 				payload, _ := io.ReadAll(taskResp.Body) | ||||
| 				return errors.Errorf("bad status code [%d]%s", | ||||
| 					taskResp.StatusCode, string(payload)) | ||||
| 			} | ||||
|  | ||||
| 			taskBody, err := io.ReadAll(taskResp.Body) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "read task response") | ||||
| 			} | ||||
|  | ||||
| 			taskData := new(ChatResponse) | ||||
| 			if err = json.Unmarshal(taskBody, taskData); err != nil { | ||||
| 				return errors.Wrap(err, "decode task response") | ||||
| 			} | ||||
|  | ||||
| 			switch taskData.Status { | ||||
| 			case "succeeded": | ||||
| 			case "failed", "canceled": | ||||
| 				return errors.Errorf("task failed, [%s]%s", taskData.Status, taskData.Error) | ||||
| 			default: | ||||
| 				time.Sleep(time.Second * 3) | ||||
| 				return errNextLoop | ||||
| 			} | ||||
|  | ||||
| 			if taskData.URLs.Stream == "" { | ||||
| 				return errors.New("stream url is empty") | ||||
| 			} | ||||
|  | ||||
| 			// request stream url | ||||
| 			responseText, err := chatStreamHandler(c, taskData.URLs.Stream) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "chat stream handler") | ||||
| 			} | ||||
|  | ||||
| 			ctxMeta := meta.GetByContext(c) | ||||
| 			usage = openai.ResponseText2Usage(responseText, | ||||
| 				ctxMeta.ActualModelName, ctxMeta.PromptTokens) | ||||
| 			return nil | ||||
| 		}() | ||||
| 		if err != nil { | ||||
| 			if errors.Is(err, errNextLoop) { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			return openai.ErrorWrapper(err, "chat_task_failed", http.StatusInternalServerError), nil | ||||
| 		} | ||||
|  | ||||
| 		break | ||||
| 	} | ||||
|  | ||||
| 	return nil, usage | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	eventPrefix = "event: " | ||||
| 	dataPrefix  = "data: " | ||||
| 	done        = "[DONE]" | ||||
| ) | ||||
|  | ||||
| func chatStreamHandler(c *gin.Context, streamUrl string) (responseText string, err error) { | ||||
| 	// request stream endpoint | ||||
| 	streamReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, streamUrl, nil) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "new request to stream") | ||||
| 	} | ||||
|  | ||||
| 	streamReq.Header.Set("Authorization", "Bearer "+meta.GetByContext(c).APIKey) | ||||
| 	streamReq.Header.Set("Accept", "text/event-stream") | ||||
| 	streamReq.Header.Set("Cache-Control", "no-store") | ||||
|  | ||||
| 	resp, err := http.DefaultClient.Do(streamReq) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "do request to stream") | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		payload, _ := io.ReadAll(resp.Body) | ||||
| 		return "", errors.Errorf("bad status code [%d]%s", resp.StatusCode, string(payload)) | ||||
| 	} | ||||
|  | ||||
| 	scanner := bufio.NewScanner(resp.Body) | ||||
| 	scanner.Split(bufio.ScanLines) | ||||
|  | ||||
| 	common.SetEventStreamHeaders(c) | ||||
| 	doneRendered := false | ||||
| 	for scanner.Scan() { | ||||
| 		line := strings.TrimSpace(scanner.Text()) | ||||
| 		if line == "" { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Handle comments starting with ':' | ||||
| 		if strings.HasPrefix(line, ":") { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Parse SSE fields | ||||
| 		if strings.HasPrefix(line, eventPrefix) { | ||||
| 			event := strings.TrimSpace(line[len(eventPrefix):]) | ||||
| 			var data string | ||||
| 			// Read the following lines to get data and id | ||||
| 			for scanner.Scan() { | ||||
| 				nextLine := scanner.Text() | ||||
| 				if nextLine == "" { | ||||
| 					break | ||||
| 				} | ||||
| 				if strings.HasPrefix(nextLine, dataPrefix) { | ||||
| 					data = nextLine[len(dataPrefix):] | ||||
| 				} else if strings.HasPrefix(nextLine, "id:") { | ||||
| 					// id = strings.TrimSpace(nextLine[len("id:"):]) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if event == "output" { | ||||
| 				render.StringData(c, data) | ||||
| 				responseText += data | ||||
| 			} else if event == "done" { | ||||
| 				render.Done(c) | ||||
| 				doneRendered = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if err := scanner.Err(); err != nil { | ||||
| 		return "", errors.Wrap(err, "scan stream") | ||||
| 	} | ||||
|  | ||||
| 	if !doneRendered { | ||||
| 		render.Done(c) | ||||
| 	} | ||||
|  | ||||
| 	return responseText, nil | ||||
| } | ||||
							
								
								
									
										58
									
								
								relay/adaptor/replicate/constant.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								relay/adaptor/replicate/constant.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| package replicate | ||||
|  | ||||
| // ModelList is a list of models that can be used with Replicate. | ||||
| // | ||||
| // https://replicate.com/pricing | ||||
| var ModelList = []string{ | ||||
| 	// ------------------------------------- | ||||
| 	// image model | ||||
| 	// ------------------------------------- | ||||
| 	"black-forest-labs/flux-1.1-pro", | ||||
| 	"black-forest-labs/flux-1.1-pro-ultra", | ||||
| 	"black-forest-labs/flux-canny-dev", | ||||
| 	"black-forest-labs/flux-canny-pro", | ||||
| 	"black-forest-labs/flux-depth-dev", | ||||
| 	"black-forest-labs/flux-depth-pro", | ||||
| 	"black-forest-labs/flux-dev", | ||||
| 	"black-forest-labs/flux-dev-lora", | ||||
| 	"black-forest-labs/flux-fill-dev", | ||||
| 	"black-forest-labs/flux-fill-pro", | ||||
| 	"black-forest-labs/flux-pro", | ||||
| 	"black-forest-labs/flux-redux-dev", | ||||
| 	"black-forest-labs/flux-redux-schnell", | ||||
| 	"black-forest-labs/flux-schnell", | ||||
| 	"black-forest-labs/flux-schnell-lora", | ||||
| 	"ideogram-ai/ideogram-v2", | ||||
| 	"ideogram-ai/ideogram-v2-turbo", | ||||
| 	"recraft-ai/recraft-v3", | ||||
| 	"recraft-ai/recraft-v3-svg", | ||||
| 	"stability-ai/stable-diffusion-3", | ||||
| 	"stability-ai/stable-diffusion-3.5-large", | ||||
| 	"stability-ai/stable-diffusion-3.5-large-turbo", | ||||
| 	"stability-ai/stable-diffusion-3.5-medium", | ||||
| 	// ------------------------------------- | ||||
| 	// language model | ||||
| 	// ------------------------------------- | ||||
| 	"ibm-granite/granite-20b-code-instruct-8k", | ||||
| 	"ibm-granite/granite-3.0-2b-instruct", | ||||
| 	"ibm-granite/granite-3.0-8b-instruct", | ||||
| 	"ibm-granite/granite-8b-code-instruct-128k", | ||||
| 	"meta/llama-2-13b", | ||||
| 	"meta/llama-2-13b-chat", | ||||
| 	"meta/llama-2-70b", | ||||
| 	"meta/llama-2-70b-chat", | ||||
| 	"meta/llama-2-7b", | ||||
| 	"meta/llama-2-7b-chat", | ||||
| 	"meta/meta-llama-3.1-405b-instruct", | ||||
| 	"meta/meta-llama-3-70b", | ||||
| 	"meta/meta-llama-3-70b-instruct", | ||||
| 	"meta/meta-llama-3-8b", | ||||
| 	"meta/meta-llama-3-8b-instruct", | ||||
| 	"mistralai/mistral-7b-instruct-v0.2", | ||||
| 	"mistralai/mistral-7b-v0.1", | ||||
| 	"mistralai/mixtral-8x7b-instruct-v0.1", | ||||
| 	// ------------------------------------- | ||||
| 	// video model | ||||
| 	// ------------------------------------- | ||||
| 	// "minimax/video-01",  // TODO: implement the adaptor | ||||
| } | ||||
							
								
								
									
										222
									
								
								relay/adaptor/replicate/image.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								relay/adaptor/replicate/image.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,222 @@ | ||||
| package replicate | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"image" | ||||
| 	"image/png" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/pkg/errors" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/meta" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| 	"golang.org/x/image/webp" | ||||
| 	"golang.org/x/sync/errgroup" | ||||
| ) | ||||
|  | ||||
| // ImagesEditsHandler just copy response body to client | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-fill-pro | ||||
| // func ImagesEditsHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { | ||||
| // 	c.Writer.WriteHeader(resp.StatusCode) | ||||
| // 	for k, v := range resp.Header { | ||||
| // 		c.Writer.Header().Set(k, v[0]) | ||||
| // 	} | ||||
|  | ||||
| // 	if _, err := io.Copy(c.Writer, resp.Body); err != nil { | ||||
| // 		return ErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil | ||||
| // 	} | ||||
| // 	defer resp.Body.Close() | ||||
|  | ||||
| // 	return nil, nil | ||||
| // } | ||||
|  | ||||
| var errNextLoop = errors.New("next_loop") | ||||
|  | ||||
| func ImageHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { | ||||
| 	if resp.StatusCode != http.StatusCreated { | ||||
| 		payload, _ := io.ReadAll(resp.Body) | ||||
| 		return openai.ErrorWrapper( | ||||
| 				errors.Errorf("bad_status_code [%d]%s", resp.StatusCode, string(payload)), | ||||
| 				"bad_status_code", http.StatusInternalServerError), | ||||
| 			nil | ||||
| 	} | ||||
|  | ||||
| 	respBody, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return openai.ErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	respData := new(ImageResponse) | ||||
| 	if err = json.Unmarshal(respBody, respData); err != nil { | ||||
| 		return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	for { | ||||
| 		err = func() error { | ||||
| 			// get task | ||||
| 			taskReq, err := http.NewRequestWithContext(c.Request.Context(), | ||||
| 				http.MethodGet, respData.URLs.Get, nil) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "new request") | ||||
| 			} | ||||
|  | ||||
| 			taskReq.Header.Set("Authorization", "Bearer "+meta.GetByContext(c).APIKey) | ||||
| 			taskResp, err := http.DefaultClient.Do(taskReq) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "get task") | ||||
| 			} | ||||
| 			defer taskResp.Body.Close() | ||||
|  | ||||
| 			if taskResp.StatusCode != http.StatusOK { | ||||
| 				payload, _ := io.ReadAll(taskResp.Body) | ||||
| 				return errors.Errorf("bad status code [%d]%s", | ||||
| 					taskResp.StatusCode, string(payload)) | ||||
| 			} | ||||
|  | ||||
| 			taskBody, err := io.ReadAll(taskResp.Body) | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "read task response") | ||||
| 			} | ||||
|  | ||||
| 			taskData := new(ImageResponse) | ||||
| 			if err = json.Unmarshal(taskBody, taskData); err != nil { | ||||
| 				return errors.Wrap(err, "decode task response") | ||||
| 			} | ||||
|  | ||||
| 			switch taskData.Status { | ||||
| 			case "succeeded": | ||||
| 			case "failed", "canceled": | ||||
| 				return errors.Errorf("task failed: %s", taskData.Status) | ||||
| 			default: | ||||
| 				time.Sleep(time.Second * 3) | ||||
| 				return errNextLoop | ||||
| 			} | ||||
|  | ||||
| 			output, err := taskData.GetOutput() | ||||
| 			if err != nil { | ||||
| 				return errors.Wrap(err, "get output") | ||||
| 			} | ||||
| 			if len(output) == 0 { | ||||
| 				return errors.New("response output is empty") | ||||
| 			} | ||||
|  | ||||
| 			var mu sync.Mutex | ||||
| 			var pool errgroup.Group | ||||
| 			respBody := &openai.ImageResponse{ | ||||
| 				Created: taskData.CompletedAt.Unix(), | ||||
| 				Data:    []openai.ImageData{}, | ||||
| 			} | ||||
|  | ||||
| 			for _, imgOut := range output { | ||||
| 				imgOut := imgOut | ||||
| 				pool.Go(func() error { | ||||
| 					// download image | ||||
| 					downloadReq, err := http.NewRequestWithContext(c.Request.Context(), | ||||
| 						http.MethodGet, imgOut, nil) | ||||
| 					if err != nil { | ||||
| 						return errors.Wrap(err, "new request") | ||||
| 					} | ||||
|  | ||||
| 					imgResp, err := http.DefaultClient.Do(downloadReq) | ||||
| 					if err != nil { | ||||
| 						return errors.Wrap(err, "download image") | ||||
| 					} | ||||
| 					defer imgResp.Body.Close() | ||||
|  | ||||
| 					if imgResp.StatusCode != http.StatusOK { | ||||
| 						payload, _ := io.ReadAll(imgResp.Body) | ||||
| 						return errors.Errorf("bad status code [%d]%s", | ||||
| 							imgResp.StatusCode, string(payload)) | ||||
| 					} | ||||
|  | ||||
| 					imgData, err := io.ReadAll(imgResp.Body) | ||||
| 					if err != nil { | ||||
| 						return errors.Wrap(err, "read image") | ||||
| 					} | ||||
|  | ||||
| 					imgData, err = ConvertImageToPNG(imgData) | ||||
| 					if err != nil { | ||||
| 						return errors.Wrap(err, "convert image") | ||||
| 					} | ||||
|  | ||||
| 					mu.Lock() | ||||
| 					respBody.Data = append(respBody.Data, openai.ImageData{ | ||||
| 						B64Json: fmt.Sprintf("data:image/png;base64,%s", | ||||
| 							base64.StdEncoding.EncodeToString(imgData)), | ||||
| 					}) | ||||
| 					mu.Unlock() | ||||
|  | ||||
| 					return nil | ||||
| 				}) | ||||
| 			} | ||||
|  | ||||
| 			if err := pool.Wait(); err != nil { | ||||
| 				if len(respBody.Data) == 0 { | ||||
| 					return errors.WithStack(err) | ||||
| 				} | ||||
|  | ||||
| 				logger.Error(c, fmt.Sprintf("some images failed to download: %+v", err)) | ||||
| 			} | ||||
|  | ||||
| 			c.JSON(http.StatusOK, respBody) | ||||
| 			return nil | ||||
| 		}() | ||||
| 		if err != nil { | ||||
| 			if errors.Is(err, errNextLoop) { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			return openai.ErrorWrapper(err, "image_task_failed", http.StatusInternalServerError), nil | ||||
| 		} | ||||
|  | ||||
| 		break | ||||
| 	} | ||||
|  | ||||
| 	return nil, nil | ||||
| } | ||||
|  | ||||
| // ConvertImageToPNG converts a WebP image to PNG format | ||||
| func ConvertImageToPNG(webpData []byte) ([]byte, error) { | ||||
| 	// bypass if it's already a PNG image | ||||
| 	if bytes.HasPrefix(webpData, []byte("\x89PNG")) { | ||||
| 		return webpData, nil | ||||
| 	} | ||||
|  | ||||
| 	// check if is jpeg, convert to png | ||||
| 	if bytes.HasPrefix(webpData, []byte("\xff\xd8\xff")) { | ||||
| 		img, _, err := image.Decode(bytes.NewReader(webpData)) | ||||
| 		if err != nil { | ||||
| 			return nil, errors.Wrap(err, "decode jpeg") | ||||
| 		} | ||||
|  | ||||
| 		var pngBuffer bytes.Buffer | ||||
| 		if err := png.Encode(&pngBuffer, img); err != nil { | ||||
| 			return nil, errors.Wrap(err, "encode png") | ||||
| 		} | ||||
|  | ||||
| 		return pngBuffer.Bytes(), nil | ||||
| 	} | ||||
|  | ||||
| 	// Decode the WebP image | ||||
| 	img, err := webp.Decode(bytes.NewReader(webpData)) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "decode webp") | ||||
| 	} | ||||
|  | ||||
| 	// Encode the image as PNG | ||||
| 	var pngBuffer bytes.Buffer | ||||
| 	if err := png.Encode(&pngBuffer, img); err != nil { | ||||
| 		return nil, errors.Wrap(err, "encode png") | ||||
| 	} | ||||
|  | ||||
| 	return pngBuffer.Bytes(), nil | ||||
| } | ||||
							
								
								
									
										159
									
								
								relay/adaptor/replicate/model.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								relay/adaptor/replicate/model.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| package replicate | ||||
|  | ||||
| import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/pkg/errors" | ||||
| ) | ||||
|  | ||||
| // DrawImageRequest draw image by fluxpro | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-pro?prediction=kg1krwsdf9rg80ch1sgsrgq7h8&output=json | ||||
| type DrawImageRequest struct { | ||||
| 	Input ImageInput `json:"input"` | ||||
| } | ||||
|  | ||||
| // ImageInput is input of DrawImageByFluxProRequest | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-1.1-pro/api/schema | ||||
| type ImageInput struct { | ||||
| 	Steps           int    `json:"steps" binding:"required,min=1"` | ||||
| 	Prompt          string `json:"prompt" binding:"required,min=5"` | ||||
| 	ImagePrompt     string `json:"image_prompt"` | ||||
| 	Guidance        int    `json:"guidance" binding:"required,min=2,max=5"` | ||||
| 	Interval        int    `json:"interval" binding:"required,min=1,max=4"` | ||||
| 	AspectRatio     string `json:"aspect_ratio" binding:"required,oneof=1:1 16:9 2:3 3:2 4:5 5:4 9:16"` | ||||
| 	SafetyTolerance int    `json:"safety_tolerance" binding:"required,min=1,max=5"` | ||||
| 	Seed            int    `json:"seed"` | ||||
| 	NImages         int    `json:"n_images" binding:"required,min=1,max=8"` | ||||
| 	Width           int    `json:"width" binding:"required,min=256,max=1440"` | ||||
| 	Height          int    `json:"height" binding:"required,min=256,max=1440"` | ||||
| } | ||||
|  | ||||
| // InpaintingImageByFlusReplicateRequest is request to inpainting image by flux pro | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-fill-pro/api/schema | ||||
| type InpaintingImageByFlusReplicateRequest struct { | ||||
| 	Input FluxInpaintingInput `json:"input"` | ||||
| } | ||||
|  | ||||
| // FluxInpaintingInput is input of DrawImageByFluxProRequest | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-fill-pro/api/schema | ||||
| type FluxInpaintingInput struct { | ||||
| 	Mask             string `json:"mask" binding:"required"` | ||||
| 	Image            string `json:"image" binding:"required"` | ||||
| 	Seed             int    `json:"seed"` | ||||
| 	Steps            int    `json:"steps" binding:"required,min=1"` | ||||
| 	Prompt           string `json:"prompt" binding:"required,min=5"` | ||||
| 	Guidance         int    `json:"guidance" binding:"required,min=2,max=5"` | ||||
| 	OutputFormat     string `json:"output_format"` | ||||
| 	SafetyTolerance  int    `json:"safety_tolerance" binding:"required,min=1,max=5"` | ||||
| 	PromptUnsampling bool   `json:"prompt_unsampling"` | ||||
| } | ||||
|  | ||||
| // ImageResponse is response of DrawImageByFluxProRequest | ||||
| // | ||||
| // https://replicate.com/black-forest-labs/flux-pro?prediction=kg1krwsdf9rg80ch1sgsrgq7h8&output=json | ||||
| type ImageResponse struct { | ||||
| 	CompletedAt time.Time        `json:"completed_at"` | ||||
| 	CreatedAt   time.Time        `json:"created_at"` | ||||
| 	DataRemoved bool             `json:"data_removed"` | ||||
| 	Error       string           `json:"error"` | ||||
| 	ID          string           `json:"id"` | ||||
| 	Input       DrawImageRequest `json:"input"` | ||||
| 	Logs        string           `json:"logs"` | ||||
| 	Metrics     FluxMetrics      `json:"metrics"` | ||||
| 	// Output could be `string` or `[]string` | ||||
| 	Output    any       `json:"output"` | ||||
| 	StartedAt time.Time `json:"started_at"` | ||||
| 	Status    string    `json:"status"` | ||||
| 	URLs      FluxURLs  `json:"urls"` | ||||
| 	Version   string    `json:"version"` | ||||
| } | ||||
|  | ||||
| func (r *ImageResponse) GetOutput() ([]string, error) { | ||||
| 	switch v := r.Output.(type) { | ||||
| 	case string: | ||||
| 		return []string{v}, nil | ||||
| 	case []string: | ||||
| 		return v, nil | ||||
| 	case nil: | ||||
| 		return nil, nil | ||||
| 	case []interface{}: | ||||
| 		// convert []interface{} to []string | ||||
| 		ret := make([]string, len(v)) | ||||
| 		for idx, vv := range v { | ||||
| 			if vvv, ok := vv.(string); ok { | ||||
| 				ret[idx] = vvv | ||||
| 			} else { | ||||
| 				return nil, errors.Errorf("unknown output type: [%T]%v", vv, vv) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return ret, nil | ||||
| 	default: | ||||
| 		return nil, errors.Errorf("unknown output type: [%T]%v", r.Output, r.Output) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // FluxMetrics is metrics of ImageResponse | ||||
| type FluxMetrics struct { | ||||
| 	ImageCount  int     `json:"image_count"` | ||||
| 	PredictTime float64 `json:"predict_time"` | ||||
| 	TotalTime   float64 `json:"total_time"` | ||||
| } | ||||
|  | ||||
| // FluxURLs is urls of ImageResponse | ||||
| type FluxURLs struct { | ||||
| 	Get    string `json:"get"` | ||||
| 	Cancel string `json:"cancel"` | ||||
| } | ||||
|  | ||||
| type ReplicateChatRequest struct { | ||||
| 	Input ChatInput `json:"input" form:"input" binding:"required"` | ||||
| } | ||||
|  | ||||
| // ChatInput is input of ChatByReplicateRequest | ||||
| // | ||||
| // https://replicate.com/meta/meta-llama-3.1-405b-instruct/api/schema | ||||
| type ChatInput struct { | ||||
| 	TopK             int     `json:"top_k"` | ||||
| 	TopP             float64 `json:"top_p"` | ||||
| 	Prompt           string  `json:"prompt"` | ||||
| 	MaxTokens        int     `json:"max_tokens"` | ||||
| 	MinTokens        int     `json:"min_tokens"` | ||||
| 	Temperature      float64 `json:"temperature"` | ||||
| 	SystemPrompt     string  `json:"system_prompt"` | ||||
| 	StopSequences    string  `json:"stop_sequences"` | ||||
| 	PromptTemplate   string  `json:"prompt_template"` | ||||
| 	PresencePenalty  float64 `json:"presence_penalty"` | ||||
| 	FrequencyPenalty float64 `json:"frequency_penalty"` | ||||
| } | ||||
|  | ||||
| // ChatResponse is response of ChatByReplicateRequest | ||||
| // | ||||
| // https://replicate.com/meta/meta-llama-3.1-405b-instruct/examples?input=http&output=json | ||||
| type ChatResponse struct { | ||||
| 	CompletedAt time.Time   `json:"completed_at"` | ||||
| 	CreatedAt   time.Time   `json:"created_at"` | ||||
| 	DataRemoved bool        `json:"data_removed"` | ||||
| 	Error       string      `json:"error"` | ||||
| 	ID          string      `json:"id"` | ||||
| 	Input       ChatInput   `json:"input"` | ||||
| 	Logs        string      `json:"logs"` | ||||
| 	Metrics     FluxMetrics `json:"metrics"` | ||||
| 	// Output could be `string` or `[]string` | ||||
| 	Output    []string        `json:"output"` | ||||
| 	StartedAt time.Time       `json:"started_at"` | ||||
| 	Status    string          `json:"status"` | ||||
| 	URLs      ChatResponseUrl `json:"urls"` | ||||
| 	Version   string          `json:"version"` | ||||
| } | ||||
|  | ||||
| // ChatResponseUrl is task urls of ChatResponse | ||||
| type ChatResponseUrl struct { | ||||
| 	Stream string `json:"stream"` | ||||
| 	Get    string `json:"get"` | ||||
| 	Cancel string `json:"cancel"` | ||||
| } | ||||
| @@ -2,16 +2,19 @@ package tencent | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/meta" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"github.com/songquanpeng/one-api/relay/relaymode" | ||||
| ) | ||||
|  | ||||
| // https://cloud.tencent.com/document/api/1729/101837 | ||||
| @@ -52,10 +55,18 @@ func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.G | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	tencentRequest := ConvertRequest(*request) | ||||
| 	var convertedRequest any | ||||
| 	switch relayMode { | ||||
| 	case relaymode.Embeddings: | ||||
| 		a.Action = "GetEmbedding" | ||||
| 		convertedRequest = ConvertEmbeddingRequest(*request) | ||||
| 	default: | ||||
| 		a.Action = "ChatCompletions" | ||||
| 		convertedRequest = ConvertRequest(*request) | ||||
| 	} | ||||
| 	// we have to calculate the sign here | ||||
| 	a.Sign = GetSign(*tencentRequest, a, secretId, secretKey) | ||||
| 	return tencentRequest, nil | ||||
| 	a.Sign = GetSign(convertedRequest, a, secretId, secretKey) | ||||
| 	return convertedRequest, nil | ||||
| } | ||||
|  | ||||
| func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { | ||||
| @@ -75,7 +86,12 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Met | ||||
| 		err, responseText = StreamHandler(c, resp) | ||||
| 		usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) | ||||
| 	} else { | ||||
| 		err, usage = Handler(c, resp) | ||||
| 		switch meta.Mode { | ||||
| 		case relaymode.Embeddings: | ||||
| 			err, usage = EmbeddingHandler(c, resp) | ||||
| 		default: | ||||
| 			err, usage = Handler(c, resp) | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
|   | ||||
| @@ -6,4 +6,5 @@ var ModelList = []string{ | ||||
| 	"hunyuan-standard-256K", | ||||
| 	"hunyuan-pro", | ||||
| 	"hunyuan-vision", | ||||
| 	"hunyuan-embedding", | ||||
| } | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/songquanpeng/one-api/common/render" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| @@ -16,11 +15,14 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/conv" | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/common/random" | ||||
| 	"github.com/songquanpeng/one-api/common/render" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor/openai" | ||||
| 	"github.com/songquanpeng/one-api/relay/constant" | ||||
| 	"github.com/songquanpeng/one-api/relay/model" | ||||
| @@ -44,8 +46,68 @@ func ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest { | ||||
| 	return &EmbeddingRequest{ | ||||
| 		InputList: request.ParseInput(), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) { | ||||
| 	var tencentResponseP EmbeddingResponseP | ||||
| 	err := json.NewDecoder(resp.Body).Decode(&tencentResponseP) | ||||
| 	if err != nil { | ||||
| 		return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	err = resp.Body.Close() | ||||
| 	if err != nil { | ||||
| 		return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
|  | ||||
| 	tencentResponse := tencentResponseP.Response | ||||
| 	if tencentResponse.Error.Code != "" { | ||||
| 		return &model.ErrorWithStatusCode{ | ||||
| 			Error: model.Error{ | ||||
| 				Message: tencentResponse.Error.Message, | ||||
| 				Code:    tencentResponse.Error.Code, | ||||
| 			}, | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 		}, nil | ||||
| 	} | ||||
| 	requestModel := c.GetString(ctxkey.RequestModel) | ||||
| 	fullTextResponse := embeddingResponseTencent2OpenAI(&tencentResponse) | ||||
| 	fullTextResponse.Model = requestModel | ||||
| 	jsonResponse, err := json.Marshal(fullTextResponse) | ||||
| 	if err != nil { | ||||
| 		return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	c.Writer.Header().Set("Content-Type", "application/json") | ||||
| 	c.Writer.WriteHeader(resp.StatusCode) | ||||
| 	_, err = c.Writer.Write(jsonResponse) | ||||
| 	return nil, &fullTextResponse.Usage | ||||
| } | ||||
|  | ||||
| func embeddingResponseTencent2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse { | ||||
| 	openAIEmbeddingResponse := openai.EmbeddingResponse{ | ||||
| 		Object: "list", | ||||
| 		Data:   make([]openai.EmbeddingResponseItem, 0, len(response.Data)), | ||||
| 		Model:  "hunyuan-embedding", | ||||
| 		Usage:  model.Usage{TotalTokens: response.EmbeddingUsage.TotalTokens}, | ||||
| 	} | ||||
|  | ||||
| 	for _, item := range response.Data { | ||||
| 		openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{ | ||||
| 			Object:    item.Object, | ||||
| 			Index:     item.Index, | ||||
| 			Embedding: item.Embedding, | ||||
| 		}) | ||||
| 	} | ||||
| 	return &openAIEmbeddingResponse | ||||
| } | ||||
|  | ||||
| func responseTencent2OpenAI(response *ChatResponse) *openai.TextResponse { | ||||
| 	fullTextResponse := openai.TextResponse{ | ||||
| 		Id:      response.ReqID, | ||||
| 		Object:  "chat.completion", | ||||
| 		Created: helper.GetTimestamp(), | ||||
| 		Usage: model.Usage{ | ||||
| @@ -148,7 +210,7 @@ func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, * | ||||
| 		return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	TencentResponse = responseP.Response | ||||
| 	if TencentResponse.Error.Code != 0 { | ||||
| 	if TencentResponse.Error.Code != "" { | ||||
| 		return &model.ErrorWithStatusCode{ | ||||
| 			Error: model.Error{ | ||||
| 				Message: TencentResponse.Error.Message, | ||||
| @@ -195,7 +257,7 @@ func hmacSha256(s, key string) string { | ||||
| 	return string(hashed.Sum(nil)) | ||||
| } | ||||
|  | ||||
| func GetSign(req ChatRequest, adaptor *Adaptor, secId, secKey string) string { | ||||
| func GetSign(req any, adaptor *Adaptor, secId, secKey string) string { | ||||
| 	// build canonical request string | ||||
| 	host := "hunyuan.tencentcloudapi.com" | ||||
| 	httpRequestMethod := "POST" | ||||
|   | ||||
| @@ -35,16 +35,16 @@ type ChatRequest struct { | ||||
| 	// 1. 影响输出文本的多样性,取值越大,生成文本的多样性越强。 | ||||
| 	// 2. 取值区间为 [0.0, 1.0],未传值时使用各模型推荐值。 | ||||
| 	// 3. 非必要不建议使用,不合理的取值会影响效果。 | ||||
| 	TopP *float64 `json:"TopP"` | ||||
| 	TopP *float64 `json:"TopP,omitempty"` | ||||
| 	// 说明: | ||||
| 	// 1. 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定。 | ||||
| 	// 2. 取值区间为 [0.0, 2.0],未传值时使用各模型推荐值。 | ||||
| 	// 3. 非必要不建议使用,不合理的取值会影响效果。 | ||||
| 	Temperature *float64 `json:"Temperature"` | ||||
| 	Temperature *float64 `json:"Temperature,omitempty"` | ||||
| } | ||||
|  | ||||
| type Error struct { | ||||
| 	Code    int    `json:"Code"` | ||||
| 	Code    string `json:"Code"` | ||||
| 	Message string `json:"Message"` | ||||
| } | ||||
|  | ||||
| @@ -61,15 +61,41 @@ type ResponseChoices struct { | ||||
| } | ||||
|  | ||||
| type ChatResponse struct { | ||||
| 	Choices []ResponseChoices `json:"Choices,omitempty"` // 结果 | ||||
| 	Created int64             `json:"Created,omitempty"` // unix 时间戳的字符串 | ||||
| 	Id      string            `json:"Id,omitempty"`      // 会话 id | ||||
| 	Usage   Usage             `json:"Usage,omitempty"`   // token 数量 | ||||
| 	Error   Error             `json:"Error,omitempty"`   // 错误信息 注意:此字段可能返回 null,表示取不到有效值 | ||||
| 	Note    string            `json:"Note,omitempty"`    // 注释 | ||||
| 	ReqID   string            `json:"Req_id,omitempty"`  // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 | ||||
| 	Choices []ResponseChoices `json:"Choices,omitempty"`   // 结果 | ||||
| 	Created int64             `json:"Created,omitempty"`   // unix 时间戳的字符串 | ||||
| 	Id      string            `json:"Id,omitempty"`        // 会话 id | ||||
| 	Usage   Usage             `json:"Usage,omitempty"`     // token 数量 | ||||
| 	Error   Error             `json:"Error,omitempty"`     // 错误信息 注意:此字段可能返回 null,表示取不到有效值 | ||||
| 	Note    string            `json:"Note,omitempty"`      // 注释 | ||||
| 	ReqID   string            `json:"RequestId,omitempty"` // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 | ||||
| } | ||||
|  | ||||
| type ChatResponseP struct { | ||||
| 	Response ChatResponse `json:"Response,omitempty"` | ||||
| } | ||||
|  | ||||
| type EmbeddingRequest struct { | ||||
| 	InputList []string `json:"InputList"` | ||||
| } | ||||
|  | ||||
| type EmbeddingData struct { | ||||
| 	Embedding []float64 `json:"Embedding"` | ||||
| 	Index     int       `json:"Index"` | ||||
| 	Object    string    `json:"Object"` | ||||
| } | ||||
|  | ||||
| type EmbeddingUsage struct { | ||||
| 	PromptTokens int `json:"PromptTokens"` | ||||
| 	TotalTokens  int `json:"TotalTokens"` | ||||
| } | ||||
|  | ||||
| type EmbeddingResponse struct { | ||||
| 	Data           []EmbeddingData `json:"Data"` | ||||
| 	EmbeddingUsage EmbeddingUsage  `json:"Usage,omitempty"` | ||||
| 	RequestId      string          `json:"RequestId,omitempty"` | ||||
| 	Error          Error           `json:"Error,omitempty"` | ||||
| } | ||||
|  | ||||
| type EmbeddingResponseP struct { | ||||
| 	Response EmbeddingResponse `json:"Response,omitempty"` | ||||
| } | ||||
|   | ||||
| @@ -15,7 +15,11 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ModelList = []string{ | ||||
| 	"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision", "gemini-1.5-pro-002", "gemini-1.5-flash-002",  | ||||
| 	"gemini-pro", "gemini-pro-vision", | ||||
| 	"gemini-1.5-pro-001", "gemini-1.5-flash-001", | ||||
| 	"gemini-1.5-pro-002", "gemini-1.5-flash-002", | ||||
| 	"gemini-2.0-flash-exp", | ||||
| 	"gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-thinking-exp-01-21", | ||||
| } | ||||
|  | ||||
| type Adaptor struct { | ||||
|   | ||||
| @@ -19,6 +19,7 @@ const ( | ||||
| 	DeepL | ||||
| 	VertexAI | ||||
| 	Proxy | ||||
| 	Replicate | ||||
|  | ||||
| 	Dummy // this one is only for count, do not add any channel after this | ||||
| ) | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package billing | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| ) | ||||
| @@ -31,8 +32,17 @@ func PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQ | ||||
| 	} | ||||
| 	// totalQuota is total quota consumed | ||||
| 	if totalQuota != 0 { | ||||
| 		logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) | ||||
| 		model.RecordConsumeLog(ctx, userId, channelId, int(totalQuota), 0, modelName, tokenName, totalQuota, logContent) | ||||
| 		logContent := fmt.Sprintf("倍率:%.2f × %.2f", modelRatio, groupRatio) | ||||
| 		model.RecordConsumeLog(ctx, &model.Log{ | ||||
| 			UserId:           userId, | ||||
| 			ChannelId:        channelId, | ||||
| 			PromptTokens:     int(totalQuota), | ||||
| 			CompletionTokens: 0, | ||||
| 			ModelName:        modelName, | ||||
| 			TokenName:        tokenName, | ||||
| 			Quota:            int(totalQuota), | ||||
| 			Content:          logContent, | ||||
| 		}) | ||||
| 		model.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota) | ||||
| 		model.UpdateChannelUsedQuota(channelId, totalQuota) | ||||
| 	} | ||||
|   | ||||
| @@ -9,9 +9,10 @@ import ( | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	USD2RMB = 7 | ||||
| 	USD     = 500 // $0.002 = 1 -> $1 = 500 | ||||
| 	RMB     = USD / USD2RMB | ||||
| 	USD2RMB   = 7 | ||||
| 	USD       = 500 // $0.002 = 1 -> $1 = 500 | ||||
| 	MILLI_USD = 1.0 / 1000 * USD | ||||
| 	RMB       = USD / USD2RMB | ||||
| ) | ||||
|  | ||||
| // ModelRatio | ||||
| @@ -37,6 +38,7 @@ var ModelRatio = map[string]float64{ | ||||
| 	"chatgpt-4o-latest":       2.5,   // $0.005 / 1K tokens | ||||
| 	"gpt-4o-2024-05-13":       2.5,   // $0.005 / 1K tokens | ||||
| 	"gpt-4o-2024-08-06":       1.25,  // $0.0025 / 1K tokens | ||||
| 	"gpt-4o-2024-11-20":       1.25,  // $0.0025 / 1K tokens | ||||
| 	"gpt-4o-mini":             0.075, // $0.00015 / 1K tokens | ||||
| 	"gpt-4o-mini-2024-07-18":  0.075, // $0.00015 / 1K tokens | ||||
| 	"gpt-4-vision-preview":    5,     // $0.01 / 1K tokens | ||||
| @@ -48,8 +50,14 @@ var ModelRatio = map[string]float64{ | ||||
| 	"gpt-3.5-turbo-instruct":  0.75, // $0.0015 / 1K tokens | ||||
| 	"gpt-3.5-turbo-1106":      0.5,  // $0.001 / 1K tokens | ||||
| 	"gpt-3.5-turbo-0125":      0.25, // $0.0005 / 1K tokens | ||||
| 	"davinci-002":             1,    // $0.002 / 1K tokens | ||||
| 	"babbage-002":             0.2,  // $0.0004 / 1K tokens | ||||
| 	"o1":                      7.5,  // $15.00 / 1M input tokens | ||||
| 	"o1-2024-12-17":           7.5, | ||||
| 	"o1-preview":              7.5, // $15.00 / 1M input tokens | ||||
| 	"o1-preview-2024-09-12":   7.5, | ||||
| 	"o1-mini":                 1.5, // $3.00 / 1M input tokens | ||||
| 	"o1-mini-2024-09-12":      1.5, | ||||
| 	"davinci-002":             1,   // $0.002 / 1K tokens | ||||
| 	"babbage-002":             0.2, // $0.0004 / 1K tokens | ||||
| 	"text-ada-001":            0.2, | ||||
| 	"text-babbage-001":        0.25, | ||||
| 	"text-curie-001":          1, | ||||
| @@ -102,11 +110,16 @@ var ModelRatio = map[string]float64{ | ||||
| 	"bge-large-en":       0.002 * RMB, | ||||
| 	"tao-8k":             0.002 * RMB, | ||||
| 	// https://ai.google.dev/pricing | ||||
| 	"gemini-pro":       1, // $0.00025 / 1k characters -> $0.001 / 1k tokens | ||||
| 	"gemini-1.0-pro":   1, | ||||
| 	"gemini-1.5-flash": 1, | ||||
| 	"gemini-1.5-pro":   1, | ||||
| 	"aqa":              1, | ||||
| 	"gemini-pro":                          1, // $0.00025 / 1k characters -> $0.001 / 1k tokens | ||||
| 	"gemini-1.0-pro":                      1, | ||||
| 	"gemini-1.5-pro":                      1, | ||||
| 	"gemini-1.5-pro-001":                  1, | ||||
| 	"gemini-1.5-flash":                    1, | ||||
| 	"gemini-1.5-flash-001":                1, | ||||
| 	"gemini-2.0-flash-exp":                1, | ||||
| 	"gemini-2.0-flash-thinking-exp":       1, | ||||
| 	"gemini-2.0-flash-thinking-exp-01-21": 1, | ||||
| 	"aqa":                                 1, | ||||
| 	// https://open.bigmodel.cn/pricing | ||||
| 	"glm-4":         0.1 * RMB, | ||||
| 	"glm-4v":        0.1 * RMB, | ||||
| @@ -118,29 +131,94 @@ var ModelRatio = map[string]float64{ | ||||
| 	"chatglm_lite":  0.1429, // ¥0.002 / 1k tokens | ||||
| 	"cogview-3":     0.25 * RMB, | ||||
| 	// https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-thousand-questions-metering-and-billing | ||||
| 	"qwen-turbo":                0.5715, // ¥0.008 / 1k tokens | ||||
| 	"qwen-plus":                 1.4286, // ¥0.02 / 1k tokens | ||||
| 	"qwen-max":                  1.4286, // ¥0.02 / 1k tokens | ||||
| 	"qwen-max-longcontext":      1.4286, // ¥0.02 / 1k tokens | ||||
| 	"text-embedding-v1":         0.05,   // ¥0.0007 / 1k tokens | ||||
| 	"ali-stable-diffusion-xl":   8, | ||||
| 	"ali-stable-diffusion-v1.5": 8, | ||||
| 	"wanx-v1":                   8, | ||||
| 	"SparkDesk":                 1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v1.1":            1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v2.1":            1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.1":            1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.1-128K":       1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.5":            1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.5-32K":        1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v4.0":            1.2858, // ¥0.018 / 1k tokens | ||||
| 	"360GPT_S2_V9":              0.8572, // ¥0.012 / 1k tokens | ||||
| 	"embedding-bert-512-v1":     0.0715, // ¥0.001 / 1k tokens | ||||
| 	"embedding_s1_v1":           0.0715, // ¥0.001 / 1k tokens | ||||
| 	"semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens | ||||
| 	"hunyuan":                   7.143,  // ¥0.1 / 1k tokens  // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 | ||||
| 	"ChatStd":                   0.01 * RMB, | ||||
| 	"ChatPro":                   0.1 * RMB, | ||||
| 	"qwen-turbo":                  1.4286, // ¥0.02 / 1k tokens | ||||
| 	"qwen-turbo-latest":           1.4286, | ||||
| 	"qwen-plus":                   1.4286, | ||||
| 	"qwen-plus-latest":            1.4286, | ||||
| 	"qwen-max":                    1.4286, | ||||
| 	"qwen-max-latest":             1.4286, | ||||
| 	"qwen-max-longcontext":        1.4286, | ||||
| 	"qwen-vl-max":                 1.4286, | ||||
| 	"qwen-vl-max-latest":          1.4286, | ||||
| 	"qwen-vl-plus":                1.4286, | ||||
| 	"qwen-vl-plus-latest":         1.4286, | ||||
| 	"qwen-vl-ocr":                 1.4286, | ||||
| 	"qwen-vl-ocr-latest":          1.4286, | ||||
| 	"qwen-audio-turbo":            1.4286, | ||||
| 	"qwen-math-plus":              1.4286, | ||||
| 	"qwen-math-plus-latest":       1.4286, | ||||
| 	"qwen-math-turbo":             1.4286, | ||||
| 	"qwen-math-turbo-latest":      1.4286, | ||||
| 	"qwen-coder-plus":             1.4286, | ||||
| 	"qwen-coder-plus-latest":      1.4286, | ||||
| 	"qwen-coder-turbo":            1.4286, | ||||
| 	"qwen-coder-turbo-latest":     1.4286, | ||||
| 	"qwq-32b-preview":             1.4286, | ||||
| 	"qwen2.5-72b-instruct":        1.4286, | ||||
| 	"qwen2.5-32b-instruct":        1.4286, | ||||
| 	"qwen2.5-14b-instruct":        1.4286, | ||||
| 	"qwen2.5-7b-instruct":         1.4286, | ||||
| 	"qwen2.5-3b-instruct":         1.4286, | ||||
| 	"qwen2.5-1.5b-instruct":       1.4286, | ||||
| 	"qwen2.5-0.5b-instruct":       1.4286, | ||||
| 	"qwen2-72b-instruct":          1.4286, | ||||
| 	"qwen2-57b-a14b-instruct":     1.4286, | ||||
| 	"qwen2-7b-instruct":           1.4286, | ||||
| 	"qwen2-1.5b-instruct":         1.4286, | ||||
| 	"qwen2-0.5b-instruct":         1.4286, | ||||
| 	"qwen1.5-110b-chat":           1.4286, | ||||
| 	"qwen1.5-72b-chat":            1.4286, | ||||
| 	"qwen1.5-32b-chat":            1.4286, | ||||
| 	"qwen1.5-14b-chat":            1.4286, | ||||
| 	"qwen1.5-7b-chat":             1.4286, | ||||
| 	"qwen1.5-1.8b-chat":           1.4286, | ||||
| 	"qwen1.5-0.5b-chat":           1.4286, | ||||
| 	"qwen-72b-chat":               1.4286, | ||||
| 	"qwen-14b-chat":               1.4286, | ||||
| 	"qwen-7b-chat":                1.4286, | ||||
| 	"qwen-1.8b-chat":              1.4286, | ||||
| 	"qwen-1.8b-longcontext-chat":  1.4286, | ||||
| 	"qwen2-vl-7b-instruct":        1.4286, | ||||
| 	"qwen2-vl-2b-instruct":        1.4286, | ||||
| 	"qwen-vl-v1":                  1.4286, | ||||
| 	"qwen-vl-chat-v1":             1.4286, | ||||
| 	"qwen2-audio-instruct":        1.4286, | ||||
| 	"qwen-audio-chat":             1.4286, | ||||
| 	"qwen2.5-math-72b-instruct":   1.4286, | ||||
| 	"qwen2.5-math-7b-instruct":    1.4286, | ||||
| 	"qwen2.5-math-1.5b-instruct":  1.4286, | ||||
| 	"qwen2-math-72b-instruct":     1.4286, | ||||
| 	"qwen2-math-7b-instruct":      1.4286, | ||||
| 	"qwen2-math-1.5b-instruct":    1.4286, | ||||
| 	"qwen2.5-coder-32b-instruct":  1.4286, | ||||
| 	"qwen2.5-coder-14b-instruct":  1.4286, | ||||
| 	"qwen2.5-coder-7b-instruct":   1.4286, | ||||
| 	"qwen2.5-coder-3b-instruct":   1.4286, | ||||
| 	"qwen2.5-coder-1.5b-instruct": 1.4286, | ||||
| 	"qwen2.5-coder-0.5b-instruct": 1.4286, | ||||
| 	"text-embedding-v1":           0.05, // ¥0.0007 / 1k tokens | ||||
| 	"text-embedding-v3":           0.05, | ||||
| 	"text-embedding-v2":           0.05, | ||||
| 	"text-embedding-async-v2":     0.05, | ||||
| 	"text-embedding-async-v1":     0.05, | ||||
| 	"ali-stable-diffusion-xl":     8.00, | ||||
| 	"ali-stable-diffusion-v1.5":   8.00, | ||||
| 	"wanx-v1":                     8.00, | ||||
| 	"SparkDesk":                   1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v1.1":              1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v2.1":              1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.1":              1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.1-128K":         1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.5":              1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v3.5-32K":          1.2858, // ¥0.018 / 1k tokens | ||||
| 	"SparkDesk-v4.0":              1.2858, // ¥0.018 / 1k tokens | ||||
| 	"360GPT_S2_V9":                0.8572, // ¥0.012 / 1k tokens | ||||
| 	"embedding-bert-512-v1":       0.0715, // ¥0.001 / 1k tokens | ||||
| 	"embedding_s1_v1":             0.0715, // ¥0.001 / 1k tokens | ||||
| 	"semantic_similarity_s1_v1":   0.0715, // ¥0.001 / 1k tokens | ||||
| 	"hunyuan":                     7.143,  // ¥0.1 / 1k tokens  // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 | ||||
| 	"ChatStd":                     0.01 * RMB, | ||||
| 	"ChatPro":                     0.1 * RMB, | ||||
| 	// https://platform.moonshot.cn/pricing | ||||
| 	"moonshot-v1-8k":   0.012 * RMB, | ||||
| 	"moonshot-v1-32k":  0.024 * RMB, | ||||
| @@ -203,20 +281,69 @@ var ModelRatio = map[string]float64{ | ||||
| 	"command-r":             0.5 / 1000 * USD, | ||||
| 	"command-r-plus":        3.0 / 1000 * USD, | ||||
| 	// https://platform.deepseek.com/api-docs/pricing/ | ||||
| 	"deepseek-chat":  1.0 / 1000 * RMB, | ||||
| 	"deepseek-coder": 1.0 / 1000 * RMB, | ||||
| 	"deepseek-chat":     0.14 * MILLI_USD, | ||||
| 	"deepseek-reasoner": 0.55 * MILLI_USD, | ||||
| 	// https://www.deepl.com/pro?cta=header-prices | ||||
| 	"deepl-zh": 25.0 / 1000 * USD, | ||||
| 	"deepl-en": 25.0 / 1000 * USD, | ||||
| 	"deepl-ja": 25.0 / 1000 * USD, | ||||
| 	// https://console.x.ai/ | ||||
| 	"grok-beta": 5.0 / 1000 * USD, | ||||
| 	// replicate charges based on the number of generated images | ||||
| 	// https://replicate.com/pricing | ||||
| 	"black-forest-labs/flux-1.1-pro":                0.04 * USD, | ||||
| 	"black-forest-labs/flux-1.1-pro-ultra":          0.06 * USD, | ||||
| 	"black-forest-labs/flux-canny-dev":              0.025 * USD, | ||||
| 	"black-forest-labs/flux-canny-pro":              0.05 * USD, | ||||
| 	"black-forest-labs/flux-depth-dev":              0.025 * USD, | ||||
| 	"black-forest-labs/flux-depth-pro":              0.05 * USD, | ||||
| 	"black-forest-labs/flux-dev":                    0.025 * USD, | ||||
| 	"black-forest-labs/flux-dev-lora":               0.032 * USD, | ||||
| 	"black-forest-labs/flux-fill-dev":               0.04 * USD, | ||||
| 	"black-forest-labs/flux-fill-pro":               0.05 * USD, | ||||
| 	"black-forest-labs/flux-pro":                    0.055 * USD, | ||||
| 	"black-forest-labs/flux-redux-dev":              0.025 * USD, | ||||
| 	"black-forest-labs/flux-redux-schnell":          0.003 * USD, | ||||
| 	"black-forest-labs/flux-schnell":                0.003 * USD, | ||||
| 	"black-forest-labs/flux-schnell-lora":           0.02 * USD, | ||||
| 	"ideogram-ai/ideogram-v2":                       0.08 * USD, | ||||
| 	"ideogram-ai/ideogram-v2-turbo":                 0.05 * USD, | ||||
| 	"recraft-ai/recraft-v3":                         0.04 * USD, | ||||
| 	"recraft-ai/recraft-v3-svg":                     0.08 * USD, | ||||
| 	"stability-ai/stable-diffusion-3":               0.035 * USD, | ||||
| 	"stability-ai/stable-diffusion-3.5-large":       0.065 * USD, | ||||
| 	"stability-ai/stable-diffusion-3.5-large-turbo": 0.04 * USD, | ||||
| 	"stability-ai/stable-diffusion-3.5-medium":      0.035 * USD, | ||||
| 	// replicate chat models | ||||
| 	"ibm-granite/granite-20b-code-instruct-8k":  0.100 * USD, | ||||
| 	"ibm-granite/granite-3.0-2b-instruct":       0.030 * USD, | ||||
| 	"ibm-granite/granite-3.0-8b-instruct":       0.050 * USD, | ||||
| 	"ibm-granite/granite-8b-code-instruct-128k": 0.050 * USD, | ||||
| 	"meta/llama-2-13b":                          0.100 * USD, | ||||
| 	"meta/llama-2-13b-chat":                     0.100 * USD, | ||||
| 	"meta/llama-2-70b":                          0.650 * USD, | ||||
| 	"meta/llama-2-70b-chat":                     0.650 * USD, | ||||
| 	"meta/llama-2-7b":                           0.050 * USD, | ||||
| 	"meta/llama-2-7b-chat":                      0.050 * USD, | ||||
| 	"meta/meta-llama-3.1-405b-instruct":         9.500 * USD, | ||||
| 	"meta/meta-llama-3-70b":                     0.650 * USD, | ||||
| 	"meta/meta-llama-3-70b-instruct":            0.650 * USD, | ||||
| 	"meta/meta-llama-3-8b":                      0.050 * USD, | ||||
| 	"meta/meta-llama-3-8b-instruct":             0.050 * USD, | ||||
| 	"mistralai/mistral-7b-instruct-v0.2":        0.050 * USD, | ||||
| 	"mistralai/mistral-7b-v0.1":                 0.050 * USD, | ||||
| 	"mistralai/mixtral-8x7b-instruct-v0.1":      0.300 * USD, | ||||
| } | ||||
|  | ||||
| var CompletionRatio = map[string]float64{ | ||||
| 	// aws llama3 | ||||
| 	"llama3-8b-8192(33)":  0.0006 / 0.0003, | ||||
| 	"llama3-70b-8192(33)": 0.0035 / 0.00265, | ||||
| 	// whisper | ||||
| 	"whisper-1": 0, // only count input tokens | ||||
| 	// deepseek | ||||
| 	"deepseek-chat":     0.28 / 0.14, | ||||
| 	"deepseek-reasoner": 2.19 / 0.55, | ||||
| } | ||||
|  | ||||
| var ( | ||||
| @@ -334,16 +461,22 @@ func GetCompletionRatio(name string, channelType int) float64 { | ||||
| 		return 4.0 / 3.0 | ||||
| 	} | ||||
| 	if strings.HasPrefix(name, "gpt-4") { | ||||
| 		if strings.HasPrefix(name, "gpt-4o-mini") || name == "gpt-4o-2024-08-06" { | ||||
| 		if strings.HasPrefix(name, "gpt-4o") { | ||||
| 			if name == "gpt-4o-2024-05-13" { | ||||
| 				return 3 | ||||
| 			} | ||||
| 			return 4 | ||||
| 		} | ||||
| 		if strings.HasPrefix(name, "gpt-4-turbo") || | ||||
| 			strings.HasPrefix(name, "gpt-4o") || | ||||
| 			strings.HasSuffix(name, "preview") { | ||||
| 			return 3 | ||||
| 		} | ||||
| 		return 2 | ||||
| 	} | ||||
| 	// including o1, o1-preview, o1-mini | ||||
| 	if strings.HasPrefix(name, "o1") { | ||||
| 		return 4 | ||||
| 	} | ||||
| 	if name == "chatgpt-4o-latest" { | ||||
| 		return 3 | ||||
| 	} | ||||
| @@ -362,6 +495,7 @@ func GetCompletionRatio(name string, channelType int) float64 { | ||||
| 	if strings.HasPrefix(name, "deepseek-") { | ||||
| 		return 2 | ||||
| 	} | ||||
|  | ||||
| 	switch name { | ||||
| 	case "llama2-70b-4096": | ||||
| 		return 0.8 / 0.64 | ||||
| @@ -377,6 +511,35 @@ func GetCompletionRatio(name string, channelType int) float64 { | ||||
| 		return 5 | ||||
| 	case "grok-beta": | ||||
| 		return 3 | ||||
| 	// Replicate Models | ||||
| 	// https://replicate.com/pricing | ||||
| 	case "ibm-granite/granite-20b-code-instruct-8k": | ||||
| 		return 5 | ||||
| 	case "ibm-granite/granite-3.0-2b-instruct": | ||||
| 		return 8.333333333333334 | ||||
| 	case "ibm-granite/granite-3.0-8b-instruct", | ||||
| 		"ibm-granite/granite-8b-code-instruct-128k": | ||||
| 		return 5 | ||||
| 	case "meta/llama-2-13b", | ||||
| 		"meta/llama-2-13b-chat", | ||||
| 		"meta/llama-2-7b", | ||||
| 		"meta/llama-2-7b-chat", | ||||
| 		"meta/meta-llama-3-8b", | ||||
| 		"meta/meta-llama-3-8b-instruct": | ||||
| 		return 5 | ||||
| 	case "meta/llama-2-70b", | ||||
| 		"meta/llama-2-70b-chat", | ||||
| 		"meta/meta-llama-3-70b", | ||||
| 		"meta/meta-llama-3-70b-instruct": | ||||
| 		return 2.750 / 0.650 // ≈4.230769 | ||||
| 	case "meta/meta-llama-3.1-405b-instruct": | ||||
| 		return 1 | ||||
| 	case "mistralai/mistral-7b-instruct-v0.2", | ||||
| 		"mistralai/mistral-7b-v0.1": | ||||
| 		return 5 | ||||
| 	case "mistralai/mixtral-8x7b-instruct-v0.1": | ||||
| 		return 1.000 / 0.300 // ≈3.333333 | ||||
| 	} | ||||
|  | ||||
| 	return 1 | ||||
| } | ||||
|   | ||||
| @@ -47,5 +47,6 @@ const ( | ||||
| 	Proxy | ||||
| 	SiliconFlow | ||||
| 	XAI | ||||
| 	Replicate | ||||
| 	Dummy | ||||
| ) | ||||
|   | ||||
| @@ -37,6 +37,8 @@ func ToAPIType(channelType int) int { | ||||
| 		apiType = apitype.DeepL | ||||
| 	case VertextAI: | ||||
| 		apiType = apitype.VertexAI | ||||
| 	case Replicate: | ||||
| 		apiType = apitype.Replicate | ||||
| 	case Proxy: | ||||
| 		apiType = apitype.Proxy | ||||
| 	} | ||||
|   | ||||
| @@ -47,6 +47,7 @@ var ChannelBaseURLs = []string{ | ||||
| 	"",                                          // 43 | ||||
| 	"https://api.siliconflow.cn",                // 44 | ||||
| 	"https://api.x.ai",                          // 45 | ||||
| 	"https://api.replicate.com/v1/models/",      // 46 | ||||
| } | ||||
|  | ||||
| func init() { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package role | ||||
|  | ||||
| const ( | ||||
| 	System    = "system" | ||||
| 	Assistant = "assistant" | ||||
| ) | ||||
|   | ||||
| @@ -110,16 +110,9 @@ func RelayAudioHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus | ||||
| 	}() | ||||
|  | ||||
| 	// map model name | ||||
| 	modelMapping := c.GetString(ctxkey.ModelMapping) | ||||
| 	if modelMapping != "" { | ||||
| 		modelMap := make(map[string]string) | ||||
| 		err := json.Unmarshal([]byte(modelMapping), &modelMap) | ||||
| 		if err != nil { | ||||
| 			return openai.ErrorWrapper(err, "unmarshal_model_mapping_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
| 		if modelMap[audioModel] != "" { | ||||
| 			audioModel = modelMap[audioModel] | ||||
| 		} | ||||
| 	modelMapping := c.GetStringMapString(ctxkey.ModelMapping) | ||||
| 	if modelMapping != nil && modelMapping[audioModel] != "" { | ||||
| 		audioModel = modelMapping[audioModel] | ||||
| 	} | ||||
|  | ||||
| 	baseURL := channeltype.ChannelBaseURLs[channelType] | ||||
|   | ||||
| @@ -8,7 +8,11 @@ import ( | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/helper" | ||||
| 	"github.com/songquanpeng/one-api/relay/constant/role" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| @@ -90,7 +94,7 @@ func preConsumeQuota(ctx context.Context, textRequest *relaymodel.GeneralOpenAIR | ||||
| 	return preConsumedQuota, nil | ||||
| } | ||||
|  | ||||
| func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.Meta, textRequest *relaymodel.GeneralOpenAIRequest, ratio float64, preConsumedQuota int64, modelRatio float64, groupRatio float64) { | ||||
| func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.Meta, textRequest *relaymodel.GeneralOpenAIRequest, ratio float64, preConsumedQuota int64, modelRatio float64, groupRatio float64, systemPromptReset bool) { | ||||
| 	if usage == nil { | ||||
| 		logger.Error(ctx, "usage is nil, which is unexpected") | ||||
| 		return | ||||
| @@ -118,8 +122,20 @@ func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.M | ||||
| 	if err != nil { | ||||
| 		logger.Error(ctx, "error update user quota cache: "+err.Error()) | ||||
| 	} | ||||
| 	logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f,补全倍率 %.2f", modelRatio, groupRatio, completionRatio) | ||||
| 	model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, promptTokens, completionTokens, textRequest.Model, meta.TokenName, quota, logContent) | ||||
| 	logContent := fmt.Sprintf("倍率:%.2f × %.2f × %.2f", modelRatio, groupRatio, completionRatio) | ||||
| 	model.RecordConsumeLog(ctx, &model.Log{ | ||||
| 		UserId:            meta.UserId, | ||||
| 		ChannelId:         meta.ChannelId, | ||||
| 		PromptTokens:      promptTokens, | ||||
| 		CompletionTokens:  completionTokens, | ||||
| 		ModelName:         textRequest.Model, | ||||
| 		TokenName:         meta.TokenName, | ||||
| 		Quota:             int(quota), | ||||
| 		Content:           logContent, | ||||
| 		IsStream:          meta.IsStream, | ||||
| 		ElapsedTime:       helper.CalcElapsedTime(meta.StartTime), | ||||
| 		SystemPromptReset: systemPromptReset, | ||||
| 	}) | ||||
| 	model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) | ||||
| 	model.UpdateChannelUsedQuota(meta.ChannelId, quota) | ||||
| } | ||||
| @@ -142,15 +158,41 @@ func isErrorHappened(meta *meta.Meta, resp *http.Response) bool { | ||||
| 		} | ||||
| 		return true | ||||
| 	} | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 	if resp.StatusCode != http.StatusOK && | ||||
| 		// replicate return 201 to create a task | ||||
| 		resp.StatusCode != http.StatusCreated { | ||||
| 		return true | ||||
| 	} | ||||
| 	if meta.ChannelType == channeltype.DeepL { | ||||
| 		// skip stream check for deepl | ||||
| 		return false | ||||
| 	} | ||||
| 	if meta.IsStream && strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { | ||||
|  | ||||
| 	if meta.IsStream && strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") && | ||||
| 		// Even if stream mode is enabled, replicate will first return a task info in JSON format, | ||||
| 		// requiring the client to request the stream endpoint in the task info | ||||
| 		meta.ChannelType != channeltype.Replicate { | ||||
| 		return true | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func setSystemPrompt(ctx context.Context, request *relaymodel.GeneralOpenAIRequest, prompt string) (reset bool) { | ||||
| 	if prompt == "" { | ||||
| 		return false | ||||
| 	} | ||||
| 	if len(request.Messages) == 0 { | ||||
| 		return false | ||||
| 	} | ||||
| 	if request.Messages[0].Role == role.System { | ||||
| 		request.Messages[0].Content = prompt | ||||
| 		logger.Infof(ctx, "rewrite system prompt") | ||||
| 		return true | ||||
| 	} | ||||
| 	request.Messages = append([]relaymodel.Message{{ | ||||
| 		Role:    role.System, | ||||
| 		Content: prompt, | ||||
| 	}}, request.Messages...) | ||||
| 	logger.Infof(ctx, "add system prompt") | ||||
| 	return true | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| @@ -22,7 +23,7 @@ import ( | ||||
| 	relaymodel "github.com/songquanpeng/one-api/relay/model" | ||||
| ) | ||||
|  | ||||
| func getImageRequest(c *gin.Context, relayMode int) (*relaymodel.ImageRequest, error) { | ||||
| func getImageRequest(c *gin.Context, _ int) (*relaymodel.ImageRequest, error) { | ||||
| 	imageRequest := &relaymodel.ImageRequest{} | ||||
| 	err := common.UnmarshalBodyReusable(c, imageRequest) | ||||
| 	if err != nil { | ||||
| @@ -65,7 +66,7 @@ func getImageSizeRatio(model string, size string) float64 { | ||||
| 	return 1 | ||||
| } | ||||
|  | ||||
| func validateImageRequest(imageRequest *relaymodel.ImageRequest, meta *meta.Meta) *relaymodel.ErrorWithStatusCode { | ||||
| func validateImageRequest(imageRequest *relaymodel.ImageRequest, _ *meta.Meta) *relaymodel.ErrorWithStatusCode { | ||||
| 	// check prompt length | ||||
| 	if imageRequest.Prompt == "" { | ||||
| 		return openai.ErrorWrapper(errors.New("prompt is required"), "prompt_missing", http.StatusBadRequest) | ||||
| @@ -150,12 +151,12 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus | ||||
| 	} | ||||
| 	adaptor.Init(meta) | ||||
|  | ||||
| 	// these adaptors need to convert the request | ||||
| 	switch meta.ChannelType { | ||||
| 	case channeltype.Ali: | ||||
| 		fallthrough | ||||
| 	case channeltype.Baidu: | ||||
| 		fallthrough | ||||
| 	case channeltype.Zhipu: | ||||
| 	case channeltype.Zhipu, | ||||
| 		channeltype.Ali, | ||||
| 		channeltype.Replicate, | ||||
| 		channeltype.Baidu: | ||||
| 		finalRequest, err := adaptor.ConvertImageRequest(imageRequest) | ||||
| 		if err != nil { | ||||
| 			return openai.ErrorWrapper(err, "convert_image_request_failed", http.StatusInternalServerError) | ||||
| @@ -172,7 +173,14 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus | ||||
| 	ratio := modelRatio * groupRatio | ||||
| 	userQuota, err := model.CacheGetUserQuota(ctx, meta.UserId) | ||||
|  | ||||
| 	quota := int64(ratio*imageCostRatio*1000) * int64(imageRequest.N) | ||||
| 	var quota int64 | ||||
| 	switch meta.ChannelType { | ||||
| 	case channeltype.Replicate: | ||||
| 		// replicate always return 1 image | ||||
| 		quota = int64(ratio * imageCostRatio * 1000) | ||||
| 	default: | ||||
| 		quota = int64(ratio*imageCostRatio*1000) * int64(imageRequest.N) | ||||
| 	} | ||||
|  | ||||
| 	if userQuota-quota < 0 { | ||||
| 		return openai.ErrorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) | ||||
| @@ -186,7 +194,9 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus | ||||
| 	} | ||||
|  | ||||
| 	defer func(ctx context.Context) { | ||||
| 		if resp != nil && resp.StatusCode != http.StatusOK { | ||||
| 		if resp != nil && | ||||
| 			resp.StatusCode != http.StatusCreated && // replicate returns 201 | ||||
| 			resp.StatusCode != http.StatusOK { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| @@ -200,8 +210,17 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus | ||||
| 		} | ||||
| 		if quota != 0 { | ||||
| 			tokenName := c.GetString(ctxkey.TokenName) | ||||
| 			logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) | ||||
| 			model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, 0, 0, imageRequest.Model, tokenName, quota, logContent) | ||||
| 			logContent := fmt.Sprintf("倍率:%.2f × %.2f", modelRatio, groupRatio) | ||||
| 			model.RecordConsumeLog(ctx, &model.Log{ | ||||
| 				UserId:           meta.UserId, | ||||
| 				ChannelId:        meta.ChannelId, | ||||
| 				PromptTokens:     0, | ||||
| 				CompletionTokens: 0, | ||||
| 				ModelName:        imageRequest.Model, | ||||
| 				TokenName:        tokenName, | ||||
| 				Quota:            int(quota), | ||||
| 				Content:          logContent, | ||||
| 			}) | ||||
| 			model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) | ||||
| 			channelId := c.GetInt(ctxkey.ChannelId) | ||||
| 			model.UpdateChannelUsedQuota(channelId, quota) | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| 	"github.com/songquanpeng/one-api/common/logger" | ||||
| 	"github.com/songquanpeng/one-api/relay" | ||||
| 	"github.com/songquanpeng/one-api/relay/adaptor" | ||||
| @@ -35,6 +37,8 @@ func RelayTextHelper(c *gin.Context) *model.ErrorWithStatusCode { | ||||
| 	meta.OriginModelName = textRequest.Model | ||||
| 	textRequest.Model, _ = getMappedModelName(textRequest.Model, meta.ModelMapping) | ||||
| 	meta.ActualModelName = textRequest.Model | ||||
| 	// set system prompt if not empty | ||||
| 	systemPromptReset := setSystemPrompt(ctx, textRequest, meta.SystemPrompt) | ||||
| 	// get model ratio & group ratio | ||||
| 	modelRatio := billingratio.GetModelRatio(textRequest.Model, meta.ChannelType) | ||||
| 	groupRatio := billingratio.GetGroupRatio(meta.Group) | ||||
| @@ -79,12 +83,12 @@ func RelayTextHelper(c *gin.Context) *model.ErrorWithStatusCode { | ||||
| 		return respErr | ||||
| 	} | ||||
| 	// post-consume quota | ||||
| 	go postConsumeQuota(ctx, usage, meta, textRequest, ratio, preConsumedQuota, modelRatio, groupRatio) | ||||
| 	go postConsumeQuota(ctx, usage, meta, textRequest, ratio, preConsumedQuota, modelRatio, groupRatio, systemPromptReset) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func getRequestBody(c *gin.Context, meta *meta.Meta, textRequest *model.GeneralOpenAIRequest, adaptor adaptor.Adaptor) (io.Reader, error) { | ||||
| 	if meta.APIType == apitype.OpenAI && meta.OriginModelName == meta.ActualModelName && meta.ChannelType != channeltype.Baichuan { | ||||
| 	if !config.EnforceIncludeUsage && meta.APIType == apitype.OpenAI && meta.OriginModelName == meta.ActualModelName && meta.ChannelType != channeltype.Baichuan { | ||||
| 		// no need to convert request for openai | ||||
| 		return c.Request.Body, nil | ||||
| 	} | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| package meta | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common/ctxkey" | ||||
| 	"github.com/songquanpeng/one-api/model" | ||||
| 	"github.com/songquanpeng/one-api/relay/channeltype" | ||||
| 	"github.com/songquanpeng/one-api/relay/relaymode" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Meta struct { | ||||
| @@ -30,6 +33,8 @@ type Meta struct { | ||||
| 	ActualModelName string | ||||
| 	RequestURLPath  string | ||||
| 	PromptTokens    int // only for DoResponse | ||||
| 	SystemPrompt    string | ||||
| 	StartTime       time.Time | ||||
| } | ||||
|  | ||||
| func GetByContext(c *gin.Context) *Meta { | ||||
| @@ -46,6 +51,8 @@ func GetByContext(c *gin.Context) *Meta { | ||||
| 		BaseURL:         c.GetString(ctxkey.BaseURL), | ||||
| 		APIKey:          strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "), | ||||
| 		RequestURLPath:  c.Request.URL.String(), | ||||
| 		SystemPrompt:    c.GetString(ctxkey.SystemPrompt), | ||||
| 		StartTime:       time.Now(), | ||||
| 	} | ||||
| 	cfg, ok := c.Get(ctxkey.Config) | ||||
| 	if ok { | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import ( | ||||
|  | ||||
| func SetRelayRouter(router *gin.Engine) { | ||||
| 	router.Use(middleware.CORS()) | ||||
| 	router.Use(middleware.GzipDecodeMiddleware()) | ||||
| 	// https://platform.openai.com/docs/api-reference/introduction | ||||
| 	modelsRouter := router.Group("/v1/models") | ||||
| 	modelsRouter.Use(middleware.TokenAuth()) | ||||
|   | ||||
| @@ -28,6 +28,8 @@ function renderType(type) { | ||||
|       return <Tag color="orange" size="large"> 管理 </Tag>; | ||||
|     case 4: | ||||
|       return <Tag color="purple" size="large"> 系统 </Tag>; | ||||
|     case 5: | ||||
|       return <Tag color="violet" size="large"> 测试 </Tag>; | ||||
|     default: | ||||
|       return <Tag color="black" size="large"> 未知 </Tag>; | ||||
|   } | ||||
|   | ||||
| @@ -31,6 +31,7 @@ export const CHANNEL_OPTIONS = [ | ||||
|   { key: 43, text: 'Proxy', value: 43, color: 'blue' }, | ||||
|   { key: 44, text: 'SiliconFlow', value: 44, color: 'blue' }, | ||||
|   { key: 45, text: 'xAI', value: 45, color: 'blue' }, | ||||
|   { key: 46, text: 'Replicate', value: 46, color: 'blue' }, | ||||
|   { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, | ||||
|   { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' }, | ||||
|   { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' }, | ||||
|   | ||||
| @@ -43,6 +43,7 @@ const EditChannel = (props) => { | ||||
|         base_url: '', | ||||
|         other: '', | ||||
|         model_mapping: '', | ||||
|         system_prompt: '', | ||||
|         models: [], | ||||
|         auto_ban: 1, | ||||
|         groups: ['default'] | ||||
| @@ -304,163 +305,163 @@ const EditChannel = (props) => { | ||||
|                 width={isMobile() ? '100%' : 600} | ||||
|             > | ||||
|                 <Spin spinning={loading}> | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>类型:</Typography.Text> | ||||
|                     </div> | ||||
|                     <Select | ||||
|                         name='type' | ||||
|                         required | ||||
|                         optionList={CHANNEL_OPTIONS} | ||||
|                         value={inputs.type} | ||||
|                         onChange={value => handleInputChange('type', value)} | ||||
|                         style={{width: '50%'}} | ||||
|                       name='type' | ||||
|                       required | ||||
|                       optionList={CHANNEL_OPTIONS} | ||||
|                       value={inputs.type} | ||||
|                       onChange={value => handleInputChange('type', value)} | ||||
|                       style={{ width: '50%' }} | ||||
|                     /> | ||||
|                     { | ||||
|                         inputs.type === 3 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Banner type={"warning"} description={ | ||||
|                                         <> | ||||
|                                             注意,<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>。 | ||||
|                                         </> | ||||
|                                     }> | ||||
|                                     </Banner> | ||||
|                                 </div> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>AZURE_OPENAI_ENDPOINT:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     label='AZURE_OPENAI_ENDPOINT' | ||||
|                                     name='azure_base_url' | ||||
|                                     placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('base_url', value) | ||||
|                                     }} | ||||
|                                     value={inputs.base_url} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>默认 API 版本:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     label='默认 API 版本' | ||||
|                                     name='azure_other' | ||||
|                                     placeholder={'请输入默认 API 版本,例如:2024-03-01-preview,该配置可以被实际的请求查询参数所覆盖'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('other', value) | ||||
|                                     }} | ||||
|                                     value={inputs.other} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                       inputs.type === 3 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Banner type={"warning"} description={ | ||||
|                                     <> | ||||
|                                         注意,<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>。 | ||||
|                                     </> | ||||
|                                 }> | ||||
|                                 </Banner> | ||||
|                             </div> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>AZURE_OPENAI_ENDPOINT:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               label='AZURE_OPENAI_ENDPOINT' | ||||
|                               name='azure_base_url' | ||||
|                               placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('base_url', value) | ||||
|                               }} | ||||
|                               value={inputs.base_url} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>默认 API 版本:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               label='默认 API 版本' | ||||
|                               name='azure_other' | ||||
|                               placeholder={'请输入默认 API 版本,例如:2024-03-01-preview,该配置可以被实际的请求查询参数所覆盖'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('other', value) | ||||
|                               }} | ||||
|                               value={inputs.other} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|                     { | ||||
|                         inputs.type === 8 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>Base URL:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     name='base_url' | ||||
|                                     placeholder={'请输入自定义渠道的 Base URL'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('base_url', value) | ||||
|                                     }} | ||||
|                                     value={inputs.base_url} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                       inputs.type === 8 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>Base URL:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               name='base_url' | ||||
|                               placeholder={'请输入自定义渠道的 Base URL'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('base_url', value) | ||||
|                               }} | ||||
|                               value={inputs.base_url} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>名称:</Typography.Text> | ||||
|                     </div> | ||||
|                     <Input | ||||
|                         required | ||||
|                         name='name' | ||||
|                         placeholder={'请为渠道命名'} | ||||
|                         onChange={value => { | ||||
|                             handleInputChange('name', value) | ||||
|                         }} | ||||
|                         value={inputs.name} | ||||
|                         autoComplete='new-password' | ||||
|                       required | ||||
|                       name='name' | ||||
|                       placeholder={'请为渠道命名'} | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('name', value) | ||||
|                       }} | ||||
|                       value={inputs.name} | ||||
|                       autoComplete='new-password' | ||||
|                     /> | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>分组:</Typography.Text> | ||||
|                     </div> | ||||
|                     <Select | ||||
|                         placeholder={'请选择可以使用该渠道的分组'} | ||||
|                         name='groups' | ||||
|                         required | ||||
|                         multiple | ||||
|                         selection | ||||
|                         allowAdditions | ||||
|                         additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} | ||||
|                         onChange={value => { | ||||
|                             handleInputChange('groups', value) | ||||
|                         }} | ||||
|                         value={inputs.groups} | ||||
|                         autoComplete='new-password' | ||||
|                         optionList={groupOptions} | ||||
|                       placeholder={'请选择可以使用该渠道的分组'} | ||||
|                       name='groups' | ||||
|                       required | ||||
|                       multiple | ||||
|                       selection | ||||
|                       allowAdditions | ||||
|                       additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('groups', value) | ||||
|                       }} | ||||
|                       value={inputs.groups} | ||||
|                       autoComplete='new-password' | ||||
|                       optionList={groupOptions} | ||||
|                     /> | ||||
|                     { | ||||
|                         inputs.type === 18 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>模型版本:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     name='other' | ||||
|                                     placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('other', value) | ||||
|                                     }} | ||||
|                                     value={inputs.other} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                       inputs.type === 18 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>模型版本:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               name='other' | ||||
|                               placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('other', value) | ||||
|                               }} | ||||
|                               value={inputs.other} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|                     { | ||||
|                         inputs.type === 21 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>知识库 ID:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     label='知识库 ID' | ||||
|                                     name='other' | ||||
|                                     placeholder={'请输入知识库 ID,例如:123456'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('other', value) | ||||
|                                     }} | ||||
|                                     value={inputs.other} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                       inputs.type === 21 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>知识库 ID:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               label='知识库 ID' | ||||
|                               name='other' | ||||
|                               placeholder={'请输入知识库 ID,例如:123456'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('other', value) | ||||
|                               }} | ||||
|                               value={inputs.other} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>模型:</Typography.Text> | ||||
|                     </div> | ||||
|                     <Select | ||||
|                         placeholder={'请选择该渠道所支持的模型'} | ||||
|                         name='models' | ||||
|                         required | ||||
|                         multiple | ||||
|                         selection | ||||
|                         onChange={value => { | ||||
|                             handleInputChange('models', value) | ||||
|                         }} | ||||
|                         value={inputs.models} | ||||
|                         autoComplete='new-password' | ||||
|                         optionList={modelOptions} | ||||
|                       placeholder={'请选择该渠道所支持的模型'} | ||||
|                       name='models' | ||||
|                       required | ||||
|                       multiple | ||||
|                       selection | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('models', value) | ||||
|                       }} | ||||
|                       value={inputs.models} | ||||
|                       autoComplete='new-password' | ||||
|                       optionList={modelOptions} | ||||
|                     /> | ||||
|                     <div style={{lineHeight: '40px', marginBottom: '12px'}}> | ||||
|                     <div style={{ lineHeight: '40px', marginBottom: '12px' }}> | ||||
|                         <Space> | ||||
|                             <Button type='primary' onClick={() => { | ||||
|                                 handleInputChange('models', basicModels); | ||||
| @@ -473,28 +474,41 @@ const EditChannel = (props) => { | ||||
|                             }}>清除所有模型</Button> | ||||
|                         </Space> | ||||
|                         <Input | ||||
|                             addonAfter={ | ||||
|                                 <Button type='primary' onClick={addCustomModel}>填入</Button> | ||||
|                             } | ||||
|                             placeholder='输入自定义模型名称' | ||||
|                             value={customModel} | ||||
|                             onChange={(value) => { | ||||
|                                 setCustomModel(value.trim()); | ||||
|                             }} | ||||
|                           addonAfter={ | ||||
|                               <Button type='primary' onClick={addCustomModel}>填入</Button> | ||||
|                           } | ||||
|                           placeholder='输入自定义模型名称' | ||||
|                           value={customModel} | ||||
|                           onChange={(value) => { | ||||
|                               setCustomModel(value.trim()); | ||||
|                           }} | ||||
|                         /> | ||||
|                     </div> | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>模型重定向:</Typography.Text> | ||||
|                     </div> | ||||
|                     <TextArea | ||||
|                         placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} | ||||
|                         name='model_mapping' | ||||
|                         onChange={value => { | ||||
|                             handleInputChange('model_mapping', value) | ||||
|                         }} | ||||
|                         autosize | ||||
|                         value={inputs.model_mapping} | ||||
|                         autoComplete='new-password' | ||||
|                       placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} | ||||
|                       name='model_mapping' | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('model_mapping', value) | ||||
|                       }} | ||||
|                       autosize | ||||
|                       value={inputs.model_mapping} | ||||
|                       autoComplete='new-password' | ||||
|                     /> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>系统提示词:</Typography.Text> | ||||
|                     </div> | ||||
|                     <TextArea | ||||
|                       placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`} | ||||
|                       name='system_prompt' | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('system_prompt', value) | ||||
|                       }} | ||||
|                       autosize | ||||
|                       value={inputs.system_prompt} | ||||
|                       autoComplete='new-password' | ||||
|                     /> | ||||
|                     <Typography.Text style={{ | ||||
|                         color: 'rgba(var(--semi-blue-5), 1)', | ||||
| @@ -507,116 +521,116 @@ const EditChannel = (props) => { | ||||
|                     }> | ||||
|                         填入模板 | ||||
|                     </Typography.Text> | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>密钥:</Typography.Text> | ||||
|                     </div> | ||||
|                     { | ||||
|                         batch ? | ||||
|                             <TextArea | ||||
|                                 label='密钥' | ||||
|                                 name='key' | ||||
|                                 required | ||||
|                                 placeholder={'请输入密钥,一行一个'} | ||||
|                                 onChange={value => { | ||||
|                                     handleInputChange('key', value) | ||||
|                                 }} | ||||
|                                 value={inputs.key} | ||||
|                                 style={{minHeight: 150, fontFamily: 'JetBrains Mono, Consolas'}} | ||||
|                                 autoComplete='new-password' | ||||
|                             /> | ||||
|                             : | ||||
|                             <Input | ||||
|                                 label='密钥' | ||||
|                                 name='key' | ||||
|                                 required | ||||
|                                 placeholder={type2secretPrompt(inputs.type)} | ||||
|                                 onChange={value => { | ||||
|                                     handleInputChange('key', value) | ||||
|                                 }} | ||||
|                                 value={inputs.key} | ||||
|                                 autoComplete='new-password' | ||||
|                             /> | ||||
|                           <TextArea | ||||
|                             label='密钥' | ||||
|                             name='key' | ||||
|                             required | ||||
|                             placeholder={'请输入密钥,一行一个'} | ||||
|                             onChange={value => { | ||||
|                                 handleInputChange('key', value) | ||||
|                             }} | ||||
|                             value={inputs.key} | ||||
|                             style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|                             autoComplete='new-password' | ||||
|                           /> | ||||
|                           : | ||||
|                           <Input | ||||
|                             label='密钥' | ||||
|                             name='key' | ||||
|                             required | ||||
|                             placeholder={type2secretPrompt(inputs.type)} | ||||
|                             onChange={value => { | ||||
|                                 handleInputChange('key', value) | ||||
|                             }} | ||||
|                             value={inputs.key} | ||||
|                             autoComplete='new-password' | ||||
|                           /> | ||||
|                     } | ||||
|                     <div style={{marginTop: 10}}> | ||||
|                     <div style={{ marginTop: 10 }}> | ||||
|                         <Typography.Text strong>组织:</Typography.Text> | ||||
|                     </div> | ||||
|                     <Input | ||||
|                         label='组织,可选,不填则为默认组织' | ||||
|                         name='openai_organization' | ||||
|                         placeholder='请输入组织org-xxx' | ||||
|                         onChange={value => { | ||||
|                             handleInputChange('openai_organization', value) | ||||
|                         }} | ||||
|                         value={inputs.openai_organization} | ||||
|                       label='组织,可选,不填则为默认组织' | ||||
|                       name='openai_organization' | ||||
|                       placeholder='请输入组织org-xxx' | ||||
|                       onChange={value => { | ||||
|                           handleInputChange('openai_organization', value) | ||||
|                       }} | ||||
|                       value={inputs.openai_organization} | ||||
|                     /> | ||||
|                     <div style={{marginTop: 10, display: 'flex'}}> | ||||
|                     <div style={{ marginTop: 10, display: 'flex' }}> | ||||
|                         <Space> | ||||
|                             <Checkbox | ||||
|                                 name='auto_ban' | ||||
|                                 checked={autoBan} | ||||
|                                 onChange={ | ||||
|                                     () => { | ||||
|                                         setAutoBan(!autoBan); | ||||
|                                     } | ||||
|                                 } | ||||
|                                 // onChange={handleInputChange} | ||||
|                               name='auto_ban' | ||||
|                               checked={autoBan} | ||||
|                               onChange={ | ||||
|                                   () => { | ||||
|                                       setAutoBan(!autoBan); | ||||
|                                   } | ||||
|                               } | ||||
|                               // onChange={handleInputChange} | ||||
|                             /> | ||||
|                             <Typography.Text | ||||
|                                 strong>是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道:</Typography.Text> | ||||
|                               strong>是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道:</Typography.Text> | ||||
|                         </Space> | ||||
|                     </div> | ||||
|  | ||||
|                     { | ||||
|                         !isEdit && ( | ||||
|                             <div style={{marginTop: 10, display: 'flex'}}> | ||||
|                                 <Space> | ||||
|                                     <Checkbox | ||||
|                                         checked={batch} | ||||
|                                         label='批量创建' | ||||
|                                         name='batch' | ||||
|                                         onChange={() => setBatch(!batch)} | ||||
|                                     /> | ||||
|                                     <Typography.Text strong>批量创建</Typography.Text> | ||||
|                                 </Space> | ||||
|                       !isEdit && ( | ||||
|                         <div style={{ marginTop: 10, display: 'flex' }}> | ||||
|                             <Space> | ||||
|                                 <Checkbox | ||||
|                                   checked={batch} | ||||
|                                   label='批量创建' | ||||
|                                   name='batch' | ||||
|                                   onChange={() => setBatch(!batch)} | ||||
|                                 /> | ||||
|                                 <Typography.Text strong>批量创建</Typography.Text> | ||||
|                             </Space> | ||||
|                         </div> | ||||
|                       ) | ||||
|                     } | ||||
|                     { | ||||
|                       inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>代理:</Typography.Text> | ||||
|                             </div> | ||||
|                         ) | ||||
|                             <Input | ||||
|                               label='代理' | ||||
|                               name='base_url' | ||||
|                               placeholder={'此项可选,用于通过代理站来进行 API 调用'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('base_url', value) | ||||
|                               }} | ||||
|                               value={inputs.base_url} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|                     { | ||||
|                         inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>代理:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     label='代理' | ||||
|                                     name='base_url' | ||||
|                                     placeholder={'此项可选,用于通过代理站来进行 API 调用'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('base_url', value) | ||||
|                                     }} | ||||
|                                     value={inputs.base_url} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                     } | ||||
|                     { | ||||
|                         inputs.type === 22 && ( | ||||
|                             <> | ||||
|                                 <div style={{marginTop: 10}}> | ||||
|                                     <Typography.Text strong>私有部署地址:</Typography.Text> | ||||
|                                 </div> | ||||
|                                 <Input | ||||
|                                     name='base_url' | ||||
|                                     placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'} | ||||
|                                     onChange={value => { | ||||
|                                         handleInputChange('base_url', value) | ||||
|                                     }} | ||||
|                                     value={inputs.base_url} | ||||
|                                     autoComplete='new-password' | ||||
|                                 /> | ||||
|                             </> | ||||
|                         ) | ||||
|                       inputs.type === 22 && ( | ||||
|                         <> | ||||
|                             <div style={{ marginTop: 10 }}> | ||||
|                                 <Typography.Text strong>私有部署地址:</Typography.Text> | ||||
|                             </div> | ||||
|                             <Input | ||||
|                               name='base_url' | ||||
|                               placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'} | ||||
|                               onChange={value => { | ||||
|                                   handleInputChange('base_url', value) | ||||
|                               }} | ||||
|                               value={inputs.base_url} | ||||
|                               autoComplete='new-password' | ||||
|                             /> | ||||
|                         </> | ||||
|                       ) | ||||
|                     } | ||||
|  | ||||
|                 </Spin> | ||||
|   | ||||
| @@ -185,6 +185,12 @@ export const CHANNEL_OPTIONS = { | ||||
|     value: 45, | ||||
|     color: 'primary' | ||||
|   }, | ||||
|   45: { | ||||
|     key: 46, | ||||
|     text: 'Replicate', | ||||
|     value: 46, | ||||
|     color: 'primary' | ||||
|   }, | ||||
|   41: { | ||||
|     key: 41, | ||||
|     text: 'Novita', | ||||
|   | ||||
| @@ -1,247 +1,260 @@ | ||||
| import { enqueueSnackbar } from 'notistack'; | ||||
| import { snackbarConstants } from 'constants/SnackbarConstants'; | ||||
| import { API } from './api'; | ||||
| import {enqueueSnackbar} from 'notistack'; | ||||
| import {snackbarConstants} from 'constants/SnackbarConstants'; | ||||
| import {API} from './api'; | ||||
|  | ||||
| export function getSystemName() { | ||||
|   let system_name = localStorage.getItem('system_name'); | ||||
|   if (!system_name) return 'One API'; | ||||
|   return system_name; | ||||
|     let system_name = localStorage.getItem('system_name'); | ||||
|     if (!system_name) return 'One API'; | ||||
|     return system_name; | ||||
| } | ||||
|  | ||||
| export function isMobile() { | ||||
|   return window.innerWidth <= 600; | ||||
|     return window.innerWidth <= 600; | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line | ||||
| export function SnackbarHTMLContent({ htmlContent }) { | ||||
|   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />; | ||||
| export function SnackbarHTMLContent({htmlContent}) { | ||||
|     return <div dangerouslySetInnerHTML={{__html: htmlContent}}/>; | ||||
| } | ||||
|  | ||||
| export function getSnackbarOptions(variant) { | ||||
|   let options = snackbarConstants.Common[variant]; | ||||
|   if (isMobile()) { | ||||
|     // 合并 options 和 snackbarConstants.Mobile | ||||
|     options = { ...options, ...snackbarConstants.Mobile }; | ||||
|   } | ||||
|   return options; | ||||
|     let options = snackbarConstants.Common[variant]; | ||||
|     if (isMobile()) { | ||||
|         // 合并 options 和 snackbarConstants.Mobile | ||||
|         options = {...options, ...snackbarConstants.Mobile}; | ||||
|     } | ||||
|     return options; | ||||
| } | ||||
|  | ||||
| export function showError(error) { | ||||
|   if (error.message) { | ||||
|     if (error.name === 'AxiosError') { | ||||
|       switch (error.response.status) { | ||||
|         case 429: | ||||
|           enqueueSnackbar('错误:请求次数过多,请稍后再试!', getSnackbarOptions('ERROR')); | ||||
|           break; | ||||
|         case 500: | ||||
|           enqueueSnackbar('错误:服务器内部错误,请联系管理员!', getSnackbarOptions('ERROR')); | ||||
|           break; | ||||
|         case 405: | ||||
|           enqueueSnackbar('本站仅作演示之用,无服务端!', getSnackbarOptions('INFO')); | ||||
|           break; | ||||
|         default: | ||||
|           enqueueSnackbar('错误:' + error.message, getSnackbarOptions('ERROR')); | ||||
|       } | ||||
|       return; | ||||
|     if (error.message) { | ||||
|         if (error.name === 'AxiosError') { | ||||
|             switch (error.response.status) { | ||||
|                 case 429: | ||||
|                     enqueueSnackbar('错误:请求次数过多,请稍后再试!', getSnackbarOptions('ERROR')); | ||||
|                     break; | ||||
|                 case 500: | ||||
|                     enqueueSnackbar('错误:服务器内部错误,请联系管理员!', getSnackbarOptions('ERROR')); | ||||
|                     break; | ||||
|                 case 405: | ||||
|                     enqueueSnackbar('本站仅作演示之用,无服务端!', getSnackbarOptions('INFO')); | ||||
|                     break; | ||||
|                 default: | ||||
|                     enqueueSnackbar('错误:' + error.message, getSnackbarOptions('ERROR')); | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
|     } else { | ||||
|         enqueueSnackbar('错误:' + error, getSnackbarOptions('ERROR')); | ||||
|     } | ||||
|   } else { | ||||
|     enqueueSnackbar('错误:' + error, getSnackbarOptions('ERROR')); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function showNotice(message, isHTML = false) { | ||||
|   if (isHTML) { | ||||
|     enqueueSnackbar(<SnackbarHTMLContent htmlContent={message} />, getSnackbarOptions('NOTICE')); | ||||
|   } else { | ||||
|     enqueueSnackbar(message, getSnackbarOptions('NOTICE')); | ||||
|   } | ||||
|     if (isHTML) { | ||||
|         enqueueSnackbar(<SnackbarHTMLContent htmlContent={message}/>, getSnackbarOptions('NOTICE')); | ||||
|     } else { | ||||
|         enqueueSnackbar(message, getSnackbarOptions('NOTICE')); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function showWarning(message) { | ||||
|   enqueueSnackbar(message, getSnackbarOptions('WARNING')); | ||||
|     enqueueSnackbar(message, getSnackbarOptions('WARNING')); | ||||
| } | ||||
|  | ||||
| export function showSuccess(message) { | ||||
|   enqueueSnackbar(message, getSnackbarOptions('SUCCESS')); | ||||
|     enqueueSnackbar(message, getSnackbarOptions('SUCCESS')); | ||||
| } | ||||
|  | ||||
| export function showInfo(message) { | ||||
|   enqueueSnackbar(message, getSnackbarOptions('INFO')); | ||||
|     enqueueSnackbar(message, getSnackbarOptions('INFO')); | ||||
| } | ||||
|  | ||||
| export async function getOAuthState() { | ||||
|   const res = await API.get('/api/oauth/state'); | ||||
|   const { success, message, data } = res.data; | ||||
|   if (success) { | ||||
|     return data; | ||||
|   } else { | ||||
|     showError(message); | ||||
|     return ''; | ||||
|   } | ||||
|     const res = await API.get('/api/oauth/state'); | ||||
|     const {success, message, data} = res.data; | ||||
|     if (success) { | ||||
|         return data; | ||||
|     } else { | ||||
|         showError(message); | ||||
|         return ''; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export async function onGitHubOAuthClicked(github_client_id, openInNewTab = false) { | ||||
|   const state = await getOAuthState(); | ||||
|   if (!state) return; | ||||
|   let url = `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`; | ||||
|   if (openInNewTab) { | ||||
|     window.open(url); | ||||
|   } else { | ||||
|     window.location.href = url; | ||||
|   } | ||||
|     const state = await getOAuthState(); | ||||
|     if (!state) return; | ||||
|     let url = `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`; | ||||
|     if (openInNewTab) { | ||||
|         window.open(url); | ||||
|     } else { | ||||
|         window.location.href = url; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export async function onLarkOAuthClicked(lark_client_id) { | ||||
|   const state = await getOAuthState(); | ||||
|   if (!state) return; | ||||
|   let redirect_uri = `${window.location.origin}/oauth/lark`; | ||||
|   window.open(`https://open.feishu.cn/open-apis/authen/v1/index?redirect_uri=${redirect_uri}&app_id=${lark_client_id}&state=${state}`); | ||||
|     const state = await getOAuthState(); | ||||
|     if (!state) return; | ||||
|     let redirect_uri = `${window.location.origin}/oauth/lark`; | ||||
|     window.open(`https://accounts.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&client_id=${lark_client_id}&state=${state}`); | ||||
| } | ||||
|  | ||||
| export async function onOidcClicked(auth_url, client_id, openInNewTab = false) { | ||||
|   const state = await getOAuthState(); | ||||
|   if (!state) return; | ||||
|   const redirect_uri = `${window.location.origin}/oauth/oidc`; | ||||
|   const response_type = "code"; | ||||
|   const scope = "openid profile email"; | ||||
|   const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`; | ||||
|   if (openInNewTab) { | ||||
|     window.open(url); | ||||
|   } else | ||||
|   { | ||||
|     window.location.href = url; | ||||
|   } | ||||
|     const state = await getOAuthState(); | ||||
|     if (!state) return; | ||||
|     const redirect_uri = `${window.location.origin}/oauth/oidc`; | ||||
|     const response_type = "code"; | ||||
|     const scope = "openid profile email"; | ||||
|     const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`; | ||||
|     if (openInNewTab) { | ||||
|         window.open(url); | ||||
|     } else { | ||||
|         window.location.href = url; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function isAdmin() { | ||||
|   let user = localStorage.getItem('user'); | ||||
|   if (!user) return false; | ||||
|   user = JSON.parse(user); | ||||
|   return user.role >= 10; | ||||
|     let user = localStorage.getItem('user'); | ||||
|     if (!user) return false; | ||||
|     user = JSON.parse(user); | ||||
|     return user.role >= 10; | ||||
| } | ||||
|  | ||||
| export function timestamp2string(timestamp) { | ||||
|   let date = new Date(timestamp * 1000); | ||||
|   let year = date.getFullYear().toString(); | ||||
|   let month = (date.getMonth() + 1).toString(); | ||||
|   let day = date.getDate().toString(); | ||||
|   let hour = date.getHours().toString(); | ||||
|   let minute = date.getMinutes().toString(); | ||||
|   let second = date.getSeconds().toString(); | ||||
|   if (month.length === 1) { | ||||
|     month = '0' + month; | ||||
|   } | ||||
|   if (day.length === 1) { | ||||
|     day = '0' + day; | ||||
|   } | ||||
|   if (hour.length === 1) { | ||||
|     hour = '0' + hour; | ||||
|   } | ||||
|   if (minute.length === 1) { | ||||
|     minute = '0' + minute; | ||||
|   } | ||||
|   if (second.length === 1) { | ||||
|     second = '0' + second; | ||||
|   } | ||||
|   return year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second; | ||||
|     let date = new Date(timestamp * 1000); | ||||
|     let year = date.getFullYear().toString(); | ||||
|     let month = (date.getMonth() + 1).toString(); | ||||
|     let day = date.getDate().toString(); | ||||
|     let hour = date.getHours().toString(); | ||||
|     let minute = date.getMinutes().toString(); | ||||
|     let second = date.getSeconds().toString(); | ||||
|     if (month.length === 1) { | ||||
|         month = '0' + month; | ||||
|     } | ||||
|     if (day.length === 1) { | ||||
|         day = '0' + day; | ||||
|     } | ||||
|     if (hour.length === 1) { | ||||
|         hour = '0' + hour; | ||||
|     } | ||||
|     if (minute.length === 1) { | ||||
|         minute = '0' + minute; | ||||
|     } | ||||
|     if (second.length === 1) { | ||||
|         second = '0' + second; | ||||
|     } | ||||
|     return year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second; | ||||
| } | ||||
|  | ||||
| export function calculateQuota(quota, digits = 2) { | ||||
|   let quotaPerUnit = localStorage.getItem('quota_per_unit'); | ||||
|   quotaPerUnit = parseFloat(quotaPerUnit); | ||||
|     let quotaPerUnit = localStorage.getItem('quota_per_unit'); | ||||
|     quotaPerUnit = parseFloat(quotaPerUnit); | ||||
|  | ||||
|   return (quota / quotaPerUnit).toFixed(digits); | ||||
|     return (quota / quotaPerUnit).toFixed(digits); | ||||
| } | ||||
|  | ||||
| export function renderQuota(quota, digits = 2) { | ||||
|   let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|   displayInCurrency = displayInCurrency === 'true'; | ||||
|   if (displayInCurrency) { | ||||
|     return '$' + calculateQuota(quota, digits); | ||||
|   } | ||||
|   return renderNumber(quota); | ||||
|     let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|     displayInCurrency = displayInCurrency === 'true'; | ||||
|     if (displayInCurrency) { | ||||
|         return '$' + calculateQuota(quota, digits); | ||||
|     } | ||||
|     return renderNumber(quota); | ||||
| } | ||||
|  | ||||
| export const verifyJSON = (str) => { | ||||
|   try { | ||||
|     JSON.parse(str); | ||||
|   } catch (e) { | ||||
|     return false; | ||||
|   } | ||||
|   return true; | ||||
|     try { | ||||
|         JSON.parse(str); | ||||
|     } catch (e) { | ||||
|         return false; | ||||
|     } | ||||
|     return true; | ||||
| }; | ||||
|  | ||||
| export function renderNumber(num) { | ||||
|   if (num >= 1000000000) { | ||||
|     return (num / 1000000000).toFixed(1) + 'B'; | ||||
|   } else if (num >= 1000000) { | ||||
|     return (num / 1000000).toFixed(1) + 'M'; | ||||
|   } else if (num >= 10000) { | ||||
|     return (num / 1000).toFixed(1) + 'k'; | ||||
|   } else { | ||||
|     return num; | ||||
|   } | ||||
|     if (num >= 1000000000) { | ||||
|         return (num / 1000000000).toFixed(1) + 'B'; | ||||
|     } else if (num >= 1000000) { | ||||
|         return (num / 1000000).toFixed(1) + 'M'; | ||||
|     } else if (num >= 10000) { | ||||
|         return (num / 1000).toFixed(1) + 'k'; | ||||
|     } else { | ||||
|         return num; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function renderQuotaWithPrompt(quota, digits) { | ||||
|   let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|   displayInCurrency = displayInCurrency === 'true'; | ||||
|   if (displayInCurrency) { | ||||
|     return `(等价金额:${renderQuota(quota, digits)})`; | ||||
|   } | ||||
|   return ''; | ||||
|     let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|     displayInCurrency = displayInCurrency === 'true'; | ||||
|     if (displayInCurrency) { | ||||
|         return `(等价金额:${renderQuota(quota, digits)})`; | ||||
|     } | ||||
|     return ''; | ||||
| } | ||||
|  | ||||
| export function downloadTextAsFile(text, filename) { | ||||
|   let blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); | ||||
|   let url = URL.createObjectURL(blob); | ||||
|   let a = document.createElement('a'); | ||||
|   a.href = url; | ||||
|   a.download = filename; | ||||
|   a.click(); | ||||
|     let blob = new Blob([text], {type: 'text/plain;charset=utf-8'}); | ||||
|     let url = URL.createObjectURL(blob); | ||||
|     let a = document.createElement('a'); | ||||
|     a.href = url; | ||||
|     a.download = filename; | ||||
|     a.click(); | ||||
| } | ||||
|  | ||||
| export function removeTrailingSlash(url) { | ||||
|   if (url.endsWith('/')) { | ||||
|     return url.slice(0, -1); | ||||
|   } else { | ||||
|     return url; | ||||
|   } | ||||
|     if (url.endsWith('/')) { | ||||
|         return url.slice(0, -1); | ||||
|     } else { | ||||
|         return url; | ||||
|     } | ||||
| } | ||||
|  | ||||
| let channelModels = undefined; | ||||
|  | ||||
| export async function loadChannelModels() { | ||||
|   const res = await API.get('/api/models'); | ||||
|   const { success, data } = res.data; | ||||
|   if (!success) { | ||||
|     return; | ||||
|   } | ||||
|   channelModels = data; | ||||
|   localStorage.setItem('channel_models', JSON.stringify(data)); | ||||
|     const res = await API.get('/api/models'); | ||||
|     const {success, data} = res.data; | ||||
|     if (!success) { | ||||
|         return; | ||||
|     } | ||||
|     channelModels = data; | ||||
|     localStorage.setItem('channel_models', JSON.stringify(data)); | ||||
| } | ||||
|  | ||||
| export function getChannelModels(type) { | ||||
|   if (channelModels !== undefined && type in channelModels) { | ||||
|     return channelModels[type]; | ||||
|   } | ||||
|   let models = localStorage.getItem('channel_models'); | ||||
|   if (!models) { | ||||
|     if (channelModels !== undefined && type in channelModels) { | ||||
|         return channelModels[type]; | ||||
|     } | ||||
|     let models = localStorage.getItem('channel_models'); | ||||
|     if (!models) { | ||||
|         return []; | ||||
|     } | ||||
|     channelModels = JSON.parse(models); | ||||
|     if (type in channelModels) { | ||||
|         return channelModels[type]; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
|   channelModels = JSON.parse(models); | ||||
|   if (type in channelModels) { | ||||
|     return channelModels[type]; | ||||
|   } | ||||
|   return []; | ||||
| } | ||||
|  | ||||
| export function copy(text, name = '') { | ||||
|   try { | ||||
|     navigator.clipboard.writeText(text); | ||||
|   } catch (error) { | ||||
|     text = `复制${name}失败,请手动复制:<br /><br />${text}`; | ||||
|     enqueueSnackbar(<SnackbarHTMLContent htmlContent={text} />, getSnackbarOptions('COPY')); | ||||
|     return; | ||||
|   } | ||||
|   showSuccess(`复制${name}成功!`); | ||||
|     if (navigator.clipboard && navigator.clipboard.writeText) { | ||||
|         navigator.clipboard.writeText(text).then(() => { | ||||
|             showNotice(`复制${name}成功!`, true); | ||||
|         }, () => { | ||||
|             text = `复制${name}失败,请手动复制:<br /><br />${text}`; | ||||
|             enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY')); | ||||
|         }); | ||||
|     } else { | ||||
|         const textArea = document.createElement("textarea"); | ||||
|         textArea.value = text; | ||||
|         document.body.appendChild(textArea); | ||||
|         textArea.select(); | ||||
|         try { | ||||
|             document.execCommand('copy'); | ||||
|             showNotice(`复制${name}成功!`, true); | ||||
|         } catch (err) { | ||||
|             text = `复制${name}失败,请手动复制:<br /><br />${text}`; | ||||
|             enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY')); | ||||
|         } | ||||
|         document.body.removeChild(textArea); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -595,6 +595,28 @@ const EditModal = ({ open, channelId, onCancel, onOk }) => { | ||||
|                   <FormHelperText id="helper-tex-channel-model_mapping-label"> {inputPrompt.model_mapping} </FormHelperText> | ||||
|                 )} | ||||
|               </FormControl> | ||||
|               <FormControl fullWidth error={Boolean(touched.system_prompt && errors.system_prompt)} sx={{ ...theme.typography.otherInput }}> | ||||
|                 {/* <InputLabel htmlFor="channel-model_mapping-label">{inputLabel.model_mapping}</InputLabel> */} | ||||
|                 <TextField | ||||
|                   multiline | ||||
|                   id="channel-system_prompt-label" | ||||
|                   label={inputLabel.system_prompt} | ||||
|                   value={values.system_prompt} | ||||
|                   name="system_prompt" | ||||
|                   onBlur={handleBlur} | ||||
|                   onChange={handleChange} | ||||
|                   aria-describedby="helper-text-channel-system_prompt-label" | ||||
|                   minRows={5} | ||||
|                   placeholder={inputPrompt.system_prompt} | ||||
|                 /> | ||||
|                 {touched.system_prompt && errors.system_prompt ? ( | ||||
|                   <FormHelperText error id="helper-tex-channel-system_prompt-label"> | ||||
|                     {errors.system_prompt} | ||||
|                   </FormHelperText> | ||||
|                 ) : ( | ||||
|                   <FormHelperText id="helper-tex-channel-system_prompt-label"> {inputPrompt.system_prompt} </FormHelperText> | ||||
|                 )} | ||||
|               </FormControl> | ||||
|               <DialogActions> | ||||
|                 <Button onClick={onCancel}>取消</Button> | ||||
|                 <Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary"> | ||||
|   | ||||
| @@ -268,6 +268,8 @@ function renderBalance(type, balance) { | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 13: // AIGC2D | ||||
|       return <span>{renderNumber(balance)}</span>; | ||||
|     case 36: // DeepSeek | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 44: // SiliconFlow | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     default: | ||||
|   | ||||
| @@ -18,6 +18,7 @@ const defaultConfig = { | ||||
|     other: '其他参数', | ||||
|     models: '模型', | ||||
|     model_mapping: '模型映射关系', | ||||
|     system_prompt: '系统提示词', | ||||
|     groups: '用户组', | ||||
|     config: null | ||||
|   }, | ||||
| @@ -30,6 +31,7 @@ const defaultConfig = { | ||||
|     models: '请选择该渠道所支持的模型', | ||||
|     model_mapping: | ||||
|       '请输入要修改的模型映射关系,格式为:api请求模型ID:实际转发给渠道的模型ID,使用JSON数组表示,例如:{"gpt-3.5": "gpt-35"}', | ||||
|     system_prompt:"此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型", | ||||
|     groups: '请选择该渠道所支持的用户组', | ||||
|     config: null | ||||
|   }, | ||||
|   | ||||
| @@ -3,7 +3,8 @@ const LOG_TYPE = { | ||||
|   1: { value: '1', text: '充值', color: 'primary' }, | ||||
|   2: { value: '2', text: '消费', color: 'orange' }, | ||||
|   3: { value: '3', text: '管理', color: 'default' }, | ||||
|   4: { value: '4', text: '系统', color: 'secondary' } | ||||
|   4: { value: '4', text: '系统', color: 'secondary' }, | ||||
|   5: { value: '5', text: '测试', color: 'secondary' }, | ||||
| }; | ||||
|  | ||||
| export default LOG_TYPE; | ||||
|   | ||||
							
								
								
									
										0
									
								
								web/build.sh
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										0
									
								
								web/build.sh
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -13,6 +13,7 @@ | ||||
|     "react-scripts": "5.0.1", | ||||
|     "react-toastify": "^9.0.8", | ||||
|     "react-turnstile": "^1.0.5", | ||||
|     "recharts": "^2.15.1", | ||||
|     "semantic-ui-css": "^2.5.0", | ||||
|     "semantic-ui-react": "^2.1.3" | ||||
|   }, | ||||
|   | ||||
| @@ -25,6 +25,7 @@ import TopUp from './pages/TopUp'; | ||||
| import Log from './pages/Log'; | ||||
| import Chat from './pages/Chat'; | ||||
| import LarkOAuth from './components/LarkOAuth'; | ||||
| import Dashboard from './pages/Dashboard'; | ||||
|  | ||||
| const Home = lazy(() => import('./pages/Home')); | ||||
| const About = lazy(() => import('./pages/About')); | ||||
| @@ -261,11 +262,11 @@ function App() { | ||||
|       <Route | ||||
|         path='/topup' | ||||
|         element={ | ||||
|         <PrivateRoute> | ||||
|           <Suspense fallback={<Loading></Loading>}> | ||||
|             <TopUp /> | ||||
|           </Suspense> | ||||
|         </PrivateRoute> | ||||
|           <PrivateRoute> | ||||
|             <Suspense fallback={<Loading></Loading>}> | ||||
|               <TopUp /> | ||||
|             </Suspense> | ||||
|           </PrivateRoute> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
| @@ -292,9 +293,15 @@ function App() { | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route path='*' element={ | ||||
|           <NotFound /> | ||||
|       } /> | ||||
|       <Route | ||||
|         path='/dashboard' | ||||
|         element={ | ||||
|           <PrivateRoute> | ||||
|             <Dashboard /> | ||||
|           </PrivateRoute> | ||||
|         } | ||||
|       /> | ||||
|       <Route path='*' element={<NotFound />} /> | ||||
|     </Routes> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Dropdown, | ||||
|   Form, | ||||
|   Input, | ||||
|   Label, | ||||
|   Message, | ||||
|   Pagination, | ||||
|   Popup, | ||||
|   Table, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { | ||||
|   API, | ||||
| @@ -9,31 +19,31 @@ import { | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showSuccess, | ||||
|   timestamp2string | ||||
|   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] ? type2label[type].text : type}</Label>; | ||||
|   return ( | ||||
|     <Label basic color={type2label[type]?.color}> | ||||
|       {type2label[type] ? type2label[type].text : type} | ||||
|     </Label> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function renderBalance(type, balance) { | ||||
| @@ -52,6 +62,8 @@ function renderBalance(type, balance) { | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 13: // AIGC2D | ||||
|       return <span>{renderNumber(balance)}</span>; | ||||
|     case 36: // DeepSeek | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     case 44: // SiliconFlow | ||||
|       return <span>¥{balance.toFixed(2)}</span>; | ||||
|     default: | ||||
| @@ -60,10 +72,10 @@ function renderBalance(type, balance) { | ||||
| } | ||||
|  | ||||
| function isShowDetail() { | ||||
|   return localStorage.getItem("show_detail") === "true"; | ||||
|   return localStorage.getItem('show_detail') === 'true'; | ||||
| } | ||||
|  | ||||
| const promptID = "detail" | ||||
| const promptID = 'detail'; | ||||
|  | ||||
| const ChannelsTable = () => { | ||||
|   const [channels, setChannels] = useState([]); | ||||
| @@ -79,33 +91,37 @@ const ChannelsTable = () => { | ||||
|     const res = await API.get(`/api/channel/?p=${startIdx}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|         let localChannels = data.map((channel) => { | ||||
|             if (channel.models === '') { | ||||
|                 channel.models = []; | ||||
|                 channel.test_model = ""; | ||||
|             } else { | ||||
|                 channel.models = channel.models.split(','); | ||||
|                 if (channel.models.length > 0) { | ||||
|                     channel.test_model = channel.models[0]; | ||||
|                 } | ||||
|                 channel.model_options = channel.models.map((model) => { | ||||
|                     return { | ||||
|                         key: model, | ||||
|                         text: model, | ||||
|                         value: model, | ||||
|                     } | ||||
|                 }) | ||||
|                 console.log('channel', channel) | ||||
|             } | ||||
|             return channel; | ||||
|         }); | ||||
|         if (startIdx === 0) { | ||||
|             setChannels(localChannels); | ||||
|       let localChannels = data.map((channel) => { | ||||
|         if (channel.models === '') { | ||||
|           channel.models = []; | ||||
|           channel.test_model = ''; | ||||
|         } else { | ||||
|             let newChannels = [...channels]; | ||||
|             newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels); | ||||
|             setChannels(newChannels); | ||||
|           channel.models = channel.models.split(','); | ||||
|           if (channel.models.length > 0) { | ||||
|             channel.test_model = channel.models[0]; | ||||
|           } | ||||
|           channel.model_options = channel.models.map((model) => { | ||||
|             return { | ||||
|               key: model, | ||||
|               text: model, | ||||
|               value: model, | ||||
|             }; | ||||
|           }); | ||||
|           console.log('channel', channel); | ||||
|         } | ||||
|         return channel; | ||||
|       }); | ||||
|       if (startIdx === 0) { | ||||
|         setChannels(localChannels); | ||||
|       } else { | ||||
|         let newChannels = [...channels]; | ||||
|         newChannels.splice( | ||||
|           startIdx * ITEMS_PER_PAGE, | ||||
|           data.length, | ||||
|           ...localChannels | ||||
|         ); | ||||
|         setChannels(newChannels); | ||||
|       } | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -129,8 +145,8 @@ const ChannelsTable = () => { | ||||
|  | ||||
|   const toggleShowDetail = () => { | ||||
|     setShowDetail(!showDetail); | ||||
|     localStorage.setItem("show_detail", (!showDetail).toString()); | ||||
|   } | ||||
|     localStorage.setItem('show_detail', (!showDetail).toString()); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadChannels(0) | ||||
| @@ -194,13 +210,19 @@ const ChannelsTable = () => { | ||||
|   const renderStatus = (status) => { | ||||
|     switch (status) { | ||||
|       case 1: | ||||
|         return <Label basic color='green'>已启用</Label>; | ||||
|         return ( | ||||
|           <Label basic color='green'> | ||||
|             已启用 | ||||
|           </Label> | ||||
|         ); | ||||
|       case 2: | ||||
|         return ( | ||||
|           <Popup | ||||
|             trigger={<Label basic color='red'> | ||||
|               已禁用 | ||||
|             </Label>} | ||||
|             trigger={ | ||||
|               <Label basic color='red'> | ||||
|                 已禁用 | ||||
|               </Label> | ||||
|             } | ||||
|             content='本渠道被手动禁用' | ||||
|             basic | ||||
|           /> | ||||
| @@ -208,9 +230,11 @@ const ChannelsTable = () => { | ||||
|       case 3: | ||||
|         return ( | ||||
|           <Popup | ||||
|             trigger={<Label basic color='yellow'> | ||||
|               已禁用 | ||||
|             </Label>} | ||||
|             trigger={ | ||||
|               <Label basic color='yellow'> | ||||
|                 已禁用 | ||||
|               </Label> | ||||
|             } | ||||
|             content='本渠道被程序自动禁用' | ||||
|             basic | ||||
|           /> | ||||
| @@ -228,15 +252,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> | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -275,7 +319,11 @@ const ChannelsTable = () => { | ||||
|       newChannels[realIdx].response_time = time * 1000; | ||||
|       newChannels[realIdx].test_time = Date.now() / 1000; | ||||
|       setChannels(newChannels); | ||||
|       showInfo(`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(2)} 秒。`); | ||||
|       showInfo( | ||||
|         `渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed( | ||||
|           2 | ||||
|         )} 秒,模型输出:${message}` | ||||
|       ); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -358,7 +406,6 @@ const ChannelsTable = () => { | ||||
|     setLoading(false); | ||||
|   }; | ||||
|  | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Form onSubmit={searchChannels}> | ||||
| @@ -372,21 +419,23 @@ const ChannelsTable = () => { | ||||
|           onChange={handleKeywordChange} | ||||
|         /> | ||||
|       </Form> | ||||
|       { | ||||
|         showPrompt && ( | ||||
|           <Message onDismiss={() => { | ||||
|       {showPrompt && ( | ||||
|         <Message | ||||
|           onDismiss={() => { | ||||
|             setShowPrompt(false); | ||||
|             setPromptShown(promptID); | ||||
|           }}> | ||||
|             OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。 | ||||
|             <br/> | ||||
|             渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo,如果该模型不可用则使用你所配置的模型列表中的第一个模型。 | ||||
|             <br/> | ||||
|             点击下方详情按钮可以显示余额以及设置额外的测试模型。 | ||||
|           </Message> | ||||
|         ) | ||||
|       } | ||||
|       <Table basic compact size='small'> | ||||
|           }} | ||||
|         > | ||||
|           OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 | ||||
|           0。对于支持的渠道类型,请点击余额进行刷新。 | ||||
|           <br /> | ||||
|           渠道测试仅支持 chat 模型,优先使用 | ||||
|           gpt-3.5-turbo,如果该模型不可用则使用你所配置的模型列表中的第一个模型。 | ||||
|           <br /> | ||||
|           点击下方详情按钮可以显示余额以及设置额外的测试模型。 | ||||
|         </Message> | ||||
|       )} | ||||
|       <Table basic={'very'} compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell | ||||
| @@ -476,7 +525,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 | ||||
| @@ -484,27 +537,38 @@ const ChannelsTable = () => { | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell hidden={!showDetail}> | ||||
|                     <Popup | ||||
|                       trigger={<span onClick={() => { | ||||
|                         updateChannelBalance(channel.id, channel.name, idx); | ||||
|                       }} style={{ cursor: 'pointer' }}> | ||||
|                       {renderBalance(channel.type, channel.balance)} | ||||
|                     </span>} | ||||
|                       trigger={ | ||||
|                         <span | ||||
|                           onClick={() => { | ||||
|                             updateChannelBalance(channel.id, channel.name, idx); | ||||
|                           }} | ||||
|                           style={{ cursor: 'pointer' }} | ||||
|                         > | ||||
|                           {renderBalance(channel.type, channel.balance)} | ||||
|                         </span> | ||||
|                       } | ||||
|                       content='点击更新' | ||||
|                       basic | ||||
|                     /> | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => { | ||||
|                         manageChannel( | ||||
|                           channel.id, | ||||
|                           'priority', | ||||
|                           idx, | ||||
|                           event.target.value | ||||
|                         ); | ||||
|                       }}> | ||||
|                         <input style={{ maxWidth: '60px' }} /> | ||||
|                       </Input>} | ||||
|                       trigger={ | ||||
|                         <Input | ||||
|                           type='number' | ||||
|                           defaultValue={channel.priority} | ||||
|                           onBlur={(event) => { | ||||
|                             manageChannel( | ||||
|                               channel.id, | ||||
|                               'priority', | ||||
|                               idx, | ||||
|                               event.target.value | ||||
|                             ); | ||||
|                           }} | ||||
|                         > | ||||
|                           <input style={{ maxWidth: '60px' }} /> | ||||
|                         </Input> | ||||
|                       } | ||||
|                       content='渠道选择优先级,越高越优先' | ||||
|                       basic | ||||
|                     /> | ||||
| @@ -526,7 +590,12 @@ const ChannelsTable = () => { | ||||
|                         size={'small'} | ||||
|                         positive | ||||
|                         onClick={() => { | ||||
|                           testChannel(channel.id, channel.name, idx, channel.test_model); | ||||
|                           testChannel( | ||||
|                             channel.id, | ||||
|                             channel.name, | ||||
|                             idx, | ||||
|                             channel.test_model | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
|                         测试 | ||||
| @@ -588,14 +657,31 @@ const ChannelsTable = () => { | ||||
|  | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan={showDetail ? "10" : "8"}> | ||||
|               <Button size='small' as={Link} to='/channel/add' loading={loading}> | ||||
|             <Table.HeaderCell colSpan={showDetail ? '10' : '8'}> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 as={Link} | ||||
|                 to='/channel/add' | ||||
|                 loading={loading} | ||||
|               > | ||||
|                 添加新的渠道 | ||||
|               </Button> | ||||
|               <Button size='small' loading={loading} onClick={()=>{testChannels("all")}}> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 loading={loading} | ||||
|                 onClick={() => { | ||||
|                   testChannels('all'); | ||||
|                 }} | ||||
|               > | ||||
|                 测试所有渠道 | ||||
|               </Button> | ||||
|               <Button size='small' loading={loading} onClick={()=>{testChannels("disabled")}}> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 loading={loading} | ||||
|                 onClick={() => { | ||||
|                   testChannels('disabled'); | ||||
|                 }} | ||||
|               > | ||||
|                 测试禁用渠道 | ||||
|               </Button> | ||||
|               {/*<Button size='small' onClick={updateAllChannelsBalance}*/} | ||||
| @@ -610,7 +696,12 @@ const ChannelsTable = () => { | ||||
|                 flowing | ||||
|                 hoverable | ||||
|               > | ||||
|                 <Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}> | ||||
|                 <Button | ||||
|                   size='small' | ||||
|                   loading={loading} | ||||
|                   negative | ||||
|                   onClick={deleteAllDisabledChannels} | ||||
|                 > | ||||
|                   确认删除 | ||||
|                 </Button> | ||||
|               </Popup> | ||||
| @@ -625,8 +716,12 @@ const ChannelsTable = () => { | ||||
|                   (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) | ||||
|                 } | ||||
|               /> | ||||
|               <Button size='small' onClick={refresh} loading={loading}>刷新</Button> | ||||
|               <Button size='small' onClick={toggleShowDetail}>{showDetail ? "隐藏详情" : "详情"}</Button> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 刷新 | ||||
|               </Button> | ||||
|               <Button size='small' onClick={toggleShowDetail}> | ||||
|                 {showDetail ? '隐藏详情' : '详情'} | ||||
|               </Button> | ||||
|             </Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Footer> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ const Footer = () => { | ||||
|  | ||||
|   return ( | ||||
|     <Segment vertical> | ||||
|       <Container textAlign='center'> | ||||
|       <Container textAlign='center' style={{ color: '#666666' }}> | ||||
|         {footer ? ( | ||||
|           <div | ||||
|             className='custom-footer' | ||||
| @@ -37,10 +37,7 @@ const Footer = () => { | ||||
|           ></div> | ||||
|         ) : ( | ||||
|           <div className='custom-footer'> | ||||
|             <a | ||||
|               href='https://github.com/songquanpeng/one-api' | ||||
|               target='_blank' | ||||
|             > | ||||
|             <a href='https://github.com/songquanpeng/one-api' target='_blank'> | ||||
|               {systemName} {process.env.REACT_APP_VERSION}{' '} | ||||
|             </a> | ||||
|             由{' '} | ||||
|   | ||||
| @@ -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,63 @@ 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: '/dashboard', | ||||
|     icon: 'chart bar', | ||||
|   }, | ||||
|   { | ||||
|     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', | ||||
|   }); | ||||
| } | ||||
|  | ||||
| @@ -97,14 +116,24 @@ const Header = () => { | ||||
|               navigate(button.to); | ||||
|               setShowSidebar(false); | ||||
|             }} | ||||
|             style={{ fontSize: '15px' }} | ||||
|           > | ||||
|             {button.name} | ||||
|           </Menu.Item> | ||||
|         ); | ||||
|       } | ||||
|       return ( | ||||
|         <Menu.Item key={button.name} as={Link} to={button.to}> | ||||
|           <Icon name={button.icon} /> | ||||
|         <Menu.Item | ||||
|           key={button.name} | ||||
|           as={Link} | ||||
|           to={button.to} | ||||
|           style={{ | ||||
|             fontSize: '15px', | ||||
|             fontWeight: '400', | ||||
|             color: '#666', | ||||
|           }} | ||||
|         > | ||||
|           <Icon name={button.icon} style={{ marginRight: '4px' }} /> | ||||
|           {button.name} | ||||
|         </Menu.Item> | ||||
|       ); | ||||
| @@ -120,21 +149,17 @@ const Header = () => { | ||||
|           style={ | ||||
|             showSidebar | ||||
|               ? { | ||||
|                 borderBottom: 'none', | ||||
|                 marginBottom: '0', | ||||
|                 borderTop: 'none', | ||||
|                 height: '51px' | ||||
|               } | ||||
|                   borderBottom: 'none', | ||||
|                   marginBottom: '0', | ||||
|                   borderTop: 'none', | ||||
|                   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> | ||||
| @@ -152,7 +177,9 @@ const Header = () => { | ||||
|               {renderButtons(true)} | ||||
|               <Menu.Item> | ||||
|                 {userState.user ? ( | ||||
|                   <Button onClick={logout}>注销</Button> | ||||
|                   <Button onClick={logout} style={{ color: '#666666' }}> | ||||
|                     注销 | ||||
|                   </Button> | ||||
|                 ) : ( | ||||
|                   <> | ||||
|                     <Button | ||||
| @@ -185,12 +212,25 @@ const Header = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Menu borderless style={{ borderTop: 'none' }}> | ||||
|       <Menu | ||||
|         borderless | ||||
|         style={{ | ||||
|           borderTop: 'none', | ||||
|           boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px', | ||||
|           border: 'none', | ||||
|         }} | ||||
|       > | ||||
|         <Container> | ||||
|           <Menu.Item as={Link} to='/' className={'hide-on-mobile'}> | ||||
|             <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} /> | ||||
|             <div style={{ fontSize: '20px' }}> | ||||
|               <b>{systemName}</b> | ||||
|             <div | ||||
|               style={{ | ||||
|                 fontSize: '18px', | ||||
|                 fontWeight: '500', | ||||
|                 color: '#333', | ||||
|               }} | ||||
|             > | ||||
|               {systemName} | ||||
|             </div> | ||||
|           </Menu.Item> | ||||
|           {renderButtons(false)} | ||||
| @@ -200,9 +240,23 @@ const Header = () => { | ||||
|                 text={userState.user.username} | ||||
|                 pointing | ||||
|                 className='link item' | ||||
|                 style={{ | ||||
|                   fontSize: '15px', | ||||
|                   fontWeight: '400', | ||||
|                   color: '#666', | ||||
|                 }} | ||||
|               > | ||||
|                 <Dropdown.Menu> | ||||
|                   <Dropdown.Item onClick={logout}>注销</Dropdown.Item> | ||||
|                   <Dropdown.Item | ||||
|                     onClick={logout} | ||||
|                     style={{ | ||||
|                       fontSize: '15px', | ||||
|                       fontWeight: '400', | ||||
|                       color: '#666', | ||||
|                     }} | ||||
|                   > | ||||
|                     注销 | ||||
|                   </Dropdown.Item> | ||||
|                 </Dropdown.Menu> | ||||
|               </Dropdown> | ||||
|             ) : ( | ||||
| @@ -211,6 +265,11 @@ const Header = () => { | ||||
|                 as={Link} | ||||
|                 to='/login' | ||||
|                 className='btn btn-link' | ||||
|                 style={{ | ||||
|                   fontSize: '15px', | ||||
|                   fontWeight: '400', | ||||
|                   color: '#666', | ||||
|                 }} | ||||
|               /> | ||||
|             )} | ||||
|           </Menu.Menu> | ||||
|   | ||||
| @@ -1,5 +1,16 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Divider, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Message, | ||||
|   Modal, | ||||
|   Segment, | ||||
|   Card, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate, useSearchParams } from 'react-router-dom'; | ||||
| import { UserContext } from '../context/User'; | ||||
| import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; | ||||
| @@ -10,7 +21,7 @@ const LoginForm = () => { | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     username: '', | ||||
|     password: '', | ||||
|     wechat_verification_code: '' | ||||
|     wechat_verification_code: '', | ||||
|   }); | ||||
|   const [searchParams, setSearchParams] = useSearchParams(); | ||||
|   const [submitted, setSubmitted] = useState(false); | ||||
| @@ -63,7 +74,7 @@ const LoginForm = () => { | ||||
|     if (username && password) { | ||||
|       const res = await API.post(`/api/user/login`, { | ||||
|         username, | ||||
|         password | ||||
|         password, | ||||
|       }); | ||||
|       const { success, message, data } = res.data; | ||||
|       if (success) { | ||||
| @@ -86,129 +97,149 @@ const LoginForm = () => { | ||||
|   return ( | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src={logo} /> 用户登录 | ||||
|         </Header> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='user' | ||||
|               iconPosition='left' | ||||
|               placeholder='用户名 / 邮箱地址' | ||||
|               name='username' | ||||
|               value={username} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='密码' | ||||
|               name='password' | ||||
|               type='password' | ||||
|               value={password} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             <Button color='green' fluid size='large' onClick={handleSubmit}> | ||||
|               登录 | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|         <Message> | ||||
|           忘记密码? | ||||
|           <Link to='/reset' className='btn btn-link'> | ||||
|             点击重置 | ||||
|           </Link> | ||||
|           ; 没有账户? | ||||
|           <Link to='/register' className='btn btn-link'> | ||||
|             点击注册 | ||||
|           </Link> | ||||
|         </Message> | ||||
|         {status.github_oauth || status.wechat_login || status.lark_client_id ? ( | ||||
|           <> | ||||
|             <Divider horizontal>Or</Divider> | ||||
|             <div style={{ display: "flex", justifyContent: "center" }}> | ||||
|               {status.github_oauth ? ( | ||||
|                 <Button | ||||
|                   circular | ||||
|                   color='black' | ||||
|                   icon='github' | ||||
|                   onClick={() => onGitHubOAuthClicked(status.github_client_id)} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|               {status.wechat_login ? ( | ||||
|                 <Button | ||||
|                   circular | ||||
|                   color='green' | ||||
|                   icon='wechat' | ||||
|                   onClick={onWeChatLoginClicked} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|               {status.lark_client_id ? ( | ||||
|                 <div style={{ | ||||
|                   background: "radial-gradient(circle, #FFFFFF, #FFFFFF, #00D6B9, #2F73FF, #0a3A9C)", | ||||
|                   width: "36px", | ||||
|                   height: "36px", | ||||
|                   borderRadius: "10em", | ||||
|                   display: "flex", | ||||
|                   cursor: "pointer" | ||||
|                 }} | ||||
|                   onClick={() => onLarkOAuthClicked(status.lark_client_id)} | ||||
|                 > | ||||
|                   <Image | ||||
|                     src={larkIcon} | ||||
|                     avatar | ||||
|                     style={{ width: "16px", height: "16px", cursor: "pointer", margin: "auto" }} | ||||
|                     onClick={() => onLarkOAuthClicked(status.lark_client_id)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|             </div> | ||||
|           </> | ||||
|         ) : ( | ||||
|           <></> | ||||
|         )} | ||||
|         <Modal | ||||
|           onClose={() => setShowWeChatLoginModal(false)} | ||||
|           onOpen={() => setShowWeChatLoginModal(true)} | ||||
|           open={showWeChatLoginModal} | ||||
|           size={'mini'} | ||||
|         <Card | ||||
|           fluid | ||||
|           className='chart-card' | ||||
|           style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|         > | ||||
|           <Modal.Content> | ||||
|             <Modal.Description> | ||||
|               <Image src={status.wechat_qrcode} fluid /> | ||||
|               <div style={{ textAlign: 'center' }}> | ||||
|                 <p> | ||||
|                   微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | ||||
|                 </p> | ||||
|           <Card.Content> | ||||
|             <Card.Header> | ||||
|               <Header | ||||
|                 as='h2' | ||||
|                 textAlign='center' | ||||
|                 style={{ marginBottom: '1.5em' }} | ||||
|               > | ||||
|                 <Image src={logo} style={{ marginBottom: '10px' }} /> | ||||
|                 <Header.Content>用户登录</Header.Content> | ||||
|               </Header> | ||||
|             </Card.Header> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='user' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='用户名 / 邮箱地址' | ||||
|                 name='username' | ||||
|                 value={username} | ||||
|                 onChange={handleChange} | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='lock' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='密码' | ||||
|                 name='password' | ||||
|                 type='password' | ||||
|                 value={password} | ||||
|                 onChange={handleChange} | ||||
|                 style={{ marginBottom: '1.5em' }} | ||||
|               /> | ||||
|               <Button | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 style={{ | ||||
|                   background: '#2F73FF', // 使用更现代的蓝色 | ||||
|                   color: 'white', | ||||
|                   marginBottom: '1.5em', | ||||
|                 }} | ||||
|                 onClick={handleSubmit} | ||||
|               > | ||||
|                 登录 | ||||
|               </Button> | ||||
|             </Form> | ||||
|  | ||||
|             <Divider /> | ||||
|             <Message style={{ background: 'transparent', boxShadow: 'none' }}> | ||||
|               <div | ||||
|                 style={{ | ||||
|                   display: 'flex', | ||||
|                   justifyContent: 'space-between', | ||||
|                   fontSize: '0.9em', | ||||
|                   color: '#666', | ||||
|                 }} | ||||
|               > | ||||
|                 <div> | ||||
|                   忘记密码? | ||||
|                   <Link to='/reset' style={{ color: '#2185d0' }}> | ||||
|                     点击重置 | ||||
|                   </Link> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   没有账户? | ||||
|                   <Link to='/register' style={{ color: '#2185d0' }}> | ||||
|                     点击注册 | ||||
|                   </Link> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <Form size='large'> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   placeholder='验证码' | ||||
|                   name='wechat_verification_code' | ||||
|                   value={inputs.wechat_verification_code} | ||||
|                   onChange={handleChange} | ||||
|                 /> | ||||
|                 <Button | ||||
|                   color='' | ||||
|                   fluid | ||||
|                   size='large' | ||||
|                   onClick={onSubmitWeChatVerificationCode} | ||||
|             </Message> | ||||
|  | ||||
|             {(status.github_oauth || | ||||
|               status.wechat_login || | ||||
|               status.lark_client_id) && ( | ||||
|               <> | ||||
|                 <Divider | ||||
|                   horizontal | ||||
|                   style={{ color: '#666', fontSize: '0.9em' }} | ||||
|                 > | ||||
|                   登录 | ||||
|                 </Button> | ||||
|               </Form> | ||||
|             </Modal.Description> | ||||
|           </Modal.Content> | ||||
|         </Modal> | ||||
|                   使用其他方式登录 | ||||
|                 </Divider> | ||||
|                 <div | ||||
|                   style={{ | ||||
|                     display: 'flex', | ||||
|                     justifyContent: 'center', | ||||
|                     gap: '1em', | ||||
|                     marginTop: '1em', | ||||
|                   }} | ||||
|                 > | ||||
|                   {status.github_oauth && ( | ||||
|                     <Button | ||||
|                       circular | ||||
|                       color='black' | ||||
|                       icon='github' | ||||
|                       onClick={() => | ||||
|                         onGitHubOAuthClicked(status.github_client_id) | ||||
|                       } | ||||
|                     /> | ||||
|                   )} | ||||
|                   {status.wechat_login && ( | ||||
|                     <Button | ||||
|                       circular | ||||
|                       color='green' | ||||
|                       icon='wechat' | ||||
|                       onClick={onWeChatLoginClicked} | ||||
|                     /> | ||||
|                   )} | ||||
|                   {status.lark_client_id && ( | ||||
|                     <div | ||||
|                       style={{ | ||||
|                         background: | ||||
|                           'radial-gradient(circle, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF)', | ||||
|                         width: '36px', | ||||
|                         height: '36px', | ||||
|                         borderRadius: '10em', | ||||
|                         display: 'flex', | ||||
|                         cursor: 'pointer', | ||||
|                       }} | ||||
|                       onClick={() => onLarkOAuthClicked(status.lark_client_id)} | ||||
|                     > | ||||
|                       <Image | ||||
|                         src={larkIcon} | ||||
|                         avatar | ||||
|                         style={{ | ||||
|                           width: '36px', | ||||
|                           height: '36px', | ||||
|                           cursor: 'pointer', | ||||
|                           margin: 'auto', | ||||
|                         }} | ||||
|                       /> | ||||
|                     </div> | ||||
|                   )} | ||||
|                 </div> | ||||
|               </> | ||||
|             )} | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   ); | ||||
|   | ||||
| @@ -1,21 +1,48 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react'; | ||||
| import { API, isAdmin, showError, timestamp2string } from '../helpers'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Header, | ||||
|   Label, | ||||
|   Pagination, | ||||
|   Segment, | ||||
|   Select, | ||||
|   Table, | ||||
| } from 'semantic-ui-react'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   isAdmin, | ||||
|   showError, | ||||
|   showSuccess, | ||||
|   showWarning, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
|  | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
| import { renderColorLabel, renderQuota } from '../helpers/render'; | ||||
| import { Link } from 'react-router-dom'; | ||||
|  | ||||
| function renderTimestamp(timestamp) { | ||||
| function renderTimestamp(timestamp, request_id) { | ||||
|   return ( | ||||
|     <> | ||||
|     <code | ||||
|       onClick={async () => { | ||||
|         if (await copy(request_id)) { | ||||
|           showSuccess(`已复制请求 ID:${request_id}`); | ||||
|         } else { | ||||
|           showWarning(`请求 ID 复制失败:${request_id}`); | ||||
|         } | ||||
|       }} | ||||
|       style={{ cursor: 'pointer' }} | ||||
|     > | ||||
|       {timestamp2string(timestamp)} | ||||
|     </> | ||||
|     </code> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const MODE_OPTIONS = [ | ||||
|   { key: 'all', text: '全部用户', value: 'all' }, | ||||
|   { key: 'self', text: '当前用户', value: 'self' } | ||||
|   { key: 'self', text: '当前用户', value: 'self' }, | ||||
| ]; | ||||
|  | ||||
| const LOG_OPTIONS = [ | ||||
| @@ -23,24 +50,92 @@ 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 }, | ||||
|   { key: '5', text: '测试', value: 5 }, | ||||
| ]; | ||||
|  | ||||
| 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> | ||||
|       ); | ||||
|     case 5: | ||||
|       return ( | ||||
|         <Label basic color='violet'> | ||||
|           测试 | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return <Label basic color='black'> 未知 </Label>; | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           未知 | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function getColorByElapsedTime(elapsedTime) { | ||||
|   if (elapsedTime === undefined || 0) return 'black'; | ||||
|   if (elapsedTime < 1000) return 'green'; | ||||
|   if (elapsedTime < 3000) return 'olive'; | ||||
|   if (elapsedTime < 5000) return 'yellow'; | ||||
|   if (elapsedTime < 10000) return 'orange'; | ||||
|   return 'red'; | ||||
| } | ||||
|  | ||||
| function renderDetail(log) { | ||||
|   return ( | ||||
|     <> | ||||
|       {log.content} | ||||
|       <br /> | ||||
|       {log.elapsed_time && ( | ||||
|         <Label | ||||
|           basic | ||||
|           size={'mini'} | ||||
|           color={getColorByElapsedTime(log.elapsed_time)} | ||||
|         > | ||||
|           {log.elapsed_time} ms | ||||
|         </Label> | ||||
|       )} | ||||
|       {log.is_stream && ( | ||||
|         <> | ||||
|           <Label size={'mini'} color='pink'> | ||||
|             Stream | ||||
|           </Label> | ||||
|         </> | ||||
|       )} | ||||
|       {log.system_prompt_reset && ( | ||||
|         <> | ||||
|           <Label basic size={'mini'} color='red'> | ||||
|             System Prompt Reset | ||||
|           </Label> | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const LogsTable = () => { | ||||
|   const [logs, setLogs] = useState([]); | ||||
|   const [showStat, setShowStat] = useState(false); | ||||
| @@ -57,13 +152,20 @@ const LogsTable = () => { | ||||
|     model_name: '', | ||||
|     start_timestamp: timestamp2string(0), | ||||
|     end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), | ||||
|     channel: '' | ||||
|     channel: '', | ||||
|   }); | ||||
|   const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs; | ||||
|   const { | ||||
|     username, | ||||
|     token_name, | ||||
|     model_name, | ||||
|     start_timestamp, | ||||
|     end_timestamp, | ||||
|     channel, | ||||
|   } = inputs; | ||||
|  | ||||
|   const [stat, setStat] = useState({ | ||||
|     quota: 0, | ||||
|     token: 0 | ||||
|     token: 0, | ||||
|   }); | ||||
|  | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
| @@ -73,7 +175,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); | ||||
| @@ -85,7 +189,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}&channel=${channel}`); | ||||
|     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}&channel=${channel}` | ||||
|     ); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       setStat(data); | ||||
| @@ -105,6 +211,10 @@ const LogsTable = () => { | ||||
|     setShowStat(!showStat); | ||||
|   }; | ||||
|  | ||||
|   const showUserTokenQuota = () => { | ||||
|     return logType !== 5; | ||||
|   }; | ||||
|  | ||||
|   const loadLogs = async (startIdx) => { | ||||
|     let url = ''; | ||||
|     let localStartTimestamp = Date.parse(start_timestamp) / 1000; | ||||
| @@ -197,43 +307,88 @@ const LogsTable = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment> | ||||
|       <> | ||||
|         <Header as='h3'> | ||||
|           使用明细(总消耗额度: | ||||
|           {showStat && renderQuota(stat.quota)} | ||||
|           {!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>点击查看</span>} | ||||
|           {!showStat && ( | ||||
|             <span | ||||
|               onClick={handleEyeClick} | ||||
|               style={{ cursor: 'pointer', color: 'gray' }} | ||||
|             > | ||||
|               点击查看 | ||||
|             </span> | ||||
|           )} | ||||
|           ) | ||||
|         </Header> | ||||
|         <Form> | ||||
|           <Form.Group> | ||||
|             <Form.Input fluid label={'令牌名称'} width={3} value={token_name} | ||||
|                         placeholder={'可选值'} name='token_name' onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='模型名称' width={3} value={model_name} placeholder='可选值' | ||||
|                         name='model_name' | ||||
|                         onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local' | ||||
|                         name='start_timestamp' | ||||
|                         onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local' | ||||
|                         name='end_timestamp' | ||||
|                         onChange={handleInputChange} /> | ||||
|             <Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label={'令牌名称'} | ||||
|               width={3} | ||||
|               value={token_name} | ||||
|               placeholder={'可选值'} | ||||
|               name='token_name' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='模型名称' | ||||
|               width={3} | ||||
|               value={model_name} | ||||
|               placeholder='可选值' | ||||
|               name='model_name' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='起始时间' | ||||
|               width={4} | ||||
|               value={start_timestamp} | ||||
|               type='datetime-local' | ||||
|               name='start_timestamp' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='结束时间' | ||||
|               width={4} | ||||
|               value={end_timestamp} | ||||
|               type='datetime-local' | ||||
|               name='end_timestamp' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Button fluid label='操作' width={2} onClick={refresh}> | ||||
|               查询 | ||||
|             </Form.Button> | ||||
|           </Form.Group> | ||||
|           { | ||||
|             isAdminUser && <> | ||||
|           {isAdminUser && ( | ||||
|             <> | ||||
|               <Form.Group> | ||||
|                 <Form.Input fluid label={'渠道 ID'} width={3} value={channel} | ||||
|                             placeholder='可选值' name='channel' | ||||
|                             onChange={handleInputChange} /> | ||||
|                 <Form.Input fluid label={'用户名称'} width={3} value={username} | ||||
|                             placeholder={'可选值'} name='username' | ||||
|                             onChange={handleInputChange} /> | ||||
|  | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   label={'渠道 ID'} | ||||
|                   width={3} | ||||
|                   value={channel} | ||||
|                   placeholder='可选值' | ||||
|                   name='channel' | ||||
|                   onChange={handleInputChange} | ||||
|                 /> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   label={'用户名称'} | ||||
|                   width={3} | ||||
|                   value={username} | ||||
|                   placeholder={'可选值'} | ||||
|                   name='username' | ||||
|                   onChange={handleInputChange} | ||||
|                 /> | ||||
|               </Form.Group> | ||||
|             </> | ||||
|           } | ||||
|           )} | ||||
|         </Form> | ||||
|         <Table basic compact size='small'> | ||||
|         <Table basic={'very'} compact size='small'> | ||||
|           <Table.Header> | ||||
|             <Table.Row> | ||||
|               <Table.HeaderCell | ||||
| @@ -245,8 +400,8 @@ const LogsTable = () => { | ||||
|               > | ||||
|                 时间 | ||||
|               </Table.HeaderCell> | ||||
|               { | ||||
|                 isAdminUser && <Table.HeaderCell | ||||
|               {isAdminUser && ( | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('channel'); | ||||
| @@ -255,27 +410,7 @@ const LogsTable = () => { | ||||
|                 > | ||||
|                   渠道 | ||||
|                 </Table.HeaderCell> | ||||
|               } | ||||
|               { | ||||
|                 isAdminUser && <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('username'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   用户 | ||||
|                 </Table.HeaderCell> | ||||
|               } | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('token_name'); | ||||
|                 }} | ||||
|                 width={1} | ||||
|               > | ||||
|                 令牌 | ||||
|               </Table.HeaderCell> | ||||
|               )} | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
| @@ -294,33 +429,57 @@ const LogsTable = () => { | ||||
|               > | ||||
|                 模型 | ||||
|               </Table.HeaderCell> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('prompt_tokens'); | ||||
|                 }} | ||||
|                 width={1} | ||||
|               > | ||||
|                 提示 | ||||
|               </Table.HeaderCell> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('completion_tokens'); | ||||
|                 }} | ||||
|                 width={1} | ||||
|               > | ||||
|                 补全 | ||||
|               </Table.HeaderCell> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('quota'); | ||||
|                 }} | ||||
|                 width={1} | ||||
|               > | ||||
|                 额度 | ||||
|               </Table.HeaderCell> | ||||
|               {showUserTokenQuota() && ( | ||||
|                 <> | ||||
|                   {isAdminUser && ( | ||||
|                     <Table.HeaderCell | ||||
|                       style={{ cursor: 'pointer' }} | ||||
|                       onClick={() => { | ||||
|                         sortLog('username'); | ||||
|                       }} | ||||
|                       width={1} | ||||
|                     > | ||||
|                       用户 | ||||
|                     </Table.HeaderCell> | ||||
|                   )} | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('token_name'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     令牌 | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('prompt_tokens'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     提示 | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('completion_tokens'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     补全 | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('quota'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     额度 | ||||
|                   </Table.HeaderCell> | ||||
|                 </> | ||||
|               )} | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
| @@ -343,24 +502,64 @@ const LogsTable = () => { | ||||
|                 if (log.deleted) return <></>; | ||||
|                 return ( | ||||
|                   <Table.Row key={log.id}> | ||||
|                     <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell> | ||||
|                     { | ||||
|                       isAdminUser && ( | ||||
|                         <Table.Cell>{log.channel ? <Label basic>{log.channel}</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> | ||||
|                       {renderTimestamp(log.created_at, log.request_id)} | ||||
|                     </Table.Cell> | ||||
|                     {isAdminUser && ( | ||||
|                       <Table.Cell> | ||||
|                         {log.channel ? ( | ||||
|                           <Label | ||||
|                             basic | ||||
|                             as={Link} | ||||
|                             to={`/channel/edit/${log.channel}`} | ||||
|                           > | ||||
|                             {log.channel} | ||||
|                           </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.content}</Table.Cell> | ||||
|                     <Table.Cell> | ||||
|                       {log.model_name ? renderColorLabel(log.model_name) : ''} | ||||
|                     </Table.Cell> | ||||
|                     {showUserTokenQuota() && ( | ||||
|                       <> | ||||
|                         {isAdminUser && ( | ||||
|                           <Table.Cell> | ||||
|                             {log.username ? ( | ||||
|                               <Label | ||||
|                                 basic | ||||
|                                 as={Link} | ||||
|                                 to={`/user/edit/${log.user_id}`} | ||||
|                               > | ||||
|                                 {log.username} | ||||
|                               </Label> | ||||
|                             ) : ( | ||||
|                               '' | ||||
|                             )} | ||||
|                           </Table.Cell> | ||||
|                         )} | ||||
|                         <Table.Cell> | ||||
|                           {log.token_name | ||||
|                             ? renderColorLabel(log.token_name) | ||||
|                             : ''} | ||||
|                         </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>{renderDetail(log)}</Table.Cell> | ||||
|                   </Table.Row> | ||||
|                 ); | ||||
|               })} | ||||
| @@ -379,7 +578,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} | ||||
| @@ -395,7 +596,7 @@ const LogsTable = () => { | ||||
|             </Table.Row> | ||||
|           </Table.Footer> | ||||
|         </Table> | ||||
|       </Segment> | ||||
|       </> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,21 @@ | ||||
| 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 { | ||||
|   Button, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Card, | ||||
|   Message, | ||||
| } from 'semantic-ui-react'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showNotice, | ||||
|   showSuccess, | ||||
| } from '../helpers'; | ||||
| import { useSearchParams } from 'react-router-dom'; | ||||
|  | ||||
| const PasswordResetConfirm = () => { | ||||
| @@ -37,7 +52,7 @@ const PasswordResetConfirm = () => { | ||||
|       setDisableButton(false); | ||||
|       setCountdown(30); | ||||
|     } | ||||
|     return () => clearInterval(countdownInterval);  | ||||
|     return () => clearInterval(countdownInterval); | ||||
|   }, [disableButton, countdown]); | ||||
|  | ||||
|   async function handleSubmit(e) { | ||||
| @@ -59,55 +74,86 @@ const PasswordResetConfirm = () => { | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|    | ||||
|  | ||||
|   return ( | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src='/logo.png' /> 密码重置确认 | ||||
|         </Header> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='mail' | ||||
|               iconPosition='left' | ||||
|               placeholder='邮箱地址' | ||||
|               name='email' | ||||
|               value={email} | ||||
|               readOnly | ||||
|             /> | ||||
|             {newPassword && ( | ||||
|         <Card | ||||
|           fluid | ||||
|           className='chart-card' | ||||
|           style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|         > | ||||
|           <Card.Content> | ||||
|             <Card.Header> | ||||
|               <Header | ||||
|                 as='h2' | ||||
|                 textAlign='center' | ||||
|                 style={{ marginBottom: '1.5em' }} | ||||
|               > | ||||
|                 <Image src='/logo.png' style={{ marginBottom: '10px' }} /> | ||||
|                 <Header.Content>密码重置确认</Header.Content> | ||||
|               </Header> | ||||
|             </Card.Header> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|               fluid | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='新密码' | ||||
|               name='newPassword' | ||||
|               value={newPassword} | ||||
|               readOnly | ||||
|               onClick={(e) => { | ||||
|                 e.target.select(); | ||||
|                 navigator.clipboard.writeText(newPassword); | ||||
|                 showNotice(`密码已复制到剪贴板:${newPassword}`); | ||||
|               }} | ||||
|             />             | ||||
|                 fluid | ||||
|                 icon='mail' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='邮箱地址' | ||||
|                 name='email' | ||||
|                 value={email} | ||||
|                 readOnly | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|               {newPassword && ( | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   icon='lock' | ||||
|                   iconPosition='left' | ||||
|                   placeholder='新密码' | ||||
|                   name='newPassword' | ||||
|                   value={newPassword} | ||||
|                   readOnly | ||||
|                   style={{ | ||||
|                     marginBottom: '1em', | ||||
|                     cursor: 'pointer', | ||||
|                     backgroundColor: '#f8f9fa', | ||||
|                   }} | ||||
|                   onClick={(e) => { | ||||
|                     e.target.select(); | ||||
|                     navigator.clipboard.writeText(newPassword); | ||||
|                     showNotice(`密码已复制到剪贴板:${newPassword}`); | ||||
|                   }} | ||||
|                 /> | ||||
|               )} | ||||
|               <Button | ||||
|                 color='blue' | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={handleSubmit} | ||||
|                 loading={loading} | ||||
|                 disabled={disableButton} | ||||
|                 style={{ | ||||
|                   background: '#2F73FF', // 使用更现代的蓝色 | ||||
|                   color: 'white', | ||||
|                   marginBottom: '1.5em', | ||||
|                 }} | ||||
|               > | ||||
|                 {disableButton ? '密码重置完成' : '提交'} | ||||
|               </Button> | ||||
|             </Form> | ||||
|             {newPassword && ( | ||||
|               <Message style={{ background: 'transparent', boxShadow: 'none' }}> | ||||
|                 <p style={{ fontSize: '0.9em', color: '#666' }}> | ||||
|                   新密码已生成,请点击密码框或上方按钮复制。请及时登录并修改密码! | ||||
|                 </p> | ||||
|               </Message> | ||||
|             )} | ||||
|             <Button | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|               loading={loading} | ||||
|               disabled={disableButton} | ||||
|             > | ||||
|               {disableButton ? `密码重置完成` : '提交'} | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   );   | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PasswordResetConfirm; | ||||
|   | ||||
| @@ -1,11 +1,19 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Card, | ||||
|   Message, | ||||
| } from 'semantic-ui-react'; | ||||
| import { API, showError, showInfo, showSuccess } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
|  | ||||
| const PasswordResetForm = () => { | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     email: '' | ||||
|     email: '', | ||||
|   }); | ||||
|   const { email } = inputs; | ||||
|  | ||||
| @@ -42,7 +50,7 @@ const PasswordResetForm = () => { | ||||
|  | ||||
|   function handleChange(e) { | ||||
|     const { name, value } = e.target; | ||||
|     setInputs(inputs => ({ ...inputs, [name]: value })); | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   } | ||||
|  | ||||
|   async function handleSubmit(e) { | ||||
| @@ -69,42 +77,72 @@ const PasswordResetForm = () => { | ||||
|   return ( | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src='/logo.png' /> 密码重置 | ||||
|         </Header> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='mail' | ||||
|               iconPosition='left' | ||||
|               placeholder='邮箱地址' | ||||
|               name='email' | ||||
|               value={email} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             {turnstileEnabled ? ( | ||||
|               <Turnstile | ||||
|                 sitekey={turnstileSiteKey} | ||||
|                 onVerify={(token) => { | ||||
|                   setTurnstileToken(token); | ||||
|                 }} | ||||
|         <Card | ||||
|           fluid | ||||
|           className='chart-card' | ||||
|           style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|         > | ||||
|           <Card.Content> | ||||
|             <Card.Header> | ||||
|               <Header | ||||
|                 as='h2' | ||||
|                 textAlign='center' | ||||
|                 style={{ marginBottom: '1.5em' }} | ||||
|               > | ||||
|                 <Image src='/logo.png' style={{ marginBottom: '10px' }} /> | ||||
|                 <Header.Content>密码重置</Header.Content> | ||||
|               </Header> | ||||
|             </Card.Header> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='mail' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='邮箱地址' | ||||
|                 name='email' | ||||
|                 value={email} | ||||
|                 onChange={handleChange} | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             <Button | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|               loading={loading} | ||||
|               disabled={disableButton} | ||||
|             > | ||||
|               {disableButton ? `重试 (${countdown})` : '提交'} | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|               {turnstileEnabled && ( | ||||
|                 <div | ||||
|                   style={{ | ||||
|                     marginBottom: '1em', | ||||
|                     display: 'flex', | ||||
|                     justifyContent: 'center', | ||||
|                   }} | ||||
|                 > | ||||
|                   <Turnstile | ||||
|                     sitekey={turnstileSiteKey} | ||||
|                     onVerify={(token) => { | ||||
|                       setTurnstileToken(token); | ||||
|                     }} | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|               <Button | ||||
|                 color='blue' | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={handleSubmit} | ||||
|                 loading={loading} | ||||
|                 disabled={disableButton} | ||||
|                 style={{ | ||||
|                   background: '#2F73FF', // 使用更现代的蓝色 | ||||
|                   color: 'white', | ||||
|                   marginBottom: '1.5em', | ||||
|                 }} | ||||
|               > | ||||
|                 {disableButton ? `重试 (${countdown})` : '提交'} | ||||
|               </Button> | ||||
|             </Form> | ||||
|             <Message style={{ background: 'transparent', boxShadow: 'none' }}> | ||||
|               <p style={{ fontSize: '0.9em', color: '#666' }}> | ||||
|                 系统将向您的邮箱发送一封包含重置链接的邮件,请注意查收。 | ||||
|               </p> | ||||
|             </Message> | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   ); | ||||
|   | ||||
| @@ -1,29 +1,59 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Label, | ||||
|   Popup, | ||||
|   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); | ||||
| @@ -159,7 +191,7 @@ const RedemptionsTable = () => { | ||||
|         /> | ||||
|       </Form> | ||||
|  | ||||
|       <Table basic compact size='small'> | ||||
|       <Table basic={'very'} compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell | ||||
| @@ -225,11 +257,19 @@ const RedemptionsTable = () => { | ||||
|               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 | ||||
| @@ -239,7 +279,9 @@ const RedemptionsTable = () => { | ||||
|                           if (await copy(redemption.key)) { | ||||
|                             showSuccess('已复制到剪贴板!'); | ||||
|                           } else { | ||||
|                             showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。') | ||||
|                             showWarning( | ||||
|                               '无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。' | ||||
|                             ); | ||||
|                             setSearchKeyword(redemption.key); | ||||
|                           } | ||||
|                         }} | ||||
| @@ -267,7 +309,7 @@ const RedemptionsTable = () => { | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         disabled={redemption.status === 3}  // used | ||||
|                         disabled={redemption.status === 3} // used | ||||
|                         onClick={() => { | ||||
|                           manageRedemption( | ||||
|                             redemption.id, | ||||
| @@ -295,7 +337,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 | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Message, | ||||
|   Segment, | ||||
|   Card, | ||||
|   Divider, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
| @@ -10,7 +20,7 @@ const RegisterForm = () => { | ||||
|     password: '', | ||||
|     password2: '', | ||||
|     email: '', | ||||
|     verification_code: '' | ||||
|     verification_code: '', | ||||
|   }); | ||||
|   const { username, password, password2 } = inputs; | ||||
|   const [showEmailVerification, setShowEmailVerification] = useState(false); | ||||
| @@ -100,92 +110,135 @@ const RegisterForm = () => { | ||||
|   return ( | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src={logo} /> 新用户注册 | ||||
|         </Header> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='user' | ||||
|               iconPosition='left' | ||||
|               placeholder='输入用户名,最长 12 位' | ||||
|               onChange={handleChange} | ||||
|               name='username' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='输入密码,最短 8 位,最长 20 位' | ||||
|               onChange={handleChange} | ||||
|               name='password' | ||||
|               type='password' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='输入密码,最短 8 位,最长 20 位' | ||||
|               onChange={handleChange} | ||||
|               name='password2' | ||||
|               type='password' | ||||
|             /> | ||||
|             {showEmailVerification ? ( | ||||
|               <> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   icon='mail' | ||||
|                   iconPosition='left' | ||||
|                   placeholder='输入邮箱地址' | ||||
|                   onChange={handleChange} | ||||
|                   name='email' | ||||
|                   type='email' | ||||
|                   action={ | ||||
|                     <Button onClick={sendVerificationCode} disabled={loading}> | ||||
|                       获取验证码 | ||||
|                     </Button> | ||||
|                   } | ||||
|                 /> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   icon='lock' | ||||
|                   iconPosition='left' | ||||
|                   placeholder='输入验证码' | ||||
|                   onChange={handleChange} | ||||
|                   name='verification_code' | ||||
|                 /> | ||||
|               </> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             {turnstileEnabled ? ( | ||||
|               <Turnstile | ||||
|                 sitekey={turnstileSiteKey} | ||||
|                 onVerify={(token) => { | ||||
|                   setTurnstileToken(token); | ||||
|                 }} | ||||
|         <Card | ||||
|           fluid | ||||
|           className='chart-card' | ||||
|           style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|         > | ||||
|           <Card.Content> | ||||
|             <Card.Header> | ||||
|               <Header | ||||
|                 as='h2' | ||||
|                 textAlign='center' | ||||
|                 style={{ marginBottom: '1.5em' }} | ||||
|               > | ||||
|                 <Image src={logo} style={{ marginBottom: '10px' }} /> | ||||
|                 <Header.Content>新用户注册</Header.Content> | ||||
|               </Header> | ||||
|             </Card.Header> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='user' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='输入用户名,最长 12 位' | ||||
|                 onChange={handleChange} | ||||
|                 name='username' | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             <Button | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|               loading={loading} | ||||
|             > | ||||
|               注册 | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|         <Message> | ||||
|           已有账户? | ||||
|           <Link to='/login' className='btn btn-link'> | ||||
|             点击登录 | ||||
|           </Link> | ||||
|         </Message> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='lock' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='输入密码,最短 8 位,最长 20 位' | ||||
|                 onChange={handleChange} | ||||
|                 name='password' | ||||
|                 type='password' | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 icon='lock' | ||||
|                 iconPosition='left' | ||||
|                 placeholder='再次输入密码' | ||||
|                 onChange={handleChange} | ||||
|                 name='password2' | ||||
|                 type='password' | ||||
|                 style={{ marginBottom: '1em' }} | ||||
|               /> | ||||
|  | ||||
|               {showEmailVerification && ( | ||||
|                 <> | ||||
|                   <Form.Input | ||||
|                     fluid | ||||
|                     icon='mail' | ||||
|                     iconPosition='left' | ||||
|                     placeholder='输入邮箱地址' | ||||
|                     onChange={handleChange} | ||||
|                     name='email' | ||||
|                     type='email' | ||||
|                     action={ | ||||
|                       <Button | ||||
|                         onClick={sendVerificationCode} | ||||
|                         disabled={loading} | ||||
|                         style={{ backgroundColor: '#2185d0', color: 'white' }} | ||||
|                       > | ||||
|                         获取验证码 | ||||
|                       </Button> | ||||
|                     } | ||||
|                     style={{ marginBottom: '1em' }} | ||||
|                   /> | ||||
|                   <Form.Input | ||||
|                     fluid | ||||
|                     icon='lock' | ||||
|                     iconPosition='left' | ||||
|                     placeholder='输入验证码' | ||||
|                     onChange={handleChange} | ||||
|                     name='verification_code' | ||||
|                     style={{ marginBottom: '1em' }} | ||||
|                   /> | ||||
|                 </> | ||||
|               )} | ||||
|  | ||||
|               {turnstileEnabled && ( | ||||
|                 <div | ||||
|                   style={{ | ||||
|                     marginBottom: '1em', | ||||
|                     display: 'flex', | ||||
|                     justifyContent: 'center', | ||||
|                   }} | ||||
|                 > | ||||
|                   <Turnstile | ||||
|                     sitekey={turnstileSiteKey} | ||||
|                     onVerify={(token) => { | ||||
|                       setTurnstileToken(token); | ||||
|                     }} | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|  | ||||
|               <Button | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={handleSubmit} | ||||
|                 style={{ | ||||
|                   background: '#2F73FF', // 使用更现代的蓝色 | ||||
|                   color: 'white', | ||||
|                   marginBottom: '1.5em', | ||||
|                 }} | ||||
|                 loading={loading} | ||||
|               > | ||||
|                 注册 | ||||
|               </Button> | ||||
|             </Form> | ||||
|  | ||||
|             <Divider /> | ||||
|             <Message style={{ background: 'transparent', boxShadow: 'none' }}> | ||||
|               <div | ||||
|                 style={{ | ||||
|                   textAlign: 'center', | ||||
|                   fontSize: '0.9em', | ||||
|                   color: '#666', | ||||
|                 }} | ||||
|               > | ||||
|                 已有账户? | ||||
|                 <Link to='/login' style={{ color: '#2185d0' }}> | ||||
|                   点击登录 | ||||
|                 </Link> | ||||
|               </div> | ||||
|             </Message> | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   ); | ||||
|   | ||||
| @@ -1,7 +1,22 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Dropdown, | ||||
|   Form, | ||||
|   Label, | ||||
|   Pagination, | ||||
|   Popup, | ||||
|   Table, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showSuccess, | ||||
|   showWarning, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
|  | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
| @@ -21,25 +36,45 @@ const OPEN_LINK_OPTIONS = [ | ||||
| ]; | ||||
|  | ||||
| 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> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -98,9 +133,10 @@ const TokensTable = () => { | ||||
|     let encodedServerAddress = encodeURIComponent(serverAddress); | ||||
|     const nextLink = localStorage.getItem('chat_link'); | ||||
|     let nextUrl; | ||||
|    | ||||
|  | ||||
|     if (nextLink) { | ||||
|       nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|       nextUrl = | ||||
|         nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } else { | ||||
|       nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } | ||||
| @@ -117,7 +153,9 @@ const TokensTable = () => { | ||||
|         url = nextUrl; | ||||
|         break; | ||||
|       case 'lobechat': | ||||
|         url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; | ||||
|         url = | ||||
|           nextLink + | ||||
|           `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; | ||||
|         break; | ||||
|       default: | ||||
|         url = `sk-${key}`; | ||||
| @@ -135,7 +173,7 @@ const TokensTable = () => { | ||||
|     let serverAddress = ''; | ||||
|     if (status) { | ||||
|       status = JSON.parse(status); | ||||
|       serverAddress = status.server_address;  | ||||
|       serverAddress = status.server_address; | ||||
|     } | ||||
|     if (serverAddress === '') { | ||||
|       serverAddress = window.location.origin; | ||||
| @@ -143,9 +181,10 @@ const TokensTable = () => { | ||||
|     let encodedServerAddress = encodeURIComponent(serverAddress); | ||||
|     const chatLink = localStorage.getItem('chat_link'); | ||||
|     let defaultUrl; | ||||
|    | ||||
|  | ||||
|     if (chatLink) { | ||||
|       defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|       defaultUrl = | ||||
|         chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } else { | ||||
|       defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } | ||||
| @@ -154,21 +193,23 @@ const TokensTable = () => { | ||||
|       case 'ama': | ||||
|         url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; | ||||
|         break; | ||||
|    | ||||
|  | ||||
|       case 'opencat': | ||||
|         url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; | ||||
|         break; | ||||
|  | ||||
|       case 'lobechat': | ||||
|         url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; | ||||
|         url = | ||||
|           chatLink + | ||||
|           `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; | ||||
|         break; | ||||
|  | ||||
|       default: | ||||
|         url = defaultUrl; | ||||
|     } | ||||
|    | ||||
|  | ||||
|     window.open(url, '_blank'); | ||||
|   } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadTokens(0, orderBy) | ||||
| @@ -274,7 +315,7 @@ const TokensTable = () => { | ||||
|         /> | ||||
|       </Form> | ||||
|  | ||||
|       <Table basic compact size='small'> | ||||
|       <Table basic={'very'} compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell | ||||
| @@ -342,12 +383,20 @@ 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.Group color='green' size={'small'}> | ||||
|                       <Button.Group color='green' size={'small'}> | ||||
|                         <Button | ||||
|                           size={'small'} | ||||
|                           positive | ||||
| @@ -360,38 +409,37 @@ const TokensTable = () => { | ||||
|                         <Dropdown | ||||
|                           className='button icon' | ||||
|                           floating | ||||
|                           options={COPY_OPTIONS.map(option => ({ | ||||
|                           options={COPY_OPTIONS.map((option) => ({ | ||||
|                             ...option, | ||||
|                             onClick: async () => { | ||||
|                               await onCopy(option.value, token.key); | ||||
|                             } | ||||
|                             }, | ||||
|                           }))} | ||||
|                           trigger={<></>} | ||||
|                         /> | ||||
|                       </Button.Group> | ||||
|                       {' '} | ||||
|                       </Button.Group>{' '} | ||||
|                       <Button.Group color='blue' size={'small'}> | ||||
|                         <Button | ||||
|                             size={'small'} | ||||
|                             positive | ||||
|                             onClick={() => {      | ||||
|                               onOpenLink('', token.key);        | ||||
|                             }}> | ||||
|                             聊天 | ||||
|                           </Button> | ||||
|                           <Dropdown    | ||||
|                             className="button icon"        | ||||
|                             floating | ||||
|                             options={OPEN_LINK_OPTIONS.map(option => ({ | ||||
|                               ...option, | ||||
|                               onClick: async () => { | ||||
|                                 await onOpenLink(option.value, token.key); | ||||
|                               } | ||||
|                             }))}        | ||||
|                             trigger={<></>}    | ||||
|                           /> | ||||
|                       </Button.Group> | ||||
|                       {' '} | ||||
|                           size={'small'} | ||||
|                           positive | ||||
|                           onClick={() => { | ||||
|                             onOpenLink('', token.key); | ||||
|                           }} | ||||
|                         > | ||||
|                           聊天 | ||||
|                         </Button> | ||||
|                         <Dropdown | ||||
|                           className='button icon' | ||||
|                           floating | ||||
|                           options={OPEN_LINK_OPTIONS.map((option) => ({ | ||||
|                             ...option, | ||||
|                             onClick: async () => { | ||||
|                               await onOpenLink(option.value, token.key); | ||||
|                             }, | ||||
|                           }))} | ||||
|                           trigger={<></>} | ||||
|                         /> | ||||
|                       </Button.Group>{' '} | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative> | ||||
| @@ -443,14 +491,24 @@ 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> | ||||
|               <Dropdown | ||||
|                 placeholder='排序方式' | ||||
|                 selection | ||||
|                 options={[ | ||||
|                   { key: '', text: '默认排序', value: '' }, | ||||
|                   { key: 'remain_quota', text: '按剩余额度排序', value: 'remain_quota' }, | ||||
|                   { key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, | ||||
|                   { | ||||
|                     key: 'remain_quota', | ||||
|                     text: '按剩余额度排序', | ||||
|                     value: 'remain_quota', | ||||
|                   }, | ||||
|                   { | ||||
|                     key: 'used_quota', | ||||
|                     text: '按已用额度排序', | ||||
|                     value: 'used_quota', | ||||
|                   }, | ||||
|                 ]} | ||||
|                 value={orderBy} | ||||
|                 onChange={handleOrderByChange} | ||||
|   | ||||
| @@ -1,10 +1,23 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Label, Pagination, Popup, Table, Dropdown } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Label, | ||||
|   Pagination, | ||||
|   Popup, | ||||
|   Table, | ||||
|   Dropdown, | ||||
| } 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) { | ||||
| @@ -66,7 +79,7 @@ const UsersTable = () => { | ||||
|     (async () => { | ||||
|       const res = await API.post('/api/user/manage', { | ||||
|         username, | ||||
|         action | ||||
|         action, | ||||
|       }); | ||||
|       const { success, message } = res.data; | ||||
|       if (success) { | ||||
| @@ -169,7 +182,7 @@ const UsersTable = () => { | ||||
|         /> | ||||
|       </Form> | ||||
|  | ||||
|       <Table basic compact size='small'> | ||||
|       <Table basic={'very'} compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell | ||||
| @@ -239,7 +252,9 @@ const UsersTable = () => { | ||||
|                     <Popup | ||||
|                       content={user.email ? user.email : '未绑定邮箱地址'} | ||||
|                       key={user.username} | ||||
|                       header={user.display_name ? user.display_name : user.username} | ||||
|                       header={ | ||||
|                         user.display_name ? user.display_name : user.username | ||||
|                       } | ||||
|                       trigger={<span>{renderText(user.username, 15)}</span>} | ||||
|                       hoverable | ||||
|                     /> | ||||
| @@ -249,9 +264,22 @@ const UsersTable = () => { | ||||
|                   {/*  {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/} | ||||
|                   {/*</Table.Cell>*/} | ||||
|                   <Table.Cell> | ||||
|                     <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>} /> | ||||
|                     <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> | ||||
| @@ -279,7 +307,11 @@ const UsersTable = () => { | ||||
|                       </Button> | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative disabled={user.role === 100}> | ||||
|                           <Button | ||||
|                             size='small' | ||||
|                             negative | ||||
|                             disabled={user.role === 100} | ||||
|                           > | ||||
|                             删除 | ||||
|                           </Button> | ||||
|                         } | ||||
| @@ -335,8 +367,16 @@ const UsersTable = () => { | ||||
|                 options={[ | ||||
|                   { key: '', text: '默认排序', value: '' }, | ||||
|                   { key: 'quota', text: '按剩余额度排序', value: 'quota' }, | ||||
|                   { key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, | ||||
|                   { key: 'request_count', text: '按请求次数排序', value: 'request_count' }, | ||||
|                   { | ||||
|                     key: 'used_quota', | ||||
|                     text: '按已用额度排序', | ||||
|                     value: 'used_quota', | ||||
|                   }, | ||||
|                   { | ||||
|                     key: 'request_count', | ||||
|                     text: '按请求次数排序', | ||||
|                     value: 'request_count', | ||||
|                   }, | ||||
|                 ]} | ||||
|                 value={orderBy} | ||||
|                 onChange={handleOrderByChange} | ||||
|   | ||||
| @@ -31,6 +31,7 @@ export const CHANNEL_OPTIONS = [ | ||||
|     { key: 43, text: 'Proxy', value: 43, color: 'blue' }, | ||||
|     { key: 44, text: 'SiliconFlow', value: 44, color: 'blue' }, | ||||
|     { key: 45, text: 'xAI', value: 45, color: 'blue' }, | ||||
|     { key: 46, text: 'Replicate', value: 46, color: 'blue' }, | ||||
|     { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, | ||||
|     { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' }, | ||||
|     { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' }, | ||||
|   | ||||
| @@ -13,16 +13,18 @@ export function renderGroup(group) { | ||||
|   } | ||||
|   let groups = group.split(','); | ||||
|   groups.sort(); | ||||
|   return <> | ||||
|     {groups.map((group) => { | ||||
|       if (group === 'vip' || group === 'pro') { | ||||
|         return <Label color='yellow'>{group}</Label>; | ||||
|       } else if (group === 'svip' || group === 'premium') { | ||||
|         return <Label color='red'>{group}</Label>; | ||||
|       } | ||||
|       return <Label>{group}</Label>; | ||||
|     })} | ||||
|   </>; | ||||
|   return ( | ||||
|     <> | ||||
|       {groups.map((group) => { | ||||
|         if (group === 'vip' || group === 'pro') { | ||||
|           return <Label color='yellow'>{group}</Label>; | ||||
|         } else if (group === 'svip' || group === 'premium') { | ||||
|           return <Label color='red'>{group}</Label>; | ||||
|         } | ||||
|         return <Label>{group}</Label>; | ||||
|       })} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function renderNumber(num) { | ||||
| @@ -55,4 +57,33 @@ export function renderQuotaWithPrompt(quota, digits) { | ||||
|     return `(等价金额:${renderQuota(quota, digits)})`; | ||||
|   } | ||||
|   return ''; | ||||
| } | ||||
| } | ||||
|  | ||||
| const colors = [ | ||||
|   'red', | ||||
|   'orange', | ||||
|   'yellow', | ||||
|   'olive', | ||||
|   'green', | ||||
|   'teal', | ||||
|   'blue', | ||||
|   'violet', | ||||
|   'purple', | ||||
|   'pink', | ||||
|   'brown', | ||||
|   'grey', | ||||
|   'black', | ||||
| ]; | ||||
|  | ||||
| export function renderColorLabel(text) { | ||||
|   let hash = 0; | ||||
|   for (let i = 0; i < text.length; i++) { | ||||
|     hash = text.charCodeAt(i) + ((hash << 5) - hash); | ||||
|   } | ||||
|   let index = Math.abs(hash % colors.length); | ||||
|   return ( | ||||
|     <Label basic color={colors[index]}> | ||||
|       {text} | ||||
|     </Label> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Header, Segment } from 'semantic-ui-react'; | ||||
| import { Card, Header, Segment } from 'semantic-ui-react'; | ||||
| import { API, showError } from '../../helpers'; | ||||
| import { marked } from 'marked'; | ||||
|  | ||||
| @@ -28,31 +28,38 @@ const About = () => { | ||||
|   useEffect(() => { | ||||
|     displayAbout().then(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       { | ||||
|         aboutLoaded && about === '' ? <> | ||||
|           <Segment> | ||||
|             <Header as='h3'>关于</Header> | ||||
|             <p>可在设置页面设置关于内容,支持 HTML & Markdown</p> | ||||
|             项目仓库地址: | ||||
|             <a href='https://github.com/songquanpeng/one-api'> | ||||
|               https://github.com/songquanpeng/one-api | ||||
|             </a> | ||||
|           </Segment> | ||||
|         </> : <> | ||||
|           { | ||||
|             about.startsWith('https://') ? <iframe | ||||
|       {aboutLoaded && about === '' ? ( | ||||
|         <div className='dashboard-container'> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header className='header'>关于系统</Card.Header> | ||||
|               <p>可在设置页面设置关于内容,支持 HTML & Markdown</p> | ||||
|               项目仓库地址: | ||||
|               <a href='https://github.com/songquanpeng/one-api'> | ||||
|                 https://github.com/songquanpeng/one-api | ||||
|               </a> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <> | ||||
|           {about.startsWith('https://') ? ( | ||||
|             <iframe | ||||
|               src={about} | ||||
|               style={{ width: '100%', height: '100vh', border: 'none' }} | ||||
|             /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div> | ||||
|           } | ||||
|             /> | ||||
|           ) : ( | ||||
|             <div | ||||
|               style={{ fontSize: 'larger' }} | ||||
|               dangerouslySetInnerHTML={{ __html: about }} | ||||
|             ></div> | ||||
|           )} | ||||
|         </> | ||||
|       } | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|  | ||||
| export default About; | ||||
|   | ||||
| @@ -1,13 +1,29 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Header, | ||||
|   Input, | ||||
|   Message, | ||||
|   Segment, | ||||
|   Card, | ||||
| } from 'semantic-ui-react'; | ||||
| import { useNavigate, useParams } from 'react-router-dom'; | ||||
| import { API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON } from '../../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   getChannelModels, | ||||
|   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' | ||||
|   'gpt-4-32k-0314': 'gpt-4-32k', | ||||
| }; | ||||
|  | ||||
| function type2secretPrompt(type) { | ||||
| @@ -43,8 +59,9 @@ const EditChannel = () => { | ||||
|     base_url: '', | ||||
|     other: '', | ||||
|     model_mapping: '', | ||||
|     system_prompt: '', | ||||
|     models: [], | ||||
|     groups: ['default'] | ||||
|     groups: ['default'], | ||||
|   }; | ||||
|   const [batch, setBatch] = useState(false); | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
| @@ -60,7 +77,7 @@ const EditChannel = () => { | ||||
|     ak: '', | ||||
|     user_id: '', | ||||
|     vertex_ai_project_id: '', | ||||
|     vertex_ai_adc: '' | ||||
|     vertex_ai_adc: '', | ||||
|   }); | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
| @@ -92,7 +109,11 @@ const EditChannel = () => { | ||||
|         data.groups = data.group.split(','); | ||||
|       } | ||||
|       if (data.model_mapping !== '') { | ||||
|         data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2); | ||||
|         data.model_mapping = JSON.stringify( | ||||
|           JSON.parse(data.model_mapping), | ||||
|           null, | ||||
|           2 | ||||
|         ); | ||||
|       } | ||||
|       setInputs(data); | ||||
|       if (data.config !== '') { | ||||
| @@ -111,7 +132,7 @@ const EditChannel = () => { | ||||
|       let localModelOptions = res.data.data.map((model) => ({ | ||||
|         key: model.id, | ||||
|         text: model.id, | ||||
|         value: model.id | ||||
|         value: model.id, | ||||
|       })); | ||||
|       setOriginModelOptions(localModelOptions); | ||||
|       setFullModels(res.data.data.map((model) => model.id)); | ||||
| @@ -123,11 +144,13 @@ const EditChannel = () => { | ||||
|   const fetchGroups = async () => { | ||||
|     try { | ||||
|       let res = await API.get(`/api/group/`); | ||||
|       setGroupOptions(res.data.data.map((group) => ({ | ||||
|         key: group, | ||||
|         text: group, | ||||
|         value: group | ||||
|       }))); | ||||
|       setGroupOptions( | ||||
|         res.data.data.map((group) => ({ | ||||
|           key: group, | ||||
|           text: group, | ||||
|           value: group, | ||||
|         })) | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       showError(error.message); | ||||
|     } | ||||
| @@ -140,7 +163,7 @@ const EditChannel = () => { | ||||
|         localModelOptions.push({ | ||||
|           key: model, | ||||
|           text: model, | ||||
|           value: model | ||||
|           value: model, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| @@ -162,7 +185,11 @@ const EditChannel = () => { | ||||
|     if (inputs.key === '') { | ||||
|       if (config.ak !== '' && config.sk !== '' && config.region !== '') { | ||||
|         inputs.key = `${config.ak}|${config.sk}|${config.region}`; | ||||
|       } else if (config.region !== '' && config.vertex_ai_project_id !== '' && config.vertex_ai_adc !== '') { | ||||
|       } else if ( | ||||
|         config.region !== '' && | ||||
|         config.vertex_ai_project_id !== '' && | ||||
|         config.vertex_ai_adc !== '' | ||||
|       ) { | ||||
|         inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`; | ||||
|       } | ||||
|     } | ||||
| @@ -178,9 +205,12 @@ const EditChannel = () => { | ||||
|       showInfo('模型映射必须是合法的 JSON 格式!'); | ||||
|       return; | ||||
|     } | ||||
|     let localInputs = {...inputs}; | ||||
|     let localInputs = { ...inputs }; | ||||
|     if (localInputs.base_url && localInputs.base_url.endsWith('/')) { | ||||
|       localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); | ||||
|       localInputs.base_url = localInputs.base_url.slice( | ||||
|         0, | ||||
|         localInputs.base_url.length - 1 | ||||
|       ); | ||||
|     } | ||||
|     if (localInputs.type === 3 && localInputs.other === '') { | ||||
|       localInputs.other = '2024-03-01-preview'; | ||||
| @@ -190,7 +220,10 @@ const EditChannel = () => { | ||||
|     localInputs.group = localInputs.groups.join(','); | ||||
|     localInputs.config = JSON.stringify(config); | ||||
|     if (isEdit) { | ||||
|       res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) }); | ||||
|       res = await API.put(`/api/channel/`, { | ||||
|         ...localInputs, | ||||
|         id: parseInt(channelId), | ||||
|       }); | ||||
|     } else { | ||||
|       res = await API.post(`/api/channel/`, localInputs); | ||||
|     } | ||||
| @@ -216,9 +249,9 @@ const EditChannel = () => { | ||||
|     localModelOptions.push({ | ||||
|       key: customModel, | ||||
|       text: customModel, | ||||
|       value: customModel | ||||
|       value: customModel, | ||||
|     }); | ||||
|     setModelOptions(modelOptions => { | ||||
|     setModelOptions((modelOptions) => { | ||||
|       return [...modelOptions, ...localModelOptions]; | ||||
|     }); | ||||
|     setCustomModel(''); | ||||
| @@ -226,34 +259,45 @@ const EditChannel = () => { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment loading={loading}> | ||||
|         <Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header> | ||||
|         <Form autoComplete='new-password'> | ||||
|           <Form.Field> | ||||
|             <Form.Select | ||||
|               label='类型' | ||||
|               name='type' | ||||
|               required | ||||
|               search | ||||
|               options={CHANNEL_OPTIONS} | ||||
|               value={inputs.type} | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           { | ||||
|             inputs.type === 3 && ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新渠道信息' : '创建新的渠道'} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Select | ||||
|                 label='类型' | ||||
|                 name='type' | ||||
|                 required | ||||
|                 search | ||||
|                 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>。 | ||||
|                   注意,<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'} | ||||
|                     placeholder={ | ||||
|                       '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com' | ||||
|                     } | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.base_url} | ||||
|                     autoComplete='new-password' | ||||
| @@ -263,73 +307,72 @@ const EditChannel = () => { | ||||
|                   <Form.Input | ||||
|                     label='默认 API 版本' | ||||
|                     name='other' | ||||
|                     placeholder={'请输入默认 API 版本,例如:2024-03-01-preview,该配置可以被实际的请求查询参数所覆盖'} | ||||
|                     placeholder={ | ||||
|                       '请输入默认 API 版本,例如:2024-03-01-preview,该配置可以被实际的请求查询参数所覆盖' | ||||
|                     } | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.other} | ||||
|                     autoComplete='new-password' | ||||
|                   /> | ||||
|                 </Form.Field> | ||||
|               </> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 8 && ( | ||||
|             )} | ||||
|             {inputs.type === 8 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='Base URL' | ||||
|                   name='base_url' | ||||
|                   placeholder={'请输入自定义渠道的 Base URL,例如:https://openai.justsong.cn'} | ||||
|                   placeholder={ | ||||
|                     '请输入自定义渠道的 Base URL,例如:https://openai.justsong.cn' | ||||
|                   } | ||||
|                   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> | ||||
|           { | ||||
|             inputs.type === 18 && ( | ||||
|             )} | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='名称' | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.name} | ||||
|                 required | ||||
|               /> | ||||
|             </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> | ||||
|             {inputs.type === 18 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='模型版本' | ||||
|                   name='other' | ||||
|                   placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'} | ||||
|                   placeholder={ | ||||
|                     '请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1' | ||||
|                   } | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.other} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 21 && ( | ||||
|             )} | ||||
|             {inputs.type === 21 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='知识库 ID' | ||||
| @@ -340,38 +383,40 @@ const EditChannel = () => { | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 17 && ( | ||||
|             )} | ||||
|             {inputs.type === 17 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='插件参数' | ||||
|                   name='other' | ||||
|                   placeholder={'请输入插件参数,即 X-DashScope-Plugin 请求头的取值'} | ||||
|                   placeholder={ | ||||
|                     '请输入插件参数,即 X-DashScope-Plugin 请求头的取值' | ||||
|                   } | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.other} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 34 && ( | ||||
|             )} | ||||
|             {inputs.type === 34 && ( | ||||
|               <Message> | ||||
|                 对于 Coze 而言,模型名称即 Bot ID,你可以添加一个前缀 `bot-`,例如:`bot-123456`。 | ||||
|                 对于 Coze 而言,模型名称即 Bot ID,你可以添加一个前缀 | ||||
|                 `bot-`,例如:`bot-123456`。 | ||||
|               </Message> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 40 && ( | ||||
|             )} | ||||
|             {inputs.type === 40 && ( | ||||
|               <Message> | ||||
|                 对于豆包而言,需要手动去 <a target="_blank" href="https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint">模型推理页面</a> 创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。 | ||||
|                 对于豆包而言,需要手动去{' '} | ||||
|                 <a | ||||
|                   target='_blank' | ||||
|                   href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint' | ||||
|                 > | ||||
|                   模型推理页面 | ||||
|                 </a>{' '} | ||||
|                 创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。 | ||||
|               </Message> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 43 && ( | ||||
|             )} | ||||
|             {inputs.type !== 43 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Dropdown | ||||
|                   label='模型' | ||||
| @@ -391,23 +436,44 @@ const EditChannel = () => { | ||||
|                   options={modelOptions} | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 43 && ( | ||||
|             )} | ||||
|             {inputs.type !== 43 && ( | ||||
|               <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> | ||||
|                 <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={addCustomModel}>填入</Button> | ||||
|                     <Button type={'button'} onClick={addCustomModel}> | ||||
|                       填入 | ||||
|                     </Button> | ||||
|                   } | ||||
|                   placeholder='输入自定义模型名称' | ||||
|                   value={customModel} | ||||
| @@ -422,25 +488,44 @@ const EditChannel = () => { | ||||
|                   }} | ||||
|                 /> | ||||
|               </div> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|           inputs.type !== 43 && ( | ||||
|               <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> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 33 && ( | ||||
|             )} | ||||
|             {inputs.type !== 43 && ( | ||||
|               <> | ||||
|                 <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='系统提示词' | ||||
|                     placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`} | ||||
|                     name='system_prompt' | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.system_prompt} | ||||
|                     style={{ | ||||
|                       minHeight: 150, | ||||
|                       fontFamily: 'JetBrains Mono, Consolas', | ||||
|                     }} | ||||
|                     autoComplete='new-password' | ||||
|                   /> | ||||
|                 </Form.Field> | ||||
|               </> | ||||
|             )} | ||||
|             {inputs.type === 33 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='Region' | ||||
| @@ -470,10 +555,8 @@ const EditChannel = () => { | ||||
|                   autoComplete='' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 42 && ( | ||||
|             )} | ||||
|             {inputs.type === 42 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='Region' | ||||
| @@ -497,16 +580,16 @@ const EditChannel = () => { | ||||
|                   label='Google Cloud Application Default Credentials JSON' | ||||
|                   name='vertex_ai_adc' | ||||
|                   required | ||||
|                   placeholder={'Google Cloud Application Default Credentials JSON'} | ||||
|                   placeholder={ | ||||
|                     'Google Cloud Application Default Credentials JSON' | ||||
|                   } | ||||
|                   onChange={handleConfigChange} | ||||
|                   value={config.vertex_ai_adc} | ||||
|                   autoComplete='' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 34 && ( | ||||
|             )} | ||||
|             {inputs.type === 34 && ( | ||||
|               <Form.Input | ||||
|                 label='User ID' | ||||
|                 name='user_id' | ||||
| @@ -515,90 +598,105 @@ const EditChannel = () => { | ||||
|                 onChange={handleConfigChange} | ||||
|                 value={config.user_id} | ||||
|                 autoComplete='' | ||||
|               />) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 33 && inputs.type !== 42 && (batch ? <Form.Field> | ||||
|               <Form.TextArea | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 required | ||||
|                 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={type2secretPrompt(inputs.type)} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field>) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 37 && ( | ||||
|             )} | ||||
|             {inputs.type !== 33 && | ||||
|               inputs.type !== 42 && | ||||
|               (batch ? ( | ||||
|                 <Form.Field> | ||||
|                   <Form.TextArea | ||||
|                     label='密钥' | ||||
|                     name='key' | ||||
|                     required | ||||
|                     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={type2secretPrompt(inputs.type)} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.key} | ||||
|                     autoComplete='new-password' | ||||
|                   /> | ||||
|                 </Form.Field> | ||||
|               ))} | ||||
|             {inputs.type === 37 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='Account ID' | ||||
|                   name='user_id' | ||||
|                   required | ||||
|                   placeholder={'请输入 Account ID,例如:d8d7c61dbc334c32d3ced580e4bf42b4'} | ||||
|                   placeholder={ | ||||
|                     '请输入 Account ID,例如:d8d7c61dbc334c32d3ced580e4bf42b4' | ||||
|                   } | ||||
|                   onChange={handleConfigChange} | ||||
|                   value={config.user_id} | ||||
|                   autoComplete='' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 33 && !isEdit && ( | ||||
|             )} | ||||
|             {inputs.type !== 33 && !isEdit && ( | ||||
|               <Form.Checkbox | ||||
|                 checked={batch} | ||||
|                 label='批量创建' | ||||
|                 name='batch' | ||||
|                 onChange={() => setBatch(!batch)} | ||||
|               /> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 3 && inputs.type !== 33 && inputs.type !== 8 && inputs.type !== 22 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='代理' | ||||
|                   name='base_url' | ||||
|                   placeholder={'此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.base_url} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 22 && ( | ||||
|             )} | ||||
|             {inputs.type !== 3 && | ||||
|               inputs.type !== 33 && | ||||
|               inputs.type !== 8 && | ||||
|               inputs.type !== 22 && ( | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='代理' | ||||
|                     name='base_url' | ||||
|                     placeholder={ | ||||
|                       '此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com' | ||||
|                     } | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.base_url} | ||||
|                     autoComplete='new-password' | ||||
|                   /> | ||||
|                 </Form.Field> | ||||
|               )} | ||||
|             {inputs.type === 22 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='私有部署地址' | ||||
|                   name='base_url' | ||||
|                   placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'} | ||||
|                   placeholder={ | ||||
|                     '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi' | ||||
|                   } | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.base_url} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           <Button onClick={handleCancel}>取消</Button> | ||||
|           <Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|             )} | ||||
|             <Button onClick={handleCancel}>取消</Button> | ||||
|             <Button | ||||
|               type={isEdit ? 'button' : 'submit'} | ||||
|               positive | ||||
|               onClick={submit} | ||||
|             > | ||||
|               提交 | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| import React from 'react'; | ||||
| import { Header, Segment } from 'semantic-ui-react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import ChannelsTable from '../../components/ChannelsTable'; | ||||
|  | ||||
| const Channel = () => ( | ||||
|   <> | ||||
|     <Segment> | ||||
|       <Header as='h3'>管理渠道</Header> | ||||
|       <ChannelsTable /> | ||||
|     </Segment> | ||||
|   </> | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>管理渠道</Card.Header> | ||||
|         <ChannelsTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| export default Channel; | ||||
|   | ||||
							
								
								
									
										109
									
								
								web/default/src/pages/Dashboard/Dashboard.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								web/default/src/pages/Dashboard/Dashboard.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| .dashboard-container { | ||||
|     padding: 20px 24px 40px; | ||||
|     background-color: #ffffff; | ||||
|     margin-top: -15px; /* 减小与导航栏的间距 */ | ||||
|     max-width: 1600px;        /* 设置最大宽度 */ | ||||
|     margin-left: auto;        /* 水平居中 */ | ||||
|     margin-right: auto; | ||||
| } | ||||
|  | ||||
| .stat-card { | ||||
|     background: linear-gradient(135deg, #2185d0 0%, #1678c2 100%) !important; | ||||
|     color: white !important; | ||||
|     box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important; | ||||
|     transition: transform 0.2s ease !important; | ||||
|     margin-bottom: 1rem !important; | ||||
| } | ||||
|  | ||||
| .stat-card:hover { | ||||
|     transform: translateY(-5px); | ||||
| } | ||||
|  | ||||
| .stat-card .statistic { | ||||
|     color: white !important; | ||||
| } | ||||
|  | ||||
| .charts-grid { | ||||
|     margin-bottom: 1rem !important; | ||||
| } | ||||
|  | ||||
| .charts-grid .column { | ||||
|     padding: 0.5rem !important; | ||||
| } | ||||
|  | ||||
| .chart-card { | ||||
|     height: 100%; | ||||
|     box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04) !important; | ||||
|     border: none !important; | ||||
|     border-radius: 16px !important; | ||||
|     padding-top: 8px!important; | ||||
| } | ||||
|  | ||||
| .chart-container { | ||||
|     margin-top: 2px; | ||||
|     padding: 16px; | ||||
|     background-color: white; | ||||
|     border-radius: 12px; | ||||
| } | ||||
|  | ||||
| .ui.card > .content > .header { | ||||
|     color: #2B3674; | ||||
|     font-size: 1.2em; | ||||
|     margin-bottom: 15px; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     font-weight: 600; | ||||
|     gap: 12px; /* 增加标题和数值之间的间距 */ | ||||
| } | ||||
|  | ||||
| .stat-value { | ||||
|     color: #4318FF; | ||||
|     font-weight: bold; | ||||
|     font-size: 1.1em; | ||||
|     background: rgba(67, 24, 255, 0.1); | ||||
|     padding: 4px 12px; | ||||
|     border-radius: 8px; | ||||
|     white-space: nowrap; /* 防止数值换行 */ | ||||
|     margin-left: 16px; | ||||
| } | ||||
|  | ||||
| /* 优化图表响应式布局 */ | ||||
| @media (max-width: 768px) { | ||||
|     .dashboard-container { | ||||
|         padding: 10px 16px;   /* 移动端也相应减小内边距 */ | ||||
|         max-width: 100%;      /* 移动端占满全宽 */ | ||||
|     } | ||||
|      | ||||
|     .chart-container { | ||||
|         padding: 12px; | ||||
|     } | ||||
|      | ||||
|     .charts-grid .column { | ||||
|         padding: 0.25rem !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* 设置页面的 Tab 样式 */ | ||||
| .settings-tab { | ||||
|     margin-top: 1rem !important; | ||||
|     border-bottom: none !important; | ||||
| } | ||||
|  | ||||
| .settings-tab .item { | ||||
|     color: #2B3674 !important; | ||||
|     font-weight: 500 !important; | ||||
|     padding: 0.8rem 1.2rem !important; | ||||
| } | ||||
|  | ||||
| .settings-tab .active.item { | ||||
|     color: #4318FF !important; | ||||
|     font-weight: 600 !important; | ||||
|     border-color: #4318FF !important; | ||||
| } | ||||
|  | ||||
| .ui.tab.segment { | ||||
|     border: none !important; | ||||
|     box-shadow: none !important; | ||||
|     padding: 1rem 0 !important; | ||||
| }  | ||||
							
								
								
									
										389
									
								
								web/default/src/pages/Dashboard/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										389
									
								
								web/default/src/pages/Dashboard/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,389 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Card, Grid, Statistic } from 'semantic-ui-react'; | ||||
| import { | ||||
|   LineChart, | ||||
|   Line, | ||||
|   XAxis, | ||||
|   YAxis, | ||||
|   CartesianGrid, | ||||
|   Tooltip, | ||||
|   ResponsiveContainer, | ||||
|   BarChart, | ||||
|   Bar, | ||||
|   Legend, | ||||
| } from 'recharts'; | ||||
| import axios from 'axios'; | ||||
| import './Dashboard.css'; | ||||
|  | ||||
| // 在 Dashboard 组件内添加自定义配置 | ||||
| const chartConfig = { | ||||
|   lineChart: { | ||||
|     style: { | ||||
|       background: '#fff', | ||||
|       borderRadius: '8px', | ||||
|     }, | ||||
|     line: { | ||||
|       strokeWidth: 2, | ||||
|       dot: false, | ||||
|       activeDot: { r: 4 }, | ||||
|     }, | ||||
|     grid: { | ||||
|       vertical: false, | ||||
|       horizontal: true, | ||||
|       opacity: 0.1, | ||||
|     }, | ||||
|   }, | ||||
|   colors: { | ||||
|     requests: '#4318FF', | ||||
|     quota: '#00B5D8', | ||||
|     tokens: '#6C63FF', | ||||
|   }, | ||||
|   barColors: [ | ||||
|     '#4318FF', // 深紫色 | ||||
|     '#00B5D8', // 青色 | ||||
|     '#6C63FF', // 紫色 | ||||
|     '#05CD99', // 绿色 | ||||
|     '#FFB547', // 橙色 | ||||
|     '#FF5E7D', // 粉色 | ||||
|     '#41B883', // 翠绿 | ||||
|     '#7983FF', // 淡紫 | ||||
|     '#FF8F6B', // 珊瑚色 | ||||
|     '#49BEFF', // 天蓝 | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| const Dashboard = () => { | ||||
|   const [data, setData] = useState([]); | ||||
|   const [summaryData, setSummaryData] = useState({ | ||||
|     todayRequests: 0, | ||||
|     todayQuota: 0, | ||||
|     todayTokens: 0, | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     fetchDashboardData(); | ||||
|   }, []); | ||||
|  | ||||
|   const fetchDashboardData = async () => { | ||||
|     try { | ||||
|       const response = await axios.get('/api/user/dashboard'); | ||||
|       if (response.data.success) { | ||||
|         const dashboardData = response.data.data || []; | ||||
|         setData(dashboardData); | ||||
|         calculateSummary(dashboardData); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Failed to fetch dashboard data:', error); | ||||
|       setData([]); | ||||
|       calculateSummary([]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const calculateSummary = (dashboardData) => { | ||||
|     if (!Array.isArray(dashboardData) || dashboardData.length === 0) { | ||||
|       setSummaryData({ | ||||
|         todayRequests: 0, | ||||
|         todayQuota: 0, | ||||
|         todayTokens: 0 | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const today = new Date().toISOString().split('T')[0]; | ||||
|     const todayData = dashboardData.filter((item) => item.Day === today); | ||||
|  | ||||
|     const summary = { | ||||
|       todayRequests: todayData.reduce( | ||||
|         (sum, item) => sum + item.RequestCount, | ||||
|         0 | ||||
|       ), | ||||
|       todayQuota: | ||||
|         todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000, | ||||
|       todayTokens: todayData.reduce( | ||||
|         (sum, item) => sum + item.PromptTokens + item.CompletionTokens, | ||||
|         0 | ||||
|       ), | ||||
|     }; | ||||
|  | ||||
|     setSummaryData(summary); | ||||
|   }; | ||||
|  | ||||
|   // 处理数据以供折线图使用,补充缺失的日期 | ||||
|   const processTimeSeriesData = () => { | ||||
|     const dailyData = {}; | ||||
|  | ||||
|     // 获取日期范围 | ||||
|     const dates = data.map((item) => item.Day); | ||||
|     const minDate = new Date(Math.min(...dates.map((d) => new Date(d)))); | ||||
|     const maxDate = new Date(Math.max(...dates.map((d) => new Date(d)))); | ||||
|  | ||||
|     // 生成所有日期 | ||||
|     for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) { | ||||
|       const dateStr = d.toISOString().split('T')[0]; | ||||
|       dailyData[dateStr] = { | ||||
|         date: dateStr, | ||||
|         requests: 0, | ||||
|         quota: 0, | ||||
|         tokens: 0, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // 填充实际数据 | ||||
|     data.forEach((item) => { | ||||
|       dailyData[item.Day].requests += item.RequestCount; | ||||
|       dailyData[item.Day].quota += item.Quota / 1000000; | ||||
|       dailyData[item.Day].tokens += item.PromptTokens + item.CompletionTokens; | ||||
|     }); | ||||
|  | ||||
|     return Object.values(dailyData).sort((a, b) => | ||||
|       a.date.localeCompare(b.date) | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   // 处理数据以供堆叠柱状图使用 | ||||
|   const processModelData = () => { | ||||
|     const timeData = {}; | ||||
|  | ||||
|     // 获取日期范围 | ||||
|     const dates = data.map((item) => item.Day); | ||||
|     const minDate = new Date(Math.min(...dates.map((d) => new Date(d)))); | ||||
|     const maxDate = new Date(Math.max(...dates.map((d) => new Date(d)))); | ||||
|  | ||||
|     // 生成所有日期 | ||||
|     for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) { | ||||
|       const dateStr = d.toISOString().split('T')[0]; | ||||
|       timeData[dateStr] = { | ||||
|         date: dateStr, | ||||
|       }; | ||||
|  | ||||
|       // 初始化所有模型的数据为0 | ||||
|       const models = [...new Set(data.map((item) => item.ModelName))]; | ||||
|       models.forEach((model) => { | ||||
|         timeData[dateStr][model] = 0; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // 填充实际数据 | ||||
|     data.forEach((item) => { | ||||
|       timeData[item.Day][item.ModelName] = | ||||
|         item.PromptTokens + item.CompletionTokens; | ||||
|     }); | ||||
|  | ||||
|     return Object.values(timeData).sort((a, b) => a.date.localeCompare(b.date)); | ||||
|   }; | ||||
|  | ||||
|   // 获取所有唯一的模型名称 | ||||
|   const getUniqueModels = () => { | ||||
|     return [...new Set(data.map((item) => item.ModelName))]; | ||||
|   }; | ||||
|  | ||||
|   const timeSeriesData = processTimeSeriesData(); | ||||
|   const modelData = processModelData(); | ||||
|   const models = getUniqueModels(); | ||||
|  | ||||
|   // 生成随机颜色 | ||||
|   const getRandomColor = (index) => { | ||||
|     return chartConfig.barColors[index % chartConfig.barColors.length]; | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       {/* 三个并排的折线图 */} | ||||
|       <Grid columns={3} stackable className='charts-grid'> | ||||
|         <Grid.Column> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 模型请求趋势 | ||||
|                 <span className='stat-value'>{summaryData.todayRequests}</span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
|                       vertical={chartConfig.lineChart.grid.vertical} | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
|                         background: '#fff', | ||||
|                         border: 'none', | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
|                       dataKey='requests' | ||||
|                       stroke={chartConfig.colors.requests} | ||||
|                       strokeWidth={chartConfig.lineChart.line.strokeWidth} | ||||
|                       dot={chartConfig.lineChart.line.dot} | ||||
|                       activeDot={chartConfig.lineChart.line.activeDot} | ||||
|                     /> | ||||
|                   </LineChart> | ||||
|                 </ResponsiveContainer> | ||||
|               </div> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|         </Grid.Column> | ||||
|  | ||||
|         <Grid.Column> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 额度消费趋势 | ||||
|                 <span className='stat-value'> | ||||
|                   ${summaryData.todayQuota.toFixed(3)} | ||||
|                 </span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
|                       vertical={chartConfig.lineChart.grid.vertical} | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
|                         background: '#fff', | ||||
|                         border: 'none', | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
|                       dataKey='quota' | ||||
|                       stroke={chartConfig.colors.quota} | ||||
|                       strokeWidth={chartConfig.lineChart.line.strokeWidth} | ||||
|                       dot={chartConfig.lineChart.line.dot} | ||||
|                       activeDot={chartConfig.lineChart.line.activeDot} | ||||
|                     /> | ||||
|                   </LineChart> | ||||
|                 </ResponsiveContainer> | ||||
|               </div> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|         </Grid.Column> | ||||
|  | ||||
|         <Grid.Column> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 Token 消费趋势 | ||||
|                 <span className='stat-value'>{summaryData.todayTokens}</span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
|                       vertical={chartConfig.lineChart.grid.vertical} | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
|                         background: '#fff', | ||||
|                         border: 'none', | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
|                       dataKey='tokens' | ||||
|                       stroke={chartConfig.colors.tokens} | ||||
|                       strokeWidth={chartConfig.lineChart.line.strokeWidth} | ||||
|                       dot={chartConfig.lineChart.line.dot} | ||||
|                       activeDot={chartConfig.lineChart.line.activeDot} | ||||
|                     /> | ||||
|                   </LineChart> | ||||
|                 </ResponsiveContainer> | ||||
|               </div> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|         </Grid.Column> | ||||
|       </Grid> | ||||
|  | ||||
|       {/* 模型使用统计 */} | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header>统计</Card.Header> | ||||
|           <div className='chart-container'> | ||||
|             <ResponsiveContainer width='100%' height={300}> | ||||
|               <BarChart data={modelData}> | ||||
|                 <CartesianGrid | ||||
|                   strokeDasharray='3 3' | ||||
|                   vertical={false} | ||||
|                   opacity={0.1} | ||||
|                 /> | ||||
|                 <XAxis | ||||
|                   dataKey='date' | ||||
|                   axisLine={false} | ||||
|                   tickLine={false} | ||||
|                   tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                 /> | ||||
|                 <YAxis | ||||
|                   axisLine={false} | ||||
|                   tickLine={false} | ||||
|                   tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                 /> | ||||
|                 <Tooltip | ||||
|                   contentStyle={{ | ||||
|                     background: '#fff', | ||||
|                     border: 'none', | ||||
|                     borderRadius: '4px', | ||||
|                     boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                   }} | ||||
|                 /> | ||||
|                 <Legend | ||||
|                   wrapperStyle={{ | ||||
|                     paddingTop: '20px', | ||||
|                   }} | ||||
|                 /> | ||||
|                 {models.map((model, index) => ( | ||||
|                   <Bar | ||||
|                     key={model} | ||||
|                     dataKey={model} | ||||
|                     stackId='a' | ||||
|                     fill={getRandomColor(index)} | ||||
|                     name={model} | ||||
|                     radius={[4, 4, 0, 0]} | ||||
|                   /> | ||||
|                 ))} | ||||
|               </BarChart> | ||||
|             </ResponsiveContainer> | ||||
|           </div> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Dashboard; | ||||
| @@ -3,22 +3,25 @@ import { Card, Grid, Header, Segment } from 'semantic-ui-react'; | ||||
| import { API, showError, showNotice, timestamp2string } from '../../helpers'; | ||||
| import { StatusContext } from '../../context/Status'; | ||||
| import { marked } from 'marked'; | ||||
| import { UserContext } from '../../context/User'; | ||||
| import { Link } from 'react-router-dom'; | ||||
|  | ||||
| const Home = () => { | ||||
|   const [statusState, statusDispatch] = useContext(StatusContext); | ||||
|   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false); | ||||
|   const [homePageContent, setHomePageContent] = useState(''); | ||||
|   const [userState] = useContext(UserContext); | ||||
|  | ||||
|   const displayNotice = async () => { | ||||
|     const res = await API.get('/api/notice'); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       let oldNotice = localStorage.getItem('notice'); | ||||
|         if (data !== oldNotice && data !== '') { | ||||
|             const htmlNotice = marked(data); | ||||
|             showNotice(htmlNotice, true); | ||||
|             localStorage.setItem('notice', data); | ||||
|         } | ||||
|       if (data !== oldNotice && data !== '') { | ||||
|         const htmlNotice = marked(data); | ||||
|         showNotice(htmlNotice, true); | ||||
|         localStorage.setItem('notice', data); | ||||
|       } | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -51,81 +54,239 @@ const Home = () => { | ||||
|     displayNotice().then(); | ||||
|     displayHomePageContent().then(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       { | ||||
|         homePageContentLoaded && homePageContent === '' ? <> | ||||
|           <Segment> | ||||
|             <Header as='h3'>系统状况</Header> | ||||
|             <Grid columns={2} stackable> | ||||
|               <Grid.Column> | ||||
|                 <Card fluid> | ||||
|                   <Card.Content> | ||||
|                     <Card.Header>系统信息</Card.Header> | ||||
|                     <Card.Meta>系统信息总览</Card.Meta> | ||||
|                     <Card.Description> | ||||
|                       <p>名称:{statusState?.status?.system_name}</p> | ||||
|                       <p>版本:{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p> | ||||
|                       <p> | ||||
|                         源码: | ||||
|                         <a | ||||
|                           href='https://github.com/songquanpeng/one-api' | ||||
|                           target='_blank' | ||||
|       {homePageContentLoaded && homePageContent === '' ? ( | ||||
|         <div className='dashboard-container'> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header className='header'>欢迎使用 One API</Card.Header> | ||||
|               <Card.Description style={{ lineHeight: '1.6' }}> | ||||
|                 <p> | ||||
|                   One API 是一个 LLM API | ||||
|                   接口管理和分发系统,可以帮助您更好地管理和使用各大厂商的 LLM | ||||
|                   API。 | ||||
|                 </p> | ||||
|                 {!userState.user && ( | ||||
|                   <p> | ||||
|                     如需使用,请先<Link to='/login'>登录</Link>或 | ||||
|                     <Link to='/register'>注册</Link>。 | ||||
|                   </p> | ||||
|                 )} | ||||
|               </Card.Description> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 <Header as='h3'>系统状况</Header> | ||||
|               </Card.Header> | ||||
|               <Grid columns={2} stackable> | ||||
|                 <Grid.Column> | ||||
|                   <Card | ||||
|                     fluid | ||||
|                     className='chart-card' | ||||
|                     style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|                   > | ||||
|                     <Card.Content> | ||||
|                       <Card.Header> | ||||
|                         <Header as='h3' style={{ color: '#444' }}> | ||||
|                           系统信息 | ||||
|                         </Header> | ||||
|                       </Card.Header> | ||||
|                       <Card.Description | ||||
|                         style={{ lineHeight: '2', marginTop: '1em' }} | ||||
|                       > | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           https://github.com/songquanpeng/one-api | ||||
|                         </a> | ||||
|                       </p> | ||||
|                       <p>启动时间:{getStartTimeString()}</p> | ||||
|                     </Card.Description> | ||||
|                   </Card.Content> | ||||
|                 </Card> | ||||
|               </Grid.Column> | ||||
|               <Grid.Column> | ||||
|                 <Card fluid> | ||||
|                   <Card.Content> | ||||
|                     <Card.Header>系统配置</Card.Header> | ||||
|                     <Card.Meta>系统配置总览</Card.Meta> | ||||
|                     <Card.Description> | ||||
|                       <p> | ||||
|                         邮箱验证: | ||||
|                         {statusState?.status?.email_verification === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         GitHub 身份验证: | ||||
|                         {statusState?.status?.github_oauth === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         微信身份验证: | ||||
|                         {statusState?.status?.wechat_login === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         Turnstile 用户校验: | ||||
|                         {statusState?.status?.turnstile_check === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                     </Card.Description> | ||||
|                   </Card.Content> | ||||
|                 </Card> | ||||
|               </Grid.Column> | ||||
|             </Grid> | ||||
|           </Segment> | ||||
|         </> : <> | ||||
|           { | ||||
|             homePageContent.startsWith('https://') ? <iframe | ||||
|                           <i className='info circle icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}>名称:</span> | ||||
|                           <span>{statusState?.status?.system_name}</span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='code branch icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}>版本:</span> | ||||
|                           <span> | ||||
|                             {statusState?.status?.version || 'unknown'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='github icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}>源码:</span> | ||||
|                           <a | ||||
|                             href='https://github.com/songquanpeng/one-api' | ||||
|                             target='_blank' | ||||
|                             style={{ color: '#2185d0' }} | ||||
|                           > | ||||
|                             GitHub 仓库 | ||||
|                           </a> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='clock outline icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}>启动时间:</span> | ||||
|                           <span>{getStartTimeString()}</span> | ||||
|                         </p> | ||||
|                       </Card.Description> | ||||
|                     </Card.Content> | ||||
|                   </Card> | ||||
|                 </Grid.Column> | ||||
|  | ||||
|                 <Grid.Column> | ||||
|                   <Card | ||||
|                     fluid | ||||
|                     className='chart-card' | ||||
|                     style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|                   > | ||||
|                     <Card.Content> | ||||
|                       <Card.Header> | ||||
|                         <Header as='h3' style={{ color: '#444' }}> | ||||
|                           系统配置 | ||||
|                         </Header> | ||||
|                       </Card.Header> | ||||
|                       <Card.Description | ||||
|                         style={{ lineHeight: '2', marginTop: '1em' }} | ||||
|                       > | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='envelope icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}>邮箱验证:</span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.email_verification | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.email_verification | ||||
|                               ? '已启用' | ||||
|                               : '未启用'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='github icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             GitHub 身份验证: | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.github_oauth | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.github_oauth | ||||
|                               ? '已启用' | ||||
|                               : '未启用'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='wechat icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             微信身份验证: | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.wechat_login | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.wechat_login | ||||
|                               ? '已启用' | ||||
|                               : '未启用'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='shield alternate icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             Turnstile 校验: | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.turnstile_check | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.turnstile_check | ||||
|                               ? '已启用' | ||||
|                               : '未启用'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                       </Card.Description> | ||||
|                     </Card.Content> | ||||
|                   </Card> | ||||
|                 </Grid.Column> | ||||
|               </Grid> | ||||
|             </Card.Content> | ||||
|           </Card>{' '} | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <> | ||||
|           {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,11 +1,16 @@ | ||||
| import React from 'react'; | ||||
| import { Header, Segment } from 'semantic-ui-react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import LogsTable from '../../components/LogsTable'; | ||||
|  | ||||
| const Token = () => ( | ||||
|   <> | ||||
|     <LogsTable /> | ||||
|   </> | ||||
| const Log = () => ( | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         {/*<Card.Header className='header'>操作日志</Card.Header>*/} | ||||
|         <LogsTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| export default Token; | ||||
| export default Log; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Segment } from 'semantic-ui-react'; | ||||
| import { Button, Form, Card } from 'semantic-ui-react'; | ||||
| import { useParams, useNavigate } from 'react-router-dom'; | ||||
| import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers'; | ||||
| import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; | ||||
| @@ -13,7 +13,7 @@ const EditRedemption = () => { | ||||
|   const originInputs = { | ||||
|     name: '', | ||||
|     quota: 100000, | ||||
|     count: 1 | ||||
|     count: 1, | ||||
|   }; | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
|   const { name, quota, count } = inputs; | ||||
| @@ -21,7 +21,7 @@ const EditRedemption = () => { | ||||
|   const handleCancel = () => { | ||||
|     navigate('/redemption'); | ||||
|   }; | ||||
|    | ||||
|  | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
| @@ -49,10 +49,13 @@ const EditRedemption = () => { | ||||
|     localInputs.quota = parseInt(localInputs.quota); | ||||
|     let res; | ||||
|     if (isEdit) { | ||||
|       res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) }); | ||||
|       res = await API.put(`/api/redemption/`, { | ||||
|         ...localInputs, | ||||
|         id: parseInt(redemptionId), | ||||
|       }); | ||||
|     } else { | ||||
|       res = await API.post(`/api/redemption/`, { | ||||
|         ...localInputs | ||||
|         ...localInputs, | ||||
|       }); | ||||
|     } | ||||
|     const { success, message, data } = res.data; | ||||
| @@ -67,61 +70,67 @@ const EditRedemption = () => { | ||||
|       showError(message); | ||||
|     } | ||||
|     if (!isEdit && data) { | ||||
|       let text = ""; | ||||
|       let text = ''; | ||||
|       for (let i = 0; i < data.length; i++) { | ||||
|         text += data[i] + "\n"; | ||||
|         text += data[i] + '\n'; | ||||
|       } | ||||
|       downloadTextAsFile(text, `${inputs.name}.txt`); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment loading={loading}> | ||||
|         <Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header> | ||||
|         <Form autoComplete='new-password'> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='名称' | ||||
|               name='name' | ||||
|               placeholder={'请输入名称'} | ||||
|               onChange={handleInputChange} | ||||
|               value={name} | ||||
|               autoComplete='new-password' | ||||
|               required={!isEdit} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label={`额度${renderQuotaWithPrompt(quota)}`} | ||||
|               name='quota' | ||||
|               placeholder={'请输入单个兑换码中包含的额度'} | ||||
|               onChange={handleInputChange} | ||||
|               value={quota} | ||||
|               autoComplete='new-password' | ||||
|               type='number' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           { | ||||
|             !isEdit && <> | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='生成数量' | ||||
|                   name='count' | ||||
|                   placeholder={'请输入生成数量'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={count} | ||||
|                   autoComplete='new-password' | ||||
|                   type='number' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             </> | ||||
|           } | ||||
|           <Button positive onClick={submit}>提交</Button> | ||||
|           <Button onClick={handleCancel}>取消</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新兑换码信息' : '创建新的兑换码'} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='名称' | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={name} | ||||
|                 autoComplete='new-password' | ||||
|                 required={!isEdit} | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={`额度${renderQuotaWithPrompt(quota)}`} | ||||
|                 name='quota' | ||||
|                 placeholder={'请输入单个兑换码中包含的额度'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={quota} | ||||
|                 autoComplete='new-password' | ||||
|                 type='number' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             {!isEdit && ( | ||||
|               <> | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='生成数量' | ||||
|                     name='count' | ||||
|                     placeholder={'请输入生成数量'} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={count} | ||||
|                     autoComplete='new-password' | ||||
|                     type='number' | ||||
|                   /> | ||||
|                 </Form.Field> | ||||
|               </> | ||||
|             )} | ||||
|             <Button positive onClick={submit}> | ||||
|               提交 | ||||
|             </Button> | ||||
|             <Button onClick={handleCancel}>取消</Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| import React from 'react'; | ||||
| import { Segment, Header } from 'semantic-ui-react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import RedemptionsTable from '../../components/RedemptionsTable'; | ||||
|  | ||||
| const Redemption = () => ( | ||||
|   <> | ||||
|     <Segment> | ||||
|       <Header as='h3'>管理兑换码</Header> | ||||
|       <RedemptionsTable/> | ||||
|     </Segment> | ||||
|   </> | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>兑换管理</Card.Header> | ||||
|         <RedemptionsTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| export default Redemption; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React from 'react'; | ||||
| import { Segment, Tab } from 'semantic-ui-react'; | ||||
| import { Card, Tab } from 'semantic-ui-react'; | ||||
| import SystemSetting from '../../components/SystemSetting'; | ||||
| import { isRoot } from '../../helpers'; | ||||
| import OtherSetting from '../../components/OtherSetting'; | ||||
| @@ -14,8 +14,8 @@ const Setting = () => { | ||||
|         <Tab.Pane attached={false}> | ||||
|           <PersonalSetting /> | ||||
|         </Tab.Pane> | ||||
|       ) | ||||
|     } | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   if (isRoot()) { | ||||
| @@ -25,7 +25,7 @@ const Setting = () => { | ||||
|         <Tab.Pane attached={false}> | ||||
|           <OperationSetting /> | ||||
|         </Tab.Pane> | ||||
|       ) | ||||
|       ), | ||||
|     }); | ||||
|     panes.push({ | ||||
|       menuItem: '系统设置', | ||||
| @@ -33,7 +33,7 @@ const Setting = () => { | ||||
|         <Tab.Pane attached={false}> | ||||
|           <SystemSetting /> | ||||
|         </Tab.Pane> | ||||
|       ) | ||||
|       ), | ||||
|     }); | ||||
|     panes.push({ | ||||
|       menuItem: '其他设置', | ||||
| @@ -41,14 +41,26 @@ const Setting = () => { | ||||
|         <Tab.Pane attached={false}> | ||||
|           <OtherSetting /> | ||||
|         </Tab.Pane> | ||||
|       ) | ||||
|       ), | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Segment> | ||||
|       <Tab menu={{ secondary: true, pointing: true }} panes={panes} /> | ||||
|     </Segment> | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>系统设置</Card.Header> | ||||
|           <Tab | ||||
|             menu={{ | ||||
|               secondary: true, | ||||
|               pointing: true, | ||||
|               className: 'settings-tab', // 添加自定义类名以便样式化 | ||||
|             }} | ||||
|             panes={panes} | ||||
|           /> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,20 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Header, | ||||
|   Message, | ||||
|   Segment, | ||||
|   Card, | ||||
| } from 'semantic-ui-react'; | ||||
| import { useNavigate, useParams } from 'react-router-dom'; | ||||
| import { API, copy, showError, showSuccess, timestamp2string } from '../../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showSuccess, | ||||
|   timestamp2string, | ||||
| } from '../../helpers'; | ||||
| import { renderQuotaWithPrompt } from '../../helpers/render'; | ||||
|  | ||||
| const EditToken = () => { | ||||
| @@ -16,7 +29,7 @@ const EditToken = () => { | ||||
|     expired_time: -1, | ||||
|     unlimited_quota: false, | ||||
|     models: [], | ||||
|     subnet: "", | ||||
|     subnet: '', | ||||
|   }; | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
|   const { name, remain_quota, expired_time, unlimited_quota } = inputs; | ||||
| @@ -79,7 +92,7 @@ const EditToken = () => { | ||||
|         return { | ||||
|           key: model, | ||||
|           text: model, | ||||
|           value: model | ||||
|           value: model, | ||||
|         }; | ||||
|       }); | ||||
|       setModelOptions(options); | ||||
| @@ -103,7 +116,10 @@ const EditToken = () => { | ||||
|     localInputs.models = localInputs.models.join(','); | ||||
|     let res; | ||||
|     if (isEdit) { | ||||
|       res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) }); | ||||
|       res = await API.put(`/api/token/`, { | ||||
|         ...localInputs, | ||||
|         id: parseInt(tokenId), | ||||
|       }); | ||||
|     } else { | ||||
|       res = await API.post(`/api/token/`, localInputs); | ||||
|     } | ||||
| @@ -121,98 +137,142 @@ const EditToken = () => { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment loading={loading}> | ||||
|         <Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header> | ||||
|         <Form autoComplete='new-password'> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='名称' | ||||
|               name='name' | ||||
|               placeholder={'请输入名称'} | ||||
|               onChange={handleInputChange} | ||||
|               value={name} | ||||
|               autoComplete='new-password' | ||||
|               required={!isEdit} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='模型范围' | ||||
|               placeholder={'请选择允许使用的模型,留空则不进行限制'} | ||||
|               name='models' | ||||
|               fluid | ||||
|               multiple | ||||
|               search | ||||
|               onLabelClick={(e, { value }) => { | ||||
|                 copy(value).then(); | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新令牌信息' : '创建新的令牌'} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='名称' | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={name} | ||||
|                 autoComplete='new-password' | ||||
|                 required={!isEdit} | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Dropdown | ||||
|                 label='模型范围' | ||||
|                 placeholder={'请选择允许使用的模型,留空则不进行限制'} | ||||
|                 name='models' | ||||
|                 fluid | ||||
|                 multiple | ||||
|                 search | ||||
|                 onLabelClick={(e, { value }) => { | ||||
|                   copy(value).then(); | ||||
|                 }} | ||||
|                 selection | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.models} | ||||
|                 autoComplete='new-password' | ||||
|                 options={modelOptions} | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='IP 限制' | ||||
|                 name='subnet' | ||||
|                 placeholder={ | ||||
|                   '请输入允许访问的网段,例如:192.168.0.0/24,请使用英文逗号分隔多个网段' | ||||
|                 } | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.subnet} | ||||
|                 autoComplete='new-password' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='过期时间' | ||||
|                 name='expired_time' | ||||
|                 placeholder={ | ||||
|                   '请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制' | ||||
|                 } | ||||
|                 onChange={handleInputChange} | ||||
|                 value={expired_time} | ||||
|                 autoComplete='new-password' | ||||
|                 type='datetime-local' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <div style={{ lineHeight: '40px' }}> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
|                 onClick={() => { | ||||
|                   setExpiredTime(0, 0, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 永不过期 | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
|                 onClick={() => { | ||||
|                   setExpiredTime(1, 0, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一个月后过期 | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
|                 onClick={() => { | ||||
|                   setExpiredTime(0, 1, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一天后过期 | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
|                 onClick={() => { | ||||
|                   setExpiredTime(0, 0, 1, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一小时后过期 | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
|                 onClick={() => { | ||||
|                   setExpiredTime(0, 0, 0, 1); | ||||
|                 }} | ||||
|               > | ||||
|                 一分钟后过期 | ||||
|               </Button> | ||||
|             </div> | ||||
|             <Message> | ||||
|               注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。 | ||||
|             </Message> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={`额度${renderQuotaWithPrompt(remain_quota)}`} | ||||
|                 name='remain_quota' | ||||
|                 placeholder={'请输入额度'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={remain_quota} | ||||
|                 autoComplete='new-password' | ||||
|                 type='number' | ||||
|                 disabled={unlimited_quota} | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Button | ||||
|               type={'button'} | ||||
|               onClick={() => { | ||||
|                 setUnlimitedQuota(); | ||||
|               }} | ||||
|               selection | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.models} | ||||
|               autoComplete='new-password' | ||||
|               options={modelOptions} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='IP 限制' | ||||
|               name='subnet' | ||||
|               placeholder={'请输入允许访问的网段,例如:192.168.0.0/24,请使用英文逗号分隔多个网段'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.subnet} | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='过期时间' | ||||
|               name='expired_time' | ||||
|               placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制'} | ||||
|               onChange={handleInputChange} | ||||
|               value={expired_time} | ||||
|               autoComplete='new-password' | ||||
|               type='datetime-local' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <div style={{ lineHeight: '40px' }}> | ||||
|             <Button type={'button'} onClick={() => { | ||||
|               setExpiredTime(0, 0, 0, 0); | ||||
|             }}>永不过期</Button> | ||||
|             <Button type={'button'} onClick={() => { | ||||
|               setExpiredTime(1, 0, 0, 0); | ||||
|             }}>一个月后过期</Button> | ||||
|             <Button type={'button'} onClick={() => { | ||||
|               setExpiredTime(0, 1, 0, 0); | ||||
|             }}>一天后过期</Button> | ||||
|             <Button type={'button'} onClick={() => { | ||||
|               setExpiredTime(0, 0, 1, 0); | ||||
|             }}>一小时后过期</Button> | ||||
|             <Button type={'button'} onClick={() => { | ||||
|               setExpiredTime(0, 0, 0, 1); | ||||
|             }}>一分钟后过期</Button> | ||||
|           </div> | ||||
|           <Message>注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。</Message> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label={`额度${renderQuotaWithPrompt(remain_quota)}`} | ||||
|               name='remain_quota' | ||||
|               placeholder={'请输入额度'} | ||||
|               onChange={handleInputChange} | ||||
|               value={remain_quota} | ||||
|               autoComplete='new-password' | ||||
|               type='number' | ||||
|               disabled={unlimited_quota} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Button type={'button'} onClick={() => { | ||||
|             setUnlimitedQuota(); | ||||
|           }}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button> | ||||
|           <Button floated='right' positive onClick={submit}>提交</Button> | ||||
|           <Button floated='right' onClick={handleCancel}>取消</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|             > | ||||
|               {unlimited_quota ? '取消无限额度' : '设为无限额度'} | ||||
|             </Button> | ||||
|             <Button floated='right' positive onClick={submit}> | ||||
|               提交 | ||||
|             </Button> | ||||
|             <Button floated='right' onClick={handleCancel}> | ||||
|               取消 | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| import React from 'react'; | ||||
| import { Segment, Header } from 'semantic-ui-react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import TokensTable from '../../components/TokensTable'; | ||||
|  | ||||
| const Token = () => ( | ||||
|   <> | ||||
|     <Segment> | ||||
|       <Header as='h3'>我的令牌</Header> | ||||
|       <TokensTable/> | ||||
|     </Segment> | ||||
|   </> | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>令牌管理</Card.Header> | ||||
|         <TokensTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| export default Token; | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user