1808837298@qq.com 1 год назад
Родитель
Сommit
9a4ca1e210

+ 40 - 0
controller/relay.go

@@ -38,6 +38,46 @@ func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
 	return err
 }
 
+func Playground(c *gin.Context) {
+	var openaiErr *dto.OpenAIErrorWithStatusCode
+
+	defer func() {
+		if openaiErr != nil {
+			c.JSON(openaiErr.StatusCode, gin.H{
+				"error": openaiErr.Error,
+			})
+		}
+	}()
+
+	playgroundRequest := &dto.PlayGroundRequest{}
+	err := common.UnmarshalBodyReusable(c, playgroundRequest)
+	if err != nil {
+		openaiErr = service.OpenAIErrorWrapperLocal(err, "unmarshal_request_failed", http.StatusBadRequest)
+		return
+	}
+
+	if playgroundRequest.Model == "" {
+		openaiErr = service.OpenAIErrorWrapperLocal(errors.New("请选择模型"), "model_required", http.StatusBadRequest)
+		return
+	}
+	c.Set("original_model", playgroundRequest.Model)
+	group := playgroundRequest.Group
+	if group == "" {
+		group = c.GetString("group")
+	} else {
+		c.Set("group", group)
+	}
+	log.Printf("group: %s", group)
+	log.Printf("model: %s", playgroundRequest.Model)
+	channel, err := model.CacheGetRandomSatisfiedChannel(group, playgroundRequest.Model, 0)
+	if err != nil {
+		openaiErr = service.OpenAIErrorWrapperLocal(err, "get_playground_channel_failed", http.StatusInternalServerError)
+		return
+	}
+	middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
+	Relay(c)
+}
+
 func Relay(c *gin.Context) {
 	relayMode := constant.Path2RelayMode(c.Request.URL.Path)
 	requestId := c.GetString(common.RequestIdKey)

+ 1 - 0
controller/user.go

@@ -68,6 +68,7 @@ func setupLogin(user *model.User, c *gin.Context) {
 	session.Set("username", user.Username)
 	session.Set("role", user.Role)
 	session.Set("status", user.Status)
+	session.Set("group", user.Group)
 	err := session.Save()
 	if err != nil {
 		c.JSON(http.StatusOK, gin.H{

+ 6 - 0
dto/playground.go

@@ -0,0 +1,6 @@
+package dto
+
+type PlayGroundRequest struct {
+	Model string `json:"model,omitempty"`
+	Group string `json:"group,omitempty"`
+}

+ 1 - 0
middleware/auth.go

@@ -121,6 +121,7 @@ func authHelper(c *gin.Context, minRole int) {
 	c.Set("username", username)
 	c.Set("role", role)
 	c.Set("id", id)
+	c.Set("group", session.Get("group"))
 	c.Next()
 }
 

+ 30 - 24
model/token.go

@@ -6,6 +6,7 @@ import (
 	"gorm.io/gorm"
 	"one-api/common"
 	"one-api/constant"
+	relaycommon "one-api/relay/common"
 	"strconv"
 	"strings"
 )
@@ -257,51 +258,56 @@ func decreaseTokenQuota(id int, quota int) (err error) {
 	return err
 }
 
-func PreConsumeTokenQuota(tokenId int, quota int) (userQuota int, err error) {
+func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) (userQuota int, err error) {
 	if quota < 0 {
 		return 0, errors.New("quota 不能为负数!")
 	}
-	token, err := GetTokenById(tokenId)
-	if err != nil {
-		return 0, err
-	}
-	if !token.UnlimitedQuota && token.RemainQuota < quota {
-		return 0, errors.New("令牌额度不足")
+	if !relayInfo.IsPlayground {
+		token, err := GetTokenById(relayInfo.TokenId)
+		if err != nil {
+			return 0, err
+		}
+		if !token.UnlimitedQuota && token.RemainQuota < quota {
+			return 0, errors.New("令牌额度不足")
+		}
 	}
-	userQuota, err = GetUserQuota(token.UserId)
+	userQuota, err = GetUserQuota(relayInfo.UserId)
 	if err != nil {
 		return 0, err
 	}
 	if userQuota < quota {
 		return 0, errors.New(fmt.Sprintf("用户额度不足,剩余额度为 %d", userQuota))
 	}
-	err = DecreaseTokenQuota(tokenId, quota)
-	if err != nil {
-		return 0, err
+	if !relayInfo.IsPlayground {
+		err = DecreaseTokenQuota(relayInfo.TokenId, quota)
+		if err != nil {
+			return 0, err
+		}
 	}
-	err = DecreaseUserQuota(token.UserId, quota)
+	err = DecreaseUserQuota(relayInfo.UserId, quota)
 	return userQuota - quota, err
 }
 
-func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuota int, sendEmail bool) (err error) {
-	token, err := GetTokenById(tokenId)
+func PostConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, userQuota int, quota int, preConsumedQuota int, sendEmail bool) (err error) {
 
 	if quota > 0 {
-		err = DecreaseUserQuota(token.UserId, quota)
+		err = DecreaseUserQuota(relayInfo.UserId, quota)
 	} else {
-		err = IncreaseUserQuota(token.UserId, -quota)
+		err = IncreaseUserQuota(relayInfo.UserId, -quota)
 	}
 	if err != nil {
 		return err
 	}
 
-	if quota > 0 {
-		err = DecreaseTokenQuota(tokenId, quota)
-	} else {
-		err = IncreaseTokenQuota(tokenId, -quota)
-	}
-	if err != nil {
-		return err
+	if !relayInfo.IsPlayground {
+		if quota > 0 {
+			err = DecreaseTokenQuota(relayInfo.TokenId, quota)
+		} else {
+			err = IncreaseTokenQuota(relayInfo.TokenId, -quota)
+		}
+		if err != nil {
+			return err
+		}
 	}
 
 	if sendEmail {
@@ -310,7 +316,7 @@ func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuo
 			noMoreQuota := userQuota-(quota+preConsumedQuota) <= 0
 			if quotaTooLow || noMoreQuota {
 				go func() {
-					email, err := GetUserEmail(token.UserId)
+					email, err := GetUserEmail(relayInfo.UserId)
 					if err != nil {
 						common.SysError("failed to fetch user email: " + err.Error())
 					}

+ 23 - 0
relay/common/relay_info.go

@@ -20,6 +20,7 @@ type RelayInfo struct {
 	setFirstResponse     bool
 	ApiType              int
 	IsStream             bool
+	IsPlayground         bool
 	RelayMode            int
 	UpstreamModelName    string
 	OriginModelName      string
@@ -65,6 +66,11 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
 		ApiKey:            strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "),
 		Organization:      c.GetString("channel_organization"),
 	}
+	if strings.HasPrefix(c.Request.URL.Path, "/pg") {
+		info.IsPlayground = true
+		info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg")
+		info.RequestURLPath = "/v1" + info.RequestURLPath
+	}
 	if info.BaseUrl == "" {
 		info.BaseUrl = common.ChannelBaseURLs[channelType]
 	}
@@ -146,3 +152,20 @@ func GenTaskRelayInfo(c *gin.Context) *TaskRelayInfo {
 	}
 	return info
 }
+
+func (info *TaskRelayInfo) ToRelayInfo() *RelayInfo {
+	return &RelayInfo{
+		ChannelType:       info.ChannelType,
+		ChannelId:         info.ChannelId,
+		TokenId:           info.TokenId,
+		UserId:            info.UserId,
+		Group:             info.Group,
+		StartTime:         info.StartTime,
+		ApiType:           info.ApiType,
+		RelayMode:         info.RelayMode,
+		UpstreamModelName: info.UpstreamModelName,
+		RequestURLPath:    info.RequestURLPath,
+		ApiKey:            info.ApiKey,
+		BaseUrl:           info.BaseUrl,
+	}
+}

+ 1 - 1
relay/constant/relay_mode.go

@@ -42,7 +42,7 @@ const (
 
 func Path2RelayMode(path string) int {
 	relayMode := RelayModeUnknown
-	if strings.HasPrefix(path, "/v1/chat/completions") {
+	if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/pg/chat/completions") {
 		relayMode = RelayModeChatCompletions
 	} else if strings.HasPrefix(path, "/v1/completions") {
 		relayMode = RelayModeCompletions

+ 3 - 3
relay/relay-audio.go

@@ -87,7 +87,7 @@ func AudioHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 		preConsumedQuota = 0
 	}
 	if preConsumedQuota > 0 {
-		userQuota, err = model.PreConsumeTokenQuota(relayInfo.TokenId, preConsumedQuota)
+		userQuota, err = model.PreConsumeTokenQuota(relayInfo, preConsumedQuota)
 		if err != nil {
 			return service.OpenAIErrorWrapperLocal(err, "pre_consume_token_quota_failed", http.StatusForbidden)
 		}
@@ -126,7 +126,7 @@ func AudioHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 	statusCodeMappingStr := c.GetString("status_code_mapping")
 	if resp != nil {
 		if resp.StatusCode != http.StatusOK {
-			returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+			returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 			openaiErr := service.RelayErrorHandler(resp)
 			// reset status code 重置状态码
 			service.ResetStatusCode(openaiErr, statusCodeMappingStr)
@@ -136,7 +136,7 @@ func AudioHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 
 	usage, openaiErr := adaptor.DoResponse(c, resp, relayInfo)
 	if openaiErr != nil {
-		returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+		returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 		// reset status code 重置状态码
 		service.ResetStatusCode(openaiErr, statusCodeMappingStr)
 		return openaiErr

+ 5 - 2
relay/relay-mj.go

@@ -12,6 +12,7 @@ import (
 	"one-api/constant"
 	"one-api/dto"
 	"one-api/model"
+	relaycommon "one-api/relay/common"
 	relayconstant "one-api/relay/constant"
 	"one-api/service"
 	"strconv"
@@ -146,6 +147,7 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
 	userId := c.GetInt("id")
 	group := c.GetString("group")
 	channelId := c.GetInt("channel_id")
+	relayInfo := relaycommon.GenRelayInfo(c)
 	var swapFaceRequest dto.SwapFaceRequest
 	err := common.UnmarshalBodyReusable(c, &swapFaceRequest)
 	if err != nil {
@@ -191,7 +193,7 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
 	}
 	defer func(ctx context.Context) {
 		if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 {
-			err := model.PostConsumeTokenQuota(tokenId, userQuota, quota, 0, true)
+			err := model.PostConsumeTokenQuota(relayInfo, userQuota, quota, 0, true)
 			if err != nil {
 				common.SysError("error consuming token remain quota: " + err.Error())
 			}
@@ -356,6 +358,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
 	userId := c.GetInt("id")
 	group := c.GetString("group")
 	channelId := c.GetInt("channel_id")
+	relayInfo := relaycommon.GenRelayInfo(c)
 	consumeQuota := true
 	var midjRequest dto.MidjourneyRequest
 	err := common.UnmarshalBodyReusable(c, &midjRequest)
@@ -495,7 +498,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
 
 	defer func(ctx context.Context) {
 		if consumeQuota && midjResponseWithStatus.StatusCode == 200 {
-			err := model.PostConsumeTokenQuota(tokenId, userQuota, quota, 0, true)
+			err := model.PostConsumeTokenQuota(relayInfo, userQuota, quota, 0, true)
 			if err != nil {
 				common.SysError("error consuming token remain quota: " + err.Error())
 			}

+ 6 - 6
relay/relay-text.go

@@ -178,7 +178,7 @@ func TextHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 	if resp != nil {
 		relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
 		if resp.StatusCode != http.StatusOK {
-			returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+			returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 			openaiErr := service.RelayErrorHandler(resp)
 			// reset status code 重置状态码
 			service.ResetStatusCode(openaiErr, statusCodeMappingStr)
@@ -188,7 +188,7 @@ func TextHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 
 	usage, openaiErr := adaptor.DoResponse(c, resp, relayInfo)
 	if openaiErr != nil {
-		returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+		returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 		// reset status code 重置状态码
 		service.ResetStatusCode(openaiErr, statusCodeMappingStr)
 		return openaiErr
@@ -266,7 +266,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 		}
 	}
 	if preConsumedQuota > 0 {
-		userQuota, err = model.PreConsumeTokenQuota(relayInfo.TokenId, preConsumedQuota)
+		userQuota, err = model.PreConsumeTokenQuota(relayInfo, preConsumedQuota)
 		if err != nil {
 			return 0, 0, service.OpenAIErrorWrapperLocal(err, "pre_consume_token_quota_failed", http.StatusForbidden)
 		}
@@ -274,11 +274,11 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 	return preConsumedQuota, userQuota, nil
 }
 
-func returnPreConsumedQuota(c *gin.Context, tokenId int, userQuota int, preConsumedQuota int) {
+func returnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, userQuota int, preConsumedQuota int) {
 	if preConsumedQuota != 0 {
 		go func(ctx context.Context) {
 			// return pre-consumed quota
-			err := model.PostConsumeTokenQuota(tokenId, userQuota, -preConsumedQuota, 0, false)
+			err := model.PostConsumeTokenQuota(relayInfo, userQuota, -preConsumedQuota, 0, false)
 			if err != nil {
 				common.SysError("error return pre-consumed quota: " + err.Error())
 			}
@@ -336,7 +336,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelN
 		//}
 		quotaDelta := quota - preConsumedQuota
 		if quotaDelta != 0 {
-			err := model.PostConsumeTokenQuota(relayInfo.TokenId, userQuota, quotaDelta, preConsumedQuota, true)
+			err := model.PostConsumeTokenQuota(relayInfo, userQuota, quotaDelta, preConsumedQuota, true)
 			if err != nil {
 				common.LogError(ctx, "error consuming token remain quota: "+err.Error())
 			}

+ 2 - 2
relay/relay_rerank.go

@@ -101,7 +101,7 @@ func RerankHelper(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
 	}
 	if resp != nil {
 		if resp.StatusCode != http.StatusOK {
-			returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+			returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 			openaiErr := service.RelayErrorHandler(resp)
 			// reset status code 重置状态码
 			service.ResetStatusCode(openaiErr, statusCodeMappingStr)
@@ -111,7 +111,7 @@ func RerankHelper(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
 
 	usage, openaiErr := adaptor.DoResponse(c, resp, relayInfo)
 	if openaiErr != nil {
-		returnPreConsumedQuota(c, relayInfo.TokenId, userQuota, preConsumedQuota)
+		returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 		// reset status code 重置状态码
 		service.ResetStatusCode(openaiErr, statusCodeMappingStr)
 		return openaiErr

+ 2 - 1
relay/relay_task.go

@@ -111,7 +111,8 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
 	defer func(ctx context.Context) {
 		// release quota
 		if relayInfo.ConsumeQuota && taskErr == nil {
-			err := model.PostConsumeTokenQuota(relayInfo.TokenId, userQuota, quota, 0, true)
+
+			err := model.PostConsumeTokenQuota(relayInfo.ToRelayInfo(), userQuota, quota, 0, true)
 			if err != nil {
 				common.SysError("error consuming token remain quota: " + err.Error())
 			}

+ 5 - 0
router/relay-router.go

@@ -16,6 +16,11 @@ func SetRelayRouter(router *gin.Engine) {
 		modelsRouter.GET("", controller.ListModels)
 		modelsRouter.GET("/:model", controller.RetrieveModel)
 	}
+	playgroundRouter := router.Group("/pg")
+	playgroundRouter.Use(middleware.UserAuth())
+	{
+		playgroundRouter.POST("/chat/completions", controller.Playground)
+	}
 	relayV1Router := router.Group("/v1")
 	relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
 	{

+ 4 - 3
web/package.json

@@ -4,8 +4,8 @@
   "private": true,
   "type": "module",
   "dependencies": {
-    "@douyinfe/semi-icons": "^2.46.1",
-    "@douyinfe/semi-ui": "^2.55.3",
+    "@douyinfe/semi-icons": "^2.63.1",
+    "@douyinfe/semi-ui": "^2.63.1",
     "@visactor/react-vchart": "~1.8.8",
     "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",
@@ -22,7 +22,8 @@
     "react-toastify": "^9.0.8",
     "react-turnstile": "^1.0.5",
     "semantic-ui-offline": "^2.5.0",
-    "semantic-ui-react": "^2.1.3"
+    "semantic-ui-react": "^2.1.3",
+    "sse": "github:mpetazzoni/sse.js"
   },
   "scripts": {
     "dev": "vite",

Разница между файлами не показана из-за своего большого размера
+ 722 - 1513
web/pnpm-lock.yaml


+ 9 - 0
web/src/App.js

@@ -25,6 +25,7 @@ import { Layout } from '@douyinfe/semi-ui';
 import Midjourney from './pages/Midjourney';
 import Pricing from './pages/Pricing/index.js';
 import Task from "./pages/Task/index.js";
+import Playground from './components/Playground.js';
 
 const Home = lazy(() => import('./pages/Home'));
 const Detail = lazy(() => import('./pages/Detail'));
@@ -100,6 +101,14 @@ function App() {
             </PrivateRoute>
           }
         />
+        <Route
+          path='/playground'
+          element={
+            <PrivateRoute>
+              <Playground />
+            </PrivateRoute>
+          }
+        />
         <Route
           path='/redemption'
           element={

+ 4 - 4
web/src/components/HeaderBar.js

@@ -39,10 +39,10 @@ let buttons = [
     // icon: <IconHomeStroked />,
   },
   // {
-  //   text: '模型价格',
-  //   itemKey: 'pricing',
-  //   to: '/pricing',
-  //   icon: <IconNoteMoneyStroked />,
+  //   text: 'Playground',
+  //   itemKey: 'playground',
+  //   to: '/playground',
+  //   // icon: <IconNoteMoneyStroked />,
   // },
 ];
 

+ 335 - 0
web/src/components/Playground.js

@@ -0,0 +1,335 @@
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { UserContext } from '../context/User';
+import { API, getUserIdFromLocalStorage, showError } from '../helpers';
+import { Card, Chat, Input, Layout, Select, Slider, TextArea, Typography } from '@douyinfe/semi-ui';
+import { SSE } from 'sse';
+
+const defaultMessage = [
+  {
+    role: 'user',
+    id: '2',
+    createAt: 1715676751919,
+    content: "你好",
+  },
+  {
+    role: 'assistant',
+    id: '3',
+    createAt: 1715676751919,
+    content: "你好,请问有什么可以帮助您的吗?",
+  }
+];
+
+let id = 4;
+function getId() {
+  return `${id++}`
+}
+
+const Playground = () => {
+  const [inputs, setInputs] = useState({
+    model: 'gpt-4o-mini',
+    group: '',
+    max_tokens: 0,
+    temperature: 0,
+  });
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [userState, userDispatch] = useContext(UserContext);
+  const [status, setStatus] = useState({});
+  const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.');
+  const [message, setMessage] = useState(defaultMessage);
+  const [models, setModels] = useState([]);
+  const [groups, setGroups] = useState([]);
+
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+
+  useEffect(() => {
+    if (searchParams.get('expired')) {
+      showError('未登录或登录已过期,请重新登录!');
+    }
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+    }
+    loadModels();
+    loadGroups();
+  }, []);
+
+  const loadModels = async () => {
+    let res = await API.get(`/api/user/models`);
+    const { success, message, data } = res.data;
+    if (success) {
+      let localModelOptions = data.map((model) => ({
+        label: model,
+        value: model,
+      }));
+      setModels(localModelOptions);
+    } else {
+      showError(message);
+    }
+  };
+
+  const loadGroups = async () => {
+    let res = await API.get(`/api/user/groups`);
+    const { success, message, data } = res.data;
+    if (success) {
+      // return data is a map, key is group name, value is group description
+      // label is group description, value is group name
+      let localGroupOptions = Object.keys(data).map((group) => ({
+        label: data[group],
+        value: group,
+      }));
+      // handleInputChange('group', localGroupOptions[0].value);
+
+      if (localGroupOptions.length > 0) {
+      } else {
+        localGroupOptions = [{
+          label: '用户分组',
+          value: '',
+        }];
+        setGroups(localGroupOptions);
+      }
+      setGroups(localGroupOptions);
+      handleInputChange('group', localGroupOptions[0].value);
+    } else {
+      showError(message);
+    }
+  };
+
+  const commonOuterStyle = {
+    border: '1px solid var(--semi-color-border)',
+    borderRadius: '16px',
+    margin: '0px 8px',
+  }
+
+  const getSystemMessage = () => {
+    if (systemPrompt !== '') {
+      return {
+        role: 'system',
+        id: '1',
+        createAt: 1715676751919,
+        content: systemPrompt,
+      }
+    }
+  }
+
+  let handleSSE = (payload) => {
+    let source = new SSE('/pg/chat/completions', {
+      headers: {
+        "Content-Type": "application/json",
+        "New-Api-User": getUserIdFromLocalStorage(),
+      },
+      method: "POST",
+      payload: JSON.stringify(payload),
+    });
+    source.addEventListener("message", (e) => {
+      if (e.data !== "[DONE]") {
+        let payload = JSON.parse(e.data);
+        // console.log("Payload: ", payload);
+        if (payload.choices.length === 0) {
+          source.close();
+          completeMessage();
+        } else {
+          let text = payload.choices[0].delta.content;
+          generateMockResponse(text);
+        }
+      } else {
+        completeMessage();
+      }
+    });
+
+    source.addEventListener("error", (e) => {
+      generateMockResponse(e.data)
+      completeMessage();
+    });
+
+    source.addEventListener("readystatechange", (e) => {
+      if (e.readyState >= 2) {
+        if (source.status === undefined) {
+          source.close();
+          completeMessage();
+        }
+      }
+    });
+    source.stream();
+  }
+
+  const onMessageSend = useCallback((content, attachment) => {
+    console.log("attachment: ", attachment);
+    setMessage((prevMessage) => {
+      const newMessage = [
+        ...prevMessage,
+        {
+          role: 'user',
+          content: content,
+          createAt: Date.now(),
+          id: getId()
+        }
+      ];
+
+      // 将 getPayload 移到这里
+      const getPayload = () => {
+        let systemMessage = getSystemMessage();
+        let messages = newMessage.map((item) => {
+          return {
+            role: item.role,
+            content: item.content,
+          }
+        });
+        if (systemMessage) {
+          messages.unshift(systemMessage);
+        }
+        return {
+          messages: messages,
+          stream: true,
+          model: inputs.model,
+          group: inputs.group,
+          max_tokens: inputs.max_tokens,
+          temperature: inputs.temperature,
+        };
+      };
+
+      // 使用更新后的消息状态调用 handleSSE
+      handleSSE(getPayload());
+      newMessage.push({
+        role: 'assistant',
+        content: '',
+        createAt: Date.now(),
+        id: getId(),
+        status: 'loading'
+      });
+      return newMessage;
+    });
+  }, [getSystemMessage]);
+
+  const completeMessage = useCallback(() => {
+    setMessage((prevMessage) => {
+      const lastMessage = prevMessage[prevMessage.length - 1];
+      return [
+        ...prevMessage.slice(0, -1),
+        { ...lastMessage, status: 'complete' }
+      ];
+    });
+  }, [])
+
+  const generateMockResponse = useCallback((content) => {
+    // console.log("Generate Mock Response: ", content);
+    setMessage((message) => {
+      const lastMessage = message[message.length - 1];
+      let newMessage = {...lastMessage};
+      if (lastMessage.status === 'loading' || lastMessage.status === 'incomplete') {
+        newMessage = {
+          ...newMessage,
+          content: (lastMessage.content || '') + content,
+          status: 'incomplete'
+        }
+      }
+      return [ ...message.slice(0, -1), newMessage ]
+    })
+  }, []);
+
+  return (
+    <Layout style={{height: '100%'}}>
+      <Layout.Sider>
+        <Card style={commonOuterStyle}>
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>分组:</Typography.Text>
+          </div>
+          <Select
+            placeholder={'请选择分组'}
+            name='group'
+            required
+            selection
+            onChange={(value) => {
+              handleInputChange('group', value);
+            }}
+            value={inputs.group}
+            autoComplete='new-password'
+            optionList={groups}
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>模型:</Typography.Text>
+          </div>
+          <Select
+            placeholder={'请选择模型'}
+            name='model'
+            required
+            selection
+            filter
+            onChange={(value) => {
+              handleInputChange('model', value);
+            }}
+            value={inputs.model}
+            autoComplete='new-password'
+            optionList={models}
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>Temperature:</Typography.Text>
+          </div>
+          <Slider
+            step={0.1}
+            min={0.1}
+            max={1}
+            value={inputs.temperature}
+            onChange={(value) => {
+              handleInputChange('temperature', value);
+            }}
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>MaxTokens:</Typography.Text>
+          </div>
+          <Input
+            placeholder='MaxTokens'
+            name='max_tokens'
+            required
+            autoComplete='new-password'
+            defaultValue={0}
+            value={inputs.max_tokens}
+            onChange={(value) => {
+              handleInputChange('max_tokens', value);
+            }}
+          />
+
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>System:</Typography.Text>
+          </div>
+          <TextArea
+            placeholder='System Prompt'
+            name='system'
+            required
+            autoComplete='new-password'
+            autosize
+            defaultValue={systemPrompt}
+            // value={systemPrompt}
+            onChange={(value) => {
+              setSystemPrompt(value);
+            }}
+          />
+
+        </Card>
+      </Layout.Sider>
+      <Layout.Content>
+        <div style={{height: '100%'}}>
+          <Chat
+            chatBoxRenderConfig={{
+              renderChatBoxAction: () => {
+                return <div></div>
+              }
+            }}
+            style={commonOuterStyle}
+            chats={message}
+            onMessageSend={onMessageSend}
+            showClearContext
+            onClear={() => {
+              setMessage([]);
+            }}
+          />
+        </div>
+      </Layout.Content>
+    </Layout>
+  );
+};
+
+export default Playground;

+ 790 - 0
web/src/components/SafetySetting.js

@@ -0,0 +1,790 @@
+import React, { useEffect, useState } from 'react';
+import {
+  Button,
+  Divider,
+  Form,
+  Grid,
+  Header,
+  Message,
+  Modal,
+} from 'semantic-ui-react';
+import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
+
+import { useTheme } from '../context/Theme';
+
+const SafetySetting = () => {
+  let [inputs, setInputs] = useState({
+    PasswordLoginEnabled: '',
+    PasswordRegisterEnabled: '',
+    EmailVerificationEnabled: '',
+    GitHubOAuthEnabled: '',
+    GitHubClientId: '',
+    GitHubClientSecret: '',
+    Notice: '',
+    SMTPServer: '',
+    SMTPPort: '',
+    SMTPAccount: '',
+    SMTPFrom: '',
+    SMTPToken: '',
+    ServerAddress: '',
+    WorkerUrl: '',
+    WorkerValidKey: '',
+    EpayId: '',
+    EpayKey: '',
+    Price: 7.3,
+    MinTopUp: 1,
+    TopupGroupRatio: '',
+    PayAddress: '',
+    CustomCallbackAddress: '',
+    Footer: '',
+    WeChatAuthEnabled: '',
+    WeChatServerAddress: '',
+    WeChatServerToken: '',
+    WeChatAccountQRCodeImageURL: '',
+    TurnstileCheckEnabled: '',
+    TurnstileSiteKey: '',
+    TurnstileSecretKey: '',
+    RegisterEnabled: '',
+    EmailDomainRestrictionEnabled: '',
+    EmailAliasRestrictionEnabled: '',
+    SMTPSSLEnabled: '',
+    EmailDomainWhitelist: [],
+    // telegram login
+    TelegramOAuthEnabled: '',
+    TelegramBotToken: '',
+    TelegramBotName: '',
+  });
+  const [originInputs, setOriginInputs] = useState({});
+  let [loading, setLoading] = useState(false);
+  const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
+  const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
+  const [showPasswordWarningModal, setShowPasswordWarningModal] =
+    useState(false);
+
+  const theme = useTheme();
+  const isDark = theme === 'dark';
+
+  const getOptions = async () => {
+    const res = await API.get('/api/option/');
+    const { success, message, data } = res.data;
+    if (success) {
+      let newInputs = {};
+      data.forEach((item) => {
+        if (item.key === 'TopupGroupRatio') {
+          item.value = JSON.stringify(JSON.parse(item.value), null, 2);
+        }
+        newInputs[item.key] = item.value;
+      });
+      setInputs({
+        ...newInputs,
+        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),
+      });
+      setOriginInputs(newInputs);
+
+      setEmailDomainWhitelist(
+        newInputs.EmailDomainWhitelist.split(',').map((item) => {
+          return { key: item, text: item, value: item };
+        }),
+      );
+    } else {
+      showError(message);
+    }
+  };
+
+  useEffect(() => {
+    getOptions().then();
+  }, []);
+  useEffect(() => {}, [inputs.EmailDomainWhitelist]);
+
+  const updateOption = async (key, value) => {
+    setLoading(true);
+    switch (key) {
+      case 'PasswordLoginEnabled':
+      case 'PasswordRegisterEnabled':
+      case 'EmailVerificationEnabled':
+      case 'GitHubOAuthEnabled':
+      case 'WeChatAuthEnabled':
+      case 'TelegramOAuthEnabled':
+      case 'TurnstileCheckEnabled':
+      case 'EmailDomainRestrictionEnabled':
+      case 'EmailAliasRestrictionEnabled':
+      case 'SMTPSSLEnabled':
+      case 'RegisterEnabled':
+        value = inputs[key] === 'true' ? 'false' : 'true';
+        break;
+      default:
+        break;
+    }
+    const res = await API.put('/api/option/', {
+      key,
+      value,
+    });
+    const { success, message } = res.data;
+    if (success) {
+      if (key === 'EmailDomainWhitelist') {
+        value = value.split(',');
+      }
+      if (key === 'Price') {
+        value = parseFloat(value);
+      }
+      setInputs((inputs) => ({
+        ...inputs,
+        [key]: value,
+      }));
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const handleInputChange = async (e, { name, value }) => {
+    if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {
+      // block disabling password login
+      setShowPasswordWarningModal(true);
+      return;
+    }
+    if (
+      name === 'Notice' ||
+      (name.startsWith('SMTP') && name !== 'SMTPSSLEnabled') ||
+      name === 'ServerAddress' ||
+      name === 'WorkerUrl' ||
+      name === 'WorkerValidKey' ||
+      name === 'EpayId' ||
+      name === 'EpayKey' ||
+      name === 'Price' ||
+      name === 'PayAddress' ||
+      name === 'GitHubClientId' ||
+      name === 'GitHubClientSecret' ||
+      name === 'WeChatServerAddress' ||
+      name === 'WeChatServerToken' ||
+      name === 'WeChatAccountQRCodeImageURL' ||
+      name === 'TurnstileSiteKey' ||
+      name === 'TurnstileSecretKey' ||
+      name === 'EmailDomainWhitelist' ||
+      name === 'TopupGroupRatio' ||
+      name === 'TelegramBotToken' ||
+      name === 'TelegramBotName'
+    ) {
+      setInputs((inputs) => ({ ...inputs, [name]: value }));
+    } else {
+      await updateOption(name, value);
+    }
+  };
+
+  const submitServerAddress = async () => {
+    let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
+    await updateOption('ServerAddress', ServerAddress);
+  };
+
+  const submitWorker = async () => {
+    let WorkerUrl = removeTrailingSlash(inputs.WorkerUrl);
+    await updateOption('WorkerUrl', WorkerUrl);
+    if (inputs.WorkerValidKey !== '') {
+      await updateOption('WorkerValidKey', inputs.WorkerValidKey);
+    }
+  }
+
+  const submitPayAddress = async () => {
+    if (inputs.ServerAddress === '') {
+      showError('请先填写服务器地址');
+      return;
+    }
+    if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
+      if (!verifyJSON(inputs.TopupGroupRatio)) {
+        showError('充值分组倍率不是合法的 JSON 字符串');
+        return;
+      }
+      await updateOption('TopupGroupRatio', inputs.TopupGroupRatio);
+    }
+    let PayAddress = removeTrailingSlash(inputs.PayAddress);
+    await updateOption('PayAddress', PayAddress);
+    if (inputs.EpayId !== '') {
+      await updateOption('EpayId', inputs.EpayId);
+    }
+    if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
+      await updateOption('EpayKey', inputs.EpayKey);
+    }
+    await updateOption('Price', '' + inputs.Price);
+  };
+
+  const submitSMTP = async () => {
+    if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
+      await updateOption('SMTPServer', inputs.SMTPServer);
+    }
+    if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
+      await updateOption('SMTPAccount', inputs.SMTPAccount);
+    }
+    if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {
+      await updateOption('SMTPFrom', inputs.SMTPFrom);
+    }
+    if (
+      originInputs['SMTPPort'] !== inputs.SMTPPort &&
+      inputs.SMTPPort !== ''
+    ) {
+      await updateOption('SMTPPort', inputs.SMTPPort);
+    }
+    if (
+      originInputs['SMTPToken'] !== inputs.SMTPToken &&
+      inputs.SMTPToken !== ''
+    ) {
+      await updateOption('SMTPToken', inputs.SMTPToken);
+    }
+  };
+
+  const submitEmailDomainWhitelist = async () => {
+    if (
+      originInputs['EmailDomainWhitelist'] !==
+        inputs.EmailDomainWhitelist.join(',') &&
+      inputs.SMTPToken !== ''
+    ) {
+      await updateOption(
+        'EmailDomainWhitelist',
+        inputs.EmailDomainWhitelist.join(','),
+      );
+    }
+  };
+
+  const submitWeChat = async () => {
+    if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
+      await updateOption(
+        'WeChatServerAddress',
+        removeTrailingSlash(inputs.WeChatServerAddress),
+      );
+    }
+    if (
+      originInputs['WeChatAccountQRCodeImageURL'] !==
+      inputs.WeChatAccountQRCodeImageURL
+    ) {
+      await updateOption(
+        'WeChatAccountQRCodeImageURL',
+        inputs.WeChatAccountQRCodeImageURL,
+      );
+    }
+    if (
+      originInputs['WeChatServerToken'] !== inputs.WeChatServerToken &&
+      inputs.WeChatServerToken !== ''
+    ) {
+      await updateOption('WeChatServerToken', inputs.WeChatServerToken);
+    }
+  };
+
+  const submitGitHubOAuth = async () => {
+    if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {
+      await updateOption('GitHubClientId', inputs.GitHubClientId);
+    }
+    if (
+      originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&
+      inputs.GitHubClientSecret !== ''
+    ) {
+      await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);
+    }
+  };
+
+  const submitTelegramSettings = async () => {
+    // await updateOption('TelegramOAuthEnabled', inputs.TelegramOAuthEnabled);
+    await updateOption('TelegramBotToken', inputs.TelegramBotToken);
+    await updateOption('TelegramBotName', inputs.TelegramBotName);
+  };
+
+  const submitTurnstile = async () => {
+    if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
+      await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
+    }
+    if (
+      originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&
+      inputs.TurnstileSecretKey !== ''
+    ) {
+      await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);
+    }
+  };
+
+  const submitNewRestrictedDomain = () => {
+    const localDomainList = inputs.EmailDomainWhitelist;
+    if (
+      restrictedDomainInput !== '' &&
+      !localDomainList.includes(restrictedDomainInput)
+    ) {
+      setRestrictedDomainInput('');
+      setInputs({
+        ...inputs,
+        EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
+      });
+      setEmailDomainWhitelist([
+        ...EmailDomainWhitelist,
+        {
+          key: restrictedDomainInput,
+          text: restrictedDomainInput,
+          value: restrictedDomainInput,
+        },
+      ]);
+    }
+  };
+
+  return (
+    <Grid columns={1}>
+      <Grid.Column>
+        <Form loading={loading} inverted={isDark}>
+          <Header as='h3' inverted={isDark}>
+            通用设置
+          </Header>
+          <Form.Group widths='equal'>
+            <Form.Input
+              label='服务器地址'
+              placeholder='例如:https://yourdomain.com'
+              value={inputs.ServerAddress}
+              name='ServerAddress'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Button onClick={submitServerAddress}>
+            更新服务器地址
+          </Form.Button>
+          <Header as='h3' inverted={isDark}>
+            代理设置(支持 <a href='https://github.com/Calcium-Ion/new-api-worker' target='_blank' rel='noreferrer'>new-api-worker</a>)
+          </Header>
+          <Form.Group widths='equal'>
+            <Form.Input
+              label='Worker地址,不填写则不启用代理'
+              placeholder='例如:https://workername.yourdomain.workers.dev'
+              value={inputs.WorkerUrl}
+              name='WorkerUrl'
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='Worker密钥,根据你部署的 Worker 填写'
+              placeholder='例如:your_secret_key'
+              value={inputs.WorkerValidKey}
+              name='WorkerValidKey'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Button onClick={submitWorker}>
+            更新Worker设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            支付设置(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)
+          </Header>
+          <Form.Group widths='equal'>
+            <Form.Input
+              label='支付地址,不填写则不启用在线支付'
+              placeholder='例如:https://yourdomain.com'
+              value={inputs.PayAddress}
+              name='PayAddress'
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='易支付商户ID'
+              placeholder='例如:0001'
+              value={inputs.EpayId}
+              name='EpayId'
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='易支付商户密钥'
+              placeholder='敏感信息不会发送到前端显示'
+              value={inputs.EpayKey}
+              name='EpayKey'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Group widths='equal'>
+            <Form.Input
+              label='回调地址,不填写则使用上方服务器地址作为回调地址'
+              placeholder='例如:https://yourdomain.com'
+              value={inputs.CustomCallbackAddress}
+              name='CustomCallbackAddress'
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='充值价格(x元/美金)'
+              placeholder='例如:7,就是7元/美金'
+              value={inputs.Price}
+              name='Price'
+              min={0}
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='最低充值美元数量(以美金为单位,如果使用额度请自行换算!)'
+              placeholder='例如:2,就是最低充值2$'
+              value={inputs.MinTopUp}
+              name='MinTopUp'
+              min={1}
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Group widths='equal'>
+            <Form.TextArea
+              label='充值分组倍率'
+              name='TopupGroupRatio'
+              onChange={handleInputChange}
+              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
+              autoComplete='new-password'
+              value={inputs.TopupGroupRatio}
+              placeholder='为一个 JSON 文本,键为组名称,值为倍率'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitPayAddress}>更新支付设置</Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置登录注册
+          </Header>
+          <Form.Group inline>
+            <Form.Checkbox
+              checked={inputs.PasswordLoginEnabled === 'true'}
+              label='允许通过密码进行登录'
+              name='PasswordLoginEnabled'
+              onChange={handleInputChange}
+            />
+            {showPasswordWarningModal && (
+              <Modal
+                open={showPasswordWarningModal}
+                onClose={() => setShowPasswordWarningModal(false)}
+                size={'tiny'}
+                style={{ maxWidth: '450px' }}
+              >
+                <Modal.Header>警告</Modal.Header>
+                <Modal.Content>
+                  <p>
+                    取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?
+                  </p>
+                </Modal.Content>
+                <Modal.Actions>
+                  <Button onClick={() => setShowPasswordWarningModal(false)}>
+                    取消
+                  </Button>
+                  <Button
+                    color='yellow'
+                    onClick={async () => {
+                      setShowPasswordWarningModal(false);
+                      await updateOption('PasswordLoginEnabled', 'false');
+                    }}
+                  >
+                    确定
+                  </Button>
+                </Modal.Actions>
+              </Modal>
+            )}
+            <Form.Checkbox
+              checked={inputs.PasswordRegisterEnabled === 'true'}
+              label='允许通过密码进行注册'
+              name='PasswordRegisterEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.EmailVerificationEnabled === 'true'}
+              label='通过密码注册时需要进行邮箱验证'
+              name='EmailVerificationEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.GitHubOAuthEnabled === 'true'}
+              label='允许通过 GitHub 账户登录 & 注册'
+              name='GitHubOAuthEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.WeChatAuthEnabled === 'true'}
+              label='允许通过微信登录 & 注册'
+              name='WeChatAuthEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.TelegramOAuthEnabled === 'true'}
+              label='允许通过 Telegram 进行登录'
+              name='TelegramOAuthEnabled'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Group inline>
+            <Form.Checkbox
+              checked={inputs.RegisterEnabled === 'true'}
+              label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)'
+              name='RegisterEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.TurnstileCheckEnabled === 'true'}
+              label='启用 Turnstile 用户校验'
+              name='TurnstileCheckEnabled'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置邮箱域名白名单
+            <Header.Subheader>
+              用以防止恶意用户利用临时邮箱批量注册
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Checkbox
+              label='启用邮箱域名白名单'
+              name='EmailDomainRestrictionEnabled'
+              onChange={handleInputChange}
+              checked={inputs.EmailDomainRestrictionEnabled === 'true'}
+            />
+          </Form.Group>
+          <Form.Group widths={3}>
+            <Form.Checkbox
+              label='启用邮箱别名限制(例如:ab.cd@gmail.com)'
+              name='EmailAliasRestrictionEnabled'
+              onChange={handleInputChange}
+              checked={inputs.EmailAliasRestrictionEnabled === 'true'}
+            />
+          </Form.Group>
+          <Form.Group widths={2}>
+            <Form.Dropdown
+              label='允许的邮箱域名'
+              placeholder='允许的邮箱域名'
+              name='EmailDomainWhitelist'
+              required
+              fluid
+              multiple
+              selection
+              onChange={handleInputChange}
+              value={inputs.EmailDomainWhitelist}
+              autoComplete='new-password'
+              options={EmailDomainWhitelist}
+            />
+            <Form.Input
+              label='添加新的允许的邮箱域名'
+              action={
+                <Button
+                  type='button'
+                  onClick={() => {
+                    submitNewRestrictedDomain();
+                  }}
+                >
+                  填入
+                </Button>
+              }
+              onKeyDown={(e) => {
+                if (e.key === 'Enter') {
+                  submitNewRestrictedDomain();
+                }
+              }}
+              autoComplete='new-password'
+              placeholder='输入新的允许的邮箱域名'
+              value={restrictedDomainInput}
+              onChange={(e, { value }) => {
+                setRestrictedDomainInput(value);
+              }}
+            />
+          </Form.Group>
+          <Form.Button onClick={submitEmailDomainWhitelist}>
+            保存邮箱域名白名单设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 SMTP
+            <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='SMTP 服务器地址'
+              name='SMTPServer'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.SMTPServer}
+              placeholder='例如:smtp.qq.com'
+            />
+            <Form.Input
+              label='SMTP 端口'
+              name='SMTPPort'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.SMTPPort}
+              placeholder='默认: 587'
+            />
+            <Form.Input
+              label='SMTP 账户'
+              name='SMTPAccount'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.SMTPAccount}
+              placeholder='通常是邮箱地址'
+            />
+          </Form.Group>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='SMTP 发送者邮箱'
+              name='SMTPFrom'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.SMTPFrom}
+              placeholder='通常和邮箱地址保持一致'
+            />
+            <Form.Input
+              label='SMTP 访问凭证'
+              name='SMTPToken'
+              onChange={handleInputChange}
+              type='password'
+              autoComplete='new-password'
+              checked={inputs.RegisterEnabled === 'true'}
+              placeholder='敏感信息不会发送到前端显示'
+            />
+          </Form.Group>
+          <Form.Group widths={3}>
+            <Form.Checkbox
+              label='启用SMTP SSL(465端口强制开启)'
+              name='SMTPSSLEnabled'
+              onChange={handleInputChange}
+              checked={inputs.SMTPSSLEnabled === 'true'}
+            />
+          </Form.Group>
+          <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 GitHub OAuth App
+            <Header.Subheader>
+              用以支持通过 GitHub 进行登录注册,
+              <a
+                href='https://github.com/settings/developers'
+                target='_blank'
+                rel='noreferrer'
+              >
+                点击此处
+              </a>
+              管理你的 GitHub OAuth App
+            </Header.Subheader>
+          </Header>
+          <Message>
+            Homepage URL 填 <code>{inputs.ServerAddress}</code>
+            ,Authorization callback URL 填{' '}
+            <code>{`${inputs.ServerAddress}/oauth/github`}</code>
+          </Message>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='GitHub Client ID'
+              name='GitHubClientId'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.GitHubClientId}
+              placeholder='输入你注册的 GitHub OAuth APP 的 ID'
+            />
+            <Form.Input
+              label='GitHub Client Secret'
+              name='GitHubClientSecret'
+              onChange={handleInputChange}
+              type='password'
+              autoComplete='new-password'
+              value={inputs.GitHubClientSecret}
+              placeholder='敏感信息不会发送到前端显示'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitGitHubOAuth}>
+            保存 GitHub OAuth 设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 WeChat Server
+            <Header.Subheader>
+              用以支持通过微信进行登录注册,
+              <a
+                href='https://github.com/songquanpeng/wechat-server'
+                target='_blank'
+                rel='noreferrer'
+              >
+                点击此处
+              </a>
+              了解 WeChat Server
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='WeChat Server 服务器地址'
+              name='WeChatServerAddress'
+              placeholder='例如:https://yourdomain.com'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.WeChatServerAddress}
+            />
+            <Form.Input
+              label='WeChat Server 访问凭证'
+              name='WeChatServerToken'
+              type='password'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.WeChatServerToken}
+              placeholder='敏感信息不会发送到前端显示'
+            />
+            <Form.Input
+              label='微信公众号二维码图片链接'
+              name='WeChatAccountQRCodeImageURL'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.WeChatAccountQRCodeImageURL}
+              placeholder='输入一个图片链接'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitWeChat}>
+            保存 WeChat Server 设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 Telegram 登录
+          </Header>
+          <Form.Group inline>
+            <Form.Input
+              label='Telegram Bot Token'
+              name='TelegramBotToken'
+              onChange={handleInputChange}
+              value={inputs.TelegramBotToken}
+              placeholder='输入你的 Telegram Bot Token'
+            />
+            <Form.Input
+              label='Telegram Bot 名称'
+              name='TelegramBotName'
+              onChange={handleInputChange}
+              value={inputs.TelegramBotName}
+              placeholder='输入你的 Telegram Bot 名称'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitTelegramSettings}>
+            保存 Telegram 登录设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 Turnstile
+            <Header.Subheader>
+              用以支持用户校验,
+              <a
+                href='https://dash.cloudflare.com/'
+                target='_blank'
+                rel='noreferrer'
+              >
+                点击此处
+              </a>
+              管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='Turnstile Site Key'
+              name='TurnstileSiteKey'
+              onChange={handleInputChange}
+              autoComplete='new-password'
+              value={inputs.TurnstileSiteKey}
+              placeholder='输入你注册的 Turnstile Site Key'
+            />
+            <Form.Input
+              label='Turnstile Secret Key'
+              name='TurnstileSecretKey'
+              onChange={handleInputChange}
+              type='password'
+              autoComplete='new-password'
+              value={inputs.TurnstileSecretKey}
+              placeholder='敏感信息不会发送到前端显示'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitTurnstile}>
+            保存 Turnstile 设置
+          </Form.Button>
+        </Form>
+      </Grid.Column>
+    </Grid>
+  );
+};
+
+export default SystemSetting;

+ 8 - 1
web/src/components/SiderBar.js

@@ -15,7 +15,7 @@ import '../index.css';
 
 import {
   IconCalendarClock, IconChecklistStroked,
-  IconComment,
+  IconComment, IconCommentStroked,
   IconCreditCard,
   IconGift, IconHelpCircle,
   IconHistogram,
@@ -63,6 +63,7 @@ const SiderBar = () => {
     detail: '/detail',
     pricing: '/pricing',
     task: '/task',
+    playground: '/playground',
   };
 
   const headerButtons = useMemo(
@@ -73,6 +74,12 @@ const SiderBar = () => {
       //   to: '/',
       //   icon: <IconHome />,
       // },
+      {
+        text: 'Playground',
+        itemKey: 'playground',
+        to: '/playground',
+        icon: <IconCommentStroked />,
+      },
       {
         text: '模型价格',
         itemKey: 'pricing',

+ 6 - 0
web/src/index.css

@@ -59,6 +59,12 @@ body {
   display: revert;
 }
 
+.semi-chat {
+  padding-top: 0 !important;
+  padding-bottom: 0 !important;
+  height: 100%;
+}
+
 .tableHiddle {
   display: none !important;
 }

+ 4 - 0
web/vite.config.js

@@ -55,6 +55,10 @@ export default defineConfig({
         target: 'http://localhost:3000',
         changeOrigin: true,
       },
+      '/pg': {
+        target: 'http://localhost:3000',
+        changeOrigin: true,
+      },
     },
   },
 });

Некоторые файлы не были показаны из-за большого количества измененных файлов