Compare commits

..

12 Commits

Author SHA1 Message Date
JustSong
2cdc718fde feat: able to use any link as about page (#60) 2023-05-14 18:58:54 +08:00
JustSong
57cb150177 perf: load cached about content first (#60) 2023-05-14 16:13:42 +08:00
JustSong
6167e20b34 style: hide scroll bar 2023-05-14 16:02:40 +08:00
JustSong
8835d8302e chore: fix typo 2023-05-14 16:01:04 +08:00
JustSong
224bebe67a feat: able to customize home page with link (close #60) 2023-05-14 15:34:14 +08:00
JustSong
cf6883778e perf: use slice to improve efficiency (#57) 2023-05-14 12:53:03 +08:00
JustSong
246b981e23 fix: fix "[DONE is not valid JSON" (#57) 2023-05-14 12:48:42 +08:00
JustSong
2edd52e851 fix: fix Azure channel not working in stream mode (#57) 2023-05-14 09:39:42 +08:00
JustSong
e123c66bc7 fix: fix SMTPFrom not updated in some cases (close #34) 2023-05-13 22:04:36 +08:00
JustSong
9edc82bde0 fix: fix garbled email subject (#34) 2023-05-13 21:41:52 +08:00
JustSong
d84c2f5c70 feat: able to customize home page now (#24) 2023-05-13 21:27:49 +08:00
JustSong
46e77389a4 fix: support smtp server with port 465 2023-05-13 18:57:27 +08:00
10 changed files with 222 additions and 91 deletions

View File

@@ -43,8 +43,8 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
## 功能 ## 功能
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道: 1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道:
+ [x] OpenAI 官方通道 + [x] OpenAI 官方通道
+ [x] [API2D](https://api2d.com/r/197971)
+ [x] Azure OpenAI API + [x] Azure OpenAI API
+ [x] [API2D](https://api2d.com/r/197971)
+ [x] [CloseAI](https://console.openai-asia.com) + [x] [CloseAI](https://console.openai-asia.com)
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [OpenAI Max](https://openaimax.com) + [x] [OpenAI Max](https://openaimax.com)
@@ -55,15 +55,16 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
4. 支持 HTTP SSE可以通过流式传输实现打字机效果。 4. 支持 HTTP SSE可以通过流式传输实现打字机效果。
5. 支持设置令牌的过期时间和使用次数。 5. 支持设置令牌的过期时间和使用次数。
6. 支持批量生成和导出兑换码,可使用兑换码为令牌进行充值。 6. 支持批量生成和导出兑换码,可使用兑换码为令牌进行充值。
7. 支持为新用户设置初始配额 7. 支持批量创建通道
8. 支持发布公告,在线修改关于页面,设置充值链接,自定义页脚。 8. 支持发布公告,自定义关于页面,设置充值链接,自定义页脚。
9. 支持通过系统访问令牌访问管理 API 9. 支持自定义首页,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入
10. 多种用户登录注册方式: 10. 支持通过系统访问令牌访问管理 API。
11. 多种用户登录注册方式:
+ 邮箱登录注册以及通过邮箱进行密码重置。 + 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。 + [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
11. 支持用户管理。 12. 支持用户管理,支持为新用户设置初始配额。
12. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 13. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
## 部署 ## 部署
### 基于 Docker 进行部署 ### 基于 Docker 进行部署

View File

@@ -1,20 +1,67 @@
package common package common
import ( import (
"crypto/tls"
"encoding/base64"
"fmt" "fmt"
"net/smtp" "net/smtp"
"strings" "strings"
) )
func SendEmail(subject string, receiver string, content string) error { func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
}
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+ mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+ "From: %s<%s>\r\n"+
"Subject: %s\r\n"+ "Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, subject, content)) receiver, SystemName, SMTPFrom, encodedSubject, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer) auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort) addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";") to := strings.Split(receiver, ";")
err := smtp.SendMail(addr, auth, SMTPAccount, to, mail) var err error
if SMTPPort == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: SMTPServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, SMTPServer)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(SMTPFrom); err != nil {
return err
}
receiverEmails := strings.Split(receiver, ";")
for _, receiver := range receiverEmails {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
}
return err return err
} }

View File

@@ -54,6 +54,17 @@ func GetAbout(c *gin.Context) {
return return
} }
func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["HomePageContent"],
})
return
}
func SendEmailVerification(c *gin.Context) { func SendEmailVerification(c *gin.Context) {
email := c.Query("email") email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil { if err := common.Validate.Var(email, "required,email"); err != nil {

View File

@@ -94,10 +94,12 @@ func relayHelper(c *gin.Context) error {
if channelType == common.ChannelTypeAzure { if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api // https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query() query := c.Request.URL.Query()
if query.Get("api-version") == "" { apiVersion := query.Get("api-version")
apiVersion := c.GetString("api_version") if apiVersion == "" {
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion) apiVersion = c.GetString("api_version")
} }
requestURL := strings.Split(requestURL, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
baseURL = c.GetString("base_url") baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/") task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model model_ := textRequest.Model
@@ -186,7 +188,7 @@ func relayHelper(c *gin.Context) error {
data := scanner.Text() data := scanner.Text()
dataChan <- data dataChan <- data
data = data[6:] data = data[6:]
if data != "[DONE]" { if !strings.HasPrefix(data, "[DONE]") {
var streamResponse StreamResponse var streamResponse StreamResponse
err = json.Unmarshal([]byte(data), &streamResponse) err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil { if err != nil {
@@ -207,6 +209,9 @@ func relayHelper(c *gin.Context) error {
c.Stream(func(w io.Writer) bool { c.Stream(func(w io.Writer) bool {
select { select {
case data := <-dataChan: case data := <-dataChan:
if strings.HasPrefix(data, "data: [DONE]") {
data = data[:12]
}
c.Render(-1, common.CustomEvent{Data: data}) c.Render(-1, common.CustomEvent{Data: data})
return true return true
case <-stopChan: case <-stopChan:

View File

@@ -39,6 +39,7 @@ func InitOptionMap() {
common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPToken"] = ""
common.OptionMap["Notice"] = "" common.OptionMap["Notice"] = ""
common.OptionMap["About"] = "" common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
common.OptionMap["Footer"] = common.Footer common.OptionMap["Footer"] = common.Footer
common.OptionMap["ServerAddress"] = "" common.OptionMap["ServerAddress"] = ""
common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientId"] = ""
@@ -59,9 +60,6 @@ func InitOptionMap() {
common.SysError("Failed to update option map: " + err.Error()) common.SysError("Failed to update option map: " + err.Error())
} }
} }
if common.SMTPFrom == "" { // for compatibility
common.SMTPFrom = common.SMTPAccount
}
} }
func UpdateOption(key string, value string) error { func UpdateOption(key string, value string) error {

View File

@@ -15,6 +15,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/notice", controller.GetNotice) apiRouter.GET("/notice", controller.GetNotice)
apiRouter.GET("/about", controller.GetAbout) apiRouter.GET("/about", controller.GetAbout)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)

View File

@@ -8,6 +8,7 @@ const OtherSetting = () => {
Footer: '', Footer: '',
Notice: '', Notice: '',
About: '', About: '',
HomePageContent: '',
}); });
let originInputs = {}; let originInputs = {};
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
@@ -69,6 +70,10 @@ const OtherSetting = () => {
await updateOption('About', inputs.About); await updateOption('About', inputs.About);
}; };
const submitOption = async (key) => {
await updateOption(key, inputs[key]);
};
const openGitHubRelease = () => { const openGitHubRelease = () => {
window.location = window.location =
'https://github.com/songquanpeng/one-api/releases/latest'; 'https://github.com/songquanpeng/one-api/releases/latest';
@@ -109,10 +114,21 @@ const OtherSetting = () => {
<Form.Button onClick={submitNotice}>保存公告</Form.Button> <Form.Button onClick={submitNotice}>保存公告</Form.Button>
<Divider /> <Divider />
<Header as='h3'>个性化设置</Header> <Header as='h3'>个性化设置</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='首页内容'
placeholder='在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'
value={inputs.HomePageContent}
name='HomePageContent'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={()=>submitOption('HomePageContent')}>保存首页内容</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='关于' label='关于'
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码' placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'
value={inputs.About} value={inputs.About}
name='About' name='About'
onChange={handleInputChange} onChange={handleInputChange}

View File

@@ -5,6 +5,11 @@ body {
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif; font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
}
body::-webkit-scrollbar {
display: none;
} }
code { code {

View File

@@ -5,18 +5,24 @@ import { marked } from 'marked';
const About = () => { const About = () => {
const [about, setAbout] = useState(''); const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false);
const displayAbout = async () => { const displayAbout = async () => {
setAbout(localStorage.getItem('about') || '');
const res = await API.get('/api/about'); const res = await API.get('/api/about');
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
let HTMLAbout = marked.parse(data); let aboutContent = data;
localStorage.setItem('about', HTMLAbout); if (!data.startsWith('https://')) {
setAbout(HTMLAbout); aboutContent = marked.parse(data);
}
setAbout(aboutContent);
localStorage.setItem('about', aboutContent);
} else { } else {
showError(message); showError(message);
setAbout('加载关于内容失败...'); setAbout('加载关于内容失败...');
} }
setAboutLoaded(true);
}; };
useEffect(() => { useEffect(() => {
@@ -25,20 +31,27 @@ const About = () => {
return ( return (
<> <>
<Segment> {
{ aboutLoaded && about === '' ? <>
about === '' ? <> <Segment>
<Header as='h3'>关于</Header> <Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p> <p>可在设置页面设置关于内容支持 HTML & Markdown</p>
项目仓库地址 项目仓库地址
<a href="https://github.com/songquanpeng/one-api"> <a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api https://github.com/songquanpeng/one-api
</a> </a>
</> : <> </Segment>
<div dangerouslySetInnerHTML={{ __html: about}}></div> </> : <>
</> {
} about.startsWith('https://') ? <iframe
</Segment> src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <Segment>
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
</Segment>
}
</>
}
</> </>
); );
}; };

View File

@@ -1,10 +1,13 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Card, Grid, Header, Segment } from 'semantic-ui-react'; import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers'; import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
const Home = () => { const Home = () => {
const [statusState, statusDispatch] = useContext(StatusContext); const [statusState, statusDispatch] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const displayNotice = async () => { const displayNotice = async () => {
const res = await API.get('/api/notice'); const res = await API.get('/api/notice');
@@ -20,6 +23,24 @@ const Home = () => {
} }
}; };
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
const res = await API.get('/api/home_page_content');
const { success, message, data } = res.data;
if (success) {
let content = data;
if (!data.startsWith('https://')) {
content = marked.parse(data);
}
setHomePageContent(content);
localStorage.setItem('home_page_content', content);
} else {
showError(message);
setHomePageContent('加载首页内容失败...');
}
setHomePageContentLoaded(true);
};
const getStartTimeString = () => { const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time; const timestamp = statusState?.status?.start_time;
return timestamp2string(timestamp); return timestamp2string(timestamp);
@@ -27,70 +48,83 @@ const Home = () => {
useEffect(() => { useEffect(() => {
displayNotice().then(); displayNotice().then();
displayHomePageContent().then();
}, []); }, []);
return ( return (
<> <>
<Segment> {
<Header as='h3'>系统状况</Header> homePageContentLoaded && homePageContent === '' ? <>
<Grid columns={2} stackable> <Segment>
<Grid.Column> <Header as='h3'>系统状况</Header>
<Card fluid> <Grid columns={2} stackable>
<Card.Content> <Grid.Column>
<Card.Header>系统信息</Card.Header> <Card fluid>
<Card.Meta>系统信息总览</Card.Meta> <Card.Content>
<Card.Description> <Card.Header>系统信息</Card.Header>
<p>名称{statusState?.status?.system_name}</p> <Card.Meta>系统信息总览</Card.Meta>
<p>版本{statusState?.status?.version}</p> <Card.Description>
<p> <p>名称{statusState?.status?.system_name}</p>
源码 <p>版本{statusState?.status?.version}</p>
<a <p>
href='https://github.com/songquanpeng/one-api' 源码
target='_blank' <a
> href='https://github.com/songquanpeng/one-api'
https://github.com/songquanpeng/one-api target='_blank'
</a> >
</p> https://github.com/songquanpeng/one-api
<p>启动时间{getStartTimeString()}</p> </a>
</Card.Description> </p>
</Card.Content> <p>启动时间{getStartTimeString()}</p>
</Card> </Card.Description>
</Grid.Column> </Card.Content>
<Grid.Column> </Card>
<Card fluid> </Grid.Column>
<Card.Content> <Grid.Column>
<Card.Header>系统配置</Card.Header> <Card fluid>
<Card.Meta>系统配置总览</Card.Meta> <Card.Content>
<Card.Description> <Card.Header>系统配置</Card.Header>
<p> <Card.Meta>系统配置总览</Card.Meta>
邮箱验证 <Card.Description>
{statusState?.status?.email_verification === true <p>
? '已启用' 邮箱验证
: '未启用'} {statusState?.status?.email_verification === true
</p> ? '已启用'
<p> : '未启用'}
GitHub 身份验证 </p>
{statusState?.status?.github_oauth === true <p>
? '已启用' GitHub 身份验证
: '未启用'} {statusState?.status?.github_oauth === true
</p> ? '已启用'
<p> : '未启用'}
微信身份验证 </p>
{statusState?.status?.wechat_login === true <p>
? '已启用' 微信身份验证
: '未启用'} {statusState?.status?.wechat_login === true
</p> ? '已启用'
<p> : '未启用'}
Turnstile 用户校验 </p>
{statusState?.status?.turnstile_check === true <p>
? '已启用' Turnstile 用户校验
: '未启用'} {statusState?.status?.turnstile_check === true
</p> ? '已启用'
</Card.Description> : '未启用'}
</Card.Content> </p>
</Card> </Card.Description>
</Grid.Column> </Card.Content>
</Grid> </Card>
</Segment> </Grid.Column>
</Grid>
</Segment>
</> : <>
{
homePageContent.startsWith('https://') ? <iframe
src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
}
</>
}
</> </>
); );
}; };