feat: charge alpha

This commit is contained in:
Zhang Minghan 2023-12-08 10:15:45 +08:00
parent fa9f7da55e
commit 035e71b2f2
56 changed files with 1012 additions and 365 deletions

View File

@ -9,7 +9,7 @@
🚀 Powerful and beautiful **AI Aggregation** chat platform
[官网](https://chatnio.net) | [开放文档](https://docs.chatnio.net) | [SDKs](https://docs.chatnio.net/kuai-su-kai-shi) | [QQ 群](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=YKcvGGlM03LYWlPk-iosqAqL4qHwOtBx&authKey=6cjCqRNKNuOUJltyo%2FNgmKm%2BS%2FUCtAyVHCnirHyxNuxGExUHsJRtlSaW1EeDxhNx&noverify=0&group_code=565902327)
[官网](https://chatnio.net) | [开放文档](https://docs.chatnio.net) | [SDKs](https://docs.chatnio.net/kuai-su-kai-shi) | [QQ 群](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=1mv1Y8SyxnQVvQCoqhmIgVTbwQmkNmvQ&authKey=5KUA9nJPR29nQwjbsYNknN2Fj6cKePkRes%2B1QZy84Dr4GHYVzcvb0yklxiMMNVJN&noverify=0&group_code=749482576)
[![code-stats](https://stats.deeptrain.net/repo/Deeptrain-Community/chatnio)](https://stats.deeptrain.net)
@ -38,24 +38,26 @@
- 🎁 Image generation
11. 🔔 PWA 应用
- 🔔 PWA application
12. ⚡ Token 计费系统
- ⚡ Token billing system
13. 📚 逆向工程模型支持
12. 📚 逆向工程模型支持
- 📚 Reverse engineering model support
14. 🌏 国际化支持
13. 🌏 国际化支持
- 🌏 Internationalization support
- 🇨🇳 简体中文
- 🇺🇸 English
- 🇷🇺 Русский
15. 🍎 主题切换
14. 🍎 主题切换
- 🍎 Theme switching
16. 🥪 Key 中转服务
15. 🥪 Key 中转服务
- 🥪 Key relay service
17. 🔨 多模型支持
16. 🔨 多模型支持
- 🔨 Multi-model support
18. ⚙ 后台管理系统
- ⚙ Admin system
19. 📂 文件上传功能 (支持 pdf, docx, pptx, xlsx, 音频, 图片等)
17. ⚙ 后台管理系统 (仪表盘,用户管理,公告管理等)
- ⚙ Admin system (dashboard, user management, announcement management, etc.)
18. ⚒ 渠道管理 (多账号均衡负载,优先级调配,权重负载,模型映射,渠道状态管理)
- ⚒ Channel management (multi-account load balancing, priority allocation, weight load, model mapping, channel status management)
19. ⚡ 计费系统 (支持匿名计费按次数计费Token 弹性计费等方式)
- ⚡ Billing system (support anonymous billing, billing by number of times, Token billing, etc.)
20. 📂 文件上传功能 (支持 pdf, docx, pptx, xlsx, 音频, 图片等)
- 📂 File upload function (support pdf, docx, pptx, xlsx, audio, images, etc.)
@ -185,11 +187,6 @@ Replace `https://api.openai.com` with `https://api.chatnio.net` and fill in the
- 应用技术: PWA + HTTP2 + WebSocket + Stream Buffer
## 🎈 感谢 | Thanks
感谢这些开源项目提供的思路:
- ChatGPT 逆向工程: [go-chatgpt-api](https://github.com/linweiyuan/go-chatgpt-api)
- New Bing 逆向工程: [EdgeGPT](https://github.com/acheong08/EdgeGPT)
## 🎃 开发团队 | Team
- [@ProgramZmh](https://github.com/zmh-program) (全栈开发)
- [@Sh1n3zz](https://github.com/sh1n3zz) (全栈开发)

View File

@ -54,7 +54,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
Message: props.Message,
Token: utils.Multi(
props.Token == 0,
utils.Multi(globals.IsFreeModel(model) && !props.Plan, utils.ToPtr(2500), nil),
utils.Multi(props.Infinity || props.Plan, nil, utils.ToPtr(2500)),
&props.Token,
),
PresencePenalty: props.PresencePenalty,

View File

@ -10,14 +10,11 @@ import (
type Hook func(message []globals.Message, token int) (string, error)
func ChatWithWeb(message []globals.Message, long bool) []globals.Message {
data := SearchWebResult(GetPointByLatestMessage(message))
func ChatWithWeb(message []globals.Message) []globals.Message {
data := utils.GetSegmentString(
SearchWebResult(GetPointByLatestMessage(message)), 2048,
)
if long {
data = utils.GetSegmentString(data, 6000)
} else {
data = utils.GetSegmentString(data, 3000)
}
return utils.Insert(message, 0, globals.Message{
Role: globals.System,
Content: fmt.Sprintf("You will play the role of an AI Q&A assistant, where your knowledge base is not offline, but can be networked in real time, and you can provide real-time networked information with links to networked search sources."+

View File

@ -9,7 +9,7 @@ func UsingWebSegment(instance *conversation.Conversation) []globals.Message {
segment := conversation.CopyMessage(instance.GetChatMessage())
if instance.IsEnableWeb() {
segment = ChatWithWeb(segment, globals.IsLongContextModel(instance.GetModel()))
segment = ChatWithWeb(segment)
}
return segment
@ -17,7 +17,7 @@ func UsingWebSegment(instance *conversation.Conversation) []globals.Message {
func UsingWebNativeSegment(enable bool, message []globals.Message) []globals.Message {
if enable {
return ChatWithWeb(message, false)
return ChatWithWeb(message)
} else {
return message
}

View File

@ -63,4 +63,5 @@ type UserData struct {
IsSubscribed bool `json:"is_subscribed"`
TotalMonth int64 `json:"total_month"`
Enterprise bool `json:"enterprise"`
Level int `json:"level"`
}

View File

@ -27,7 +27,7 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
SELECT
auth.id, auth.username, auth.is_admin,
quota.quota, quota.used,
subscription.expired_at, subscription.total_month, subscription.enterprise
subscription.expired_at, subscription.total_month, subscription.enterprise, subscription.level
FROM auth
LEFT JOIN quota ON quota.user_id = auth.id
LEFT JOIN subscription ON subscription.user_id = auth.id
@ -49,8 +49,9 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
usedQuota sql.NullFloat64
totalMonth sql.NullInt64
isEnterprise sql.NullBool
subscriptionLevel sql.NullInt64
)
if err := rows.Scan(&user.Id, &user.Username, &user.IsAdmin, &quota, &usedQuota, &expired, &totalMonth, &isEnterprise); err != nil {
if err := rows.Scan(&user.Id, &user.Username, &user.IsAdmin, &quota, &usedQuota, &expired, &totalMonth, &isEnterprise, &subscriptionLevel); err != nil {
return PaginationForm{
Status: false,
Message: err.Error(),
@ -65,6 +66,9 @@ func GetUserPagination(db *sql.DB, page int64, search string) PaginationForm {
if totalMonth.Valid {
user.TotalMonth = totalMonth.Int64
}
if subscriptionLevel.Valid {
user.Level = int(subscriptionLevel.Int64)
}
stamp := utils.ConvertTime(expired)
if stamp != nil {
user.IsSubscribed = stamp.After(time.Now())

View File

@ -11,6 +11,6 @@
},
"aliases": {
"components": "src/components",
"utils": "src/components/ui/lib/utils"
"utils": "@/components/ui/lib/utils"
}
}

View File

@ -19,6 +19,7 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",

33
app/pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ dependencies:
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-radio-group':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
@ -1213,6 +1216,36 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@types/react': 18.2.33
'@types/react-dom': 18.2.14
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:

View File

@ -1,5 +1,3 @@
import { ChannelCommonResponse } from "@/admin/api/channel.ts";
export type Channel = {
id: number;
name: string;
@ -175,15 +173,3 @@ export function getChannelType(type?: string): string {
if (type && type in ChannelTypes) return ChannelTypes[type];
return ChannelTypes.openai;
}
export function toastState(
toast: any,
t: any,
state: ChannelCommonResponse,
toastSuccess?: boolean,
) {
if (state.status)
toastSuccess &&
toast({ title: t("success"), description: t("request-success") });
else toast({ title: t("error"), description: state.error });
}

15
app/src/admin/charge.ts Normal file
View File

@ -0,0 +1,15 @@
export const tokenBilling = "token-billing";
export const timesBilling = "times-billing";
export const nonBilling = "non-billing";
export const defaultChargeType = tokenBilling;
export const chargeTypes = [nonBilling, timesBilling, tokenBilling];
export type ChargeProps = {
id: number;
models: string[];
type: string;
anonymous: boolean;
input: number;
output: number;
};

View File

@ -66,6 +66,7 @@ export type UserData = {
used_quota: number;
is_subscribed: boolean;
total_month: number;
level: number;
enterprise: boolean;
};

16
app/src/admin/utils.ts Normal file
View File

@ -0,0 +1,16 @@
export type CommonResponse = {
status: boolean;
error: string;
};
export function toastState(
toast: any,
t: any,
state: CommonResponse,
toastSuccess?: boolean,
) {
if (state.status)
toastSuccess &&
toast({ title: t("success"), description: t("request-success") });
else toast({ title: t("error"), description: state.error });
}

View File

@ -3,6 +3,7 @@
@import "management";
@import "broadcast";
@import "channel";
@import "charge";
.admin-page {
position: relative;

View File

@ -1,6 +1,6 @@
.broadcast {
width: 100%;
height: 100%;
height: max-content;
padding: 2rem;
display: flex;
flex-direction: column;

View File

@ -1,6 +1,6 @@
.channel {
width: 100%;
height: 100%;
height: max-content;
padding: 2rem;
display: flex;
flex-direction: column;
@ -10,6 +10,17 @@
height: 100%;
min-height: 20vh;
}
.channel-table {
border-radius: var(--radius);
overflow: hidden;
border: 1px solid hsl(var(--border));
padding-bottom: 0.5rem;
&:hover {
border-color: hsl(var(--border-hover));
}
}
}
.channel-wrapper {

View File

@ -0,0 +1,46 @@
.charge {
width: 100%;
height: max-content;
padding: 2rem;
display: flex;
flex-direction: column;
.charge-card {
width: 100%;
height: 100%;
min-height: 20vh;
}
}
.charge-widget {
height: max-content;
width: 100%;
display: flex;
flex-direction: column;
& > * {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
.charge-editor {
padding: 1.5rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
.token {
color: hsl(var(--text-secondary));
user-select: none;
}
}
.charge-table {
padding-bottom: 1rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
}

View File

@ -45,8 +45,9 @@
height: max-content;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
background: hsl(var(--background-container));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
box-shadow: 0.5rem 0.5rem 1rem 0 var(--shadow);
user-select: none;
max-width: 460px;
@ -113,10 +114,10 @@
padding: 1rem 2rem;
margin: 0.5rem;
border-radius: var(--radius);
background: hsl(var(--background-container));
background: hsl(var(--background));
box-shadow: 0.5rem 0.5rem 1rem 0 var(--shadow);
border: 1px solid hsl(var(--border));
user-select: none;
box-shadow: 0 0 1rem 0 hsla(var(--foreground), 0.1);
@media (max-width: 680px) {
width: 100%;

View File

@ -2,8 +2,7 @@
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: calc(100vh - 56px);
height: calc(100vh - 56px);
padding: 2rem;
& > * {

View File

@ -4,9 +4,9 @@
flex-direction: column;
width: 0;
height: 100%;
padding: 0;
padding: 0.75rem 0;
margin: 0;
background: var(--background-sidebar);
background: hsl(var(--background));
transition: 0.225s ease-in-out;
min-height: calc(100vh - 56px);
transition-property: width, background, box-shadow, opacity;
@ -28,13 +28,13 @@
height: max-content;
padding: 0.75rem 1rem;
align-items: center;
background: var(--conversation-card);
margin: 0.75rem 0.75rem -0.25rem;
margin: 0 0.75rem;
user-select: none;
cursor: pointer;
transition: 0.2s ease-in-out;
border-radius: var(--radius);
font-size: 16px;
color: hsl(var(--text-secondary));
&:before {
content: '';
@ -50,16 +50,16 @@
}
&:hover {
background: var(--conversation-card-hover);
background: hsl(var(--card-hover));
&:before {
opacity: .25;
opacity: .05;
margin-right: 0.75rem;
}
}
&.active {
background: var(--conversation-card-active);
color: hsl(var(--text));
&:before {
opacity: 1;
@ -92,14 +92,9 @@
.admin-content {
flex-grow: 1;
height: max-content;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
overflow-x: hidden;
overflow-y: auto;
touch-action: pan-y;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
background: hsla(var(--background-container));
}

View File

@ -4,57 +4,59 @@
@layer base {
:root {
--background: 71 65% 97%;
--background-hover: 56 43% 97%;
--background-container: 71 65% 97%;
--foreground: 222.2 84% 4.9%;
--background: 0 0% 100%;
--background-hover: 5 12% 100%;
--background-container: 0, 0%, 97%, 0.8;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-hover: 0 0% 97.5%;
--card-active: 0 0% 95%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 60 26% 92%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 37 26% 90%;
--accent-secondary: 37 26% 95%;
--accent: 240 4.8% 95.9%;
--accent-secondary: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 67.22% 50.59%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--border-hover: 240 5.9% 85%;
--border-active: 240 5.9% 80%;
--input: 240 5.9% 90%;
--input-unread: 240 5.9% 50%;
--ring: 240 5% 64.9%;
--border: 37 26% 83%;
--border-hover: 37 26% 78%;
--border-active: 37 26% 73%;
--input: 37 26% 90%;
--input-unread: 37 26% 70%;
--ring: 222.2 84% 4.9%;
--text: 0 0% 0%;
--text-dark: 0 0% 100%;
--text-secondary: 0 0% 20%;
--text-secondary-dark: 0 0% 80%;
--selection: 212 100% 41%;
--selection-foreground: 0 0% 98%;
--radius: 0.5rem;
--conversation-card: rgba(222,214,200,.05);
--conversation-card-hover: rgba(222,214,200,.2);
--conversation-card-active: rgba(222,214,200,.3);
--conversation-card-border: rgba(222,214,200,.5);
--shadow: #00000005;
--background-sidebar: rgba(240, 242, 231, 0.25);
--chat-bot-background: rgba(88, 166, 255, .04);
--chat-bot-border: rgba(88, 166, 255, .12);
--chat-bot-border-hover: rgba(88, 166, 255, .24);
--model-card-background: rgba(222,214,200,.2);
--assistant-background: hsla(218, 100%, 64%, .04);
--assistant-border: hsla(218, 100%, 64%, .12);
--assistant-border-hover: hsla(218, 100%, 64%, .24);
--assistant-shadow: hsla(218, 100%, 64%, .03);
--gold: 45 100% 50%;
}
@ -62,10 +64,12 @@
.dark {
--background: 0 0% 0%;
--background-hover: 0 0% 7.8%;
--background-container: 0 0% 7.8%;
--background-container: 0, 0%, 7.8%, 0.8;
--foreground: 210 40% 98%;
--card: 240 10% 3.9%;
--card-hover: 240 10% 8.9%;
--card-active: 240 10% 13.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
@ -81,7 +85,7 @@
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-secondary: 240 3.7% 15.9%;
--accent-secondary: 240 5% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
@ -96,17 +100,12 @@
--text: 0 0% 100%;
--text-secondary: 0 0% 80%;
--conversation-card: rgba(152,153,165,.05);
--conversation-card-hover: rgba(152,153,165,.2);
--conversation-card-active: rgba(152,153,165,.3);
--conversation-card-border: rgba(152,153,165,.1);
--background-sidebar: rgba(0, 0, 0, 0.25);
--shadow: #ffffff05;
--chat-bot-background: rgba(88, 166, 255, .1);
--chat-bot-border: rgba(88, 166, 255, .2);
--chat-bot-border-hover: rgba(88, 166, 255, .25);
--model-card-background: rgba(152,153,165,.05);
--assistant-background: hsla(218, 100%, 64%, .1);
--assistant-border: hsla(218, 100%, 64%, .2);
--assistant-border-hover: hsla(218, 100%, 64%, .25);
--assistant-shadow: hsla(218, 100%, 64%, .05);
}
}

View File

@ -147,7 +147,7 @@ strong {
height: max-content;
min-height: calc(100vh - 56px);
overflow: hidden;
background: hsl(var(--background-container));
background: hsla(var(--background-container));
padding: 2.5rem 5rem;
@media (max-width: 720px) {

View File

@ -11,7 +11,7 @@
margin-bottom: 4px !important;
padding: 6px 12px !important;
border: 1px solid hsl(var(--border-hover));
background: hsl(var(--background-container)) !important;
background: hsla(var(--background-container)) !important;
border-radius: var(--radius);
@media (max-width: 668px) {

View File

@ -2,5 +2,5 @@
width: 100%;
height: calc(100vh - 56px);
overflow: hidden;
background: hsl(var(--background-container));
background: hsla(var(--background-container));
}

View File

@ -144,16 +144,28 @@
align-items: flex-end;
}
&.user {
.message-content {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
&:hover {
border-color: hsl(var(--border-hover));
}
}
}
&.assistant,
&.system {
align-items: flex-start;
.message-content {
background: var(--chat-bot-background);
border: 1px solid var(--chat-bot-border);
background: var(--assistant-background);
border: 1px solid var(--assistant-border);
box-shadow: 0.25rem 0.25rem 1rem 0 var(--assistant-shadow);
&:hover {
border-color: var(--chat-bot-border-hover);
border-color: var(--assistant-border-hover);
}
}
}

View File

@ -116,7 +116,7 @@
color: hsl(var(--text));
font-size: 1rem;
font-weight: 500;
background: hsl(var(--background-container));
background: hsla(var(--background-container));
&:hover {
border: 1px solid hsl(var(--border-hover));

View File

@ -5,7 +5,7 @@
width: 100%;
height: calc(100vh - 56px);
overflow: hidden;
background: hsl(var(--background-container));
background: hsla(var(--background-container));
}
.model-market {
@ -126,7 +126,7 @@
left: 0;
width: 0;
height: 100%;
background: var(--model-card-background);
background: hsl(var(--background));
transition: 0.5s;
z-index: -1;
border-radius: var(--radius);
@ -277,7 +277,7 @@
height: 100%;
padding: 0;
margin: 0;
background: var(--background-sidebar);
background: hsl(var(--background));
transition: 0.2s ease-in-out;
transition-property: width, background, box-shadow, border-right, opacity;
border-right: 0;
@ -381,9 +381,9 @@
margin: 0 6px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--conversation-card-border);
border: 1px solid hsl(var(--border));
transition: 0.2s ease-in-out;
background: var(--conversation-card);
background: hsl(var(--card));
.more {
color: hsl(var(--text-secondary));
@ -399,7 +399,8 @@
}
&:hover {
background: var(--conversation-card-hover);
background: hsl(var(--card-hover));
border-color: hsl(var(--border-hover));
.id {
display: none;
@ -412,7 +413,8 @@
}
&.active {
background: var(--conversation-card-active);
background: hsl(var(--card-hover));
border-color: hsl(var(--border-active));
}
}
@ -508,7 +510,7 @@
flex: 1 1 auto;
min-width: 0;
height: 100%;
background: hsl(var(--background-container));
background: hsla(var(--background-container));
transition: width 0.2s ease-in-out;
.chat-wrapper {

View File

@ -114,7 +114,7 @@
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border));
background: hsl(var(--background-container));
background: hsla(var(--background-container));
border-radius: var(--radius);
user-select: none;
cursor: pointer;

View File

@ -58,7 +58,7 @@
display: flex;
flex-direction: row;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--background-container));
background: hsla(var(--background-container));
color: hsl(var(--text));
padding: 0.85rem 1rem 0.75rem;
align-items: center;

View File

@ -30,24 +30,12 @@
color: hsl(var(--text));
&:hover {
background: hsl(var(--accent));
background: hsl(var(--accent-secondary));
}
&.active {
background: hsl(var(--text));
border-color: hsl(var(--border-hover));
color: hsl(var(--background));
.select-element.badge {
&.badge-default {
background: hsl(var(--background)) !important;
color: hsl(var(--text));
&:hover {
background: hsl(var(--background)) !important;
}
}
}
background: hsl(var(--accent));
}
}
}
@ -123,3 +111,15 @@ input[type="number"] {
transform: rotate(135deg);
}
}
.selection {
::selection {
color: hsl(var(--selection-foreground));
background: hsl(var(--selection));
}
::-moz-selection {
color: hsl(var(--selection-foreground));
background: hsl(var(--selection));
}
}

View File

@ -36,7 +36,7 @@ function OperationAction({ tooltip, children, onClick, variant }: ActionProps) {
<PopoverTrigger asChild>
<Button
size={`icon`}
className={`mx-1 w-8 h-8`}
className={`w-8 h-8`}
variant={variant}
>
{children}
@ -56,7 +56,7 @@ function OperationAction({ tooltip, children, onClick, variant }: ActionProps) {
) : (
<Button
size={`icon`}
className={`mx-1 w-8 h-8`}
className={`w-8 h-8`}
onClick={onClick}
variant={variant}
>

View File

@ -0,0 +1,367 @@
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
ChargeProps,
chargeTypes,
defaultChargeType, nonBilling, timesBilling, tokenBilling,
} from "@/admin/charge.ts";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input.tsx";
import {useMemo, useReducer, useState} from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Cloud,
DownloadCloud,
Eraser,
EyeOff,
Minus,
Plus, RotateCw,
Search,
Settings2, Trash,
UploadCloud,
} from "lucide-react";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu.tsx";
import {Command, CommandInput, CommandItem, CommandList} from "@/components/ui/command.tsx";
import {ChannelModels} from "@/admin/channel.ts";
import {toastState} from "@/admin/utils.ts";
import {Switch} from "@/components/ui/switch.tsx";
import {NumberInput} from "@/components/ui/number-input.tsx";
import {Table, TableBody, TableCell, TableHeader, TableRow} from "@/components/ui/table.tsx";
import OperationAction from "@/components/OperationAction.tsx";
import {Badge} from "@/components/ui/badge.tsx";
import { useToast } from "@/components/ui/use-toast";
const initialState: ChargeProps = {
id: 0,
type: defaultChargeType,
models: [],
anonymous: false,
input: 0,
output: 0,
};
function reducer(state: ChargeProps, action: any): ChargeProps {
switch (action.type) {
case "set-models":
return { ...state, models: action.payload };
case "add-model":
const model = action.payload.trim();
if (model.length === 0 || state.models.includes(model)) return state;
return { ...state, models: [...state.models, model] };
case "remove-model":
return {
...state,
models: state.models.filter((model) => model !== action.payload),
};
case "set-type":
return { ...state, type: action.payload };
case "set-anonymous":
return { ...state, anonymous: action.payload };
case "set-input":
return { ...state, input: action.payload };
case "set-output":
return { ...state, output: action.payload };
case "clear":
return initialState;
case "clear-param":
return { ...initialState, id: state.id };
default:
return state;
}
}
function preflight(state: ChargeProps): ChargeProps {
state.models = state.models.map((model) => model.trim()).filter((model) => model.length > 0);
switch (state.type) {
case nonBilling:
state.input = 0;
state.output = 0;
break;
case timesBilling:
state.input = 0;
state.anonymous = false;
break;
case tokenBilling:
state.anonymous = false;
break;
}
if (state.input < 0) state.input = 0;
if (state.output < 0) state.output = 0;
return state;
}
function ChargeEditor() {
const { t } = useTranslation();
const { toast } = useToast();
const [model, setModel] = useState("");
const [form, dispatch] = useReducer(reducer, initialState);
const unusedModels = useMemo(() => {
return ChannelModels.filter(
(model) => !form.models.includes(model) && model !== "",
);
}, [form.models]);
async function post() {
const data = preflight({ ...form });
console.log(data);
toastState(toast, t, {});
dispatch({ type: "clear" });
}
return (
<div className={`charge-editor`}>
<div className={`w-full h-max mb-5`}>
<RadioGroup
value={form.type}
onValueChange={(value) =>
dispatch({ type: "set-type", payload: value })
}
className={`flex flex-row gap-5 whitespace-nowrap flex-wrap`}
>
{chargeTypes.map((chargeType, index) => (
<div
className="flex items-center space-x-2 cursor-pointer"
key={index}
>
<RadioGroupItem
className={`transition-all duration-200`}
value={chargeType}
id={chargeType}
/>
<Label htmlFor={chargeType} className={`cursor-pointer`}>
{t(`admin.charge.${chargeType}`)}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className={`flex flex-row w-full h-max mb-4`}>
<Button
onClick={() => dispatch({ type: "add-model", payload: model })}
size={`icon`}
className={`mr-2 shrink-0`}
>
<Plus className={`w-4 h-4`} />
</Button>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={t("admin.channels.model")}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={`icon`} className={`ml-2 shrink-0`}>
<Search className={`w-4 h-4`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`} asChild>
<Command>
<CommandInput
placeholder={t("admin.channels.search-model")}
/>
<CommandList className={`thin-scrollbar`}>
{unusedModels.map((model, idx) => (
<CommandItem
key={idx}
value={model}
onSelect={(value) => dispatch({ type: "add-model", payload: value })}
className={`px-2`}
>
{model}
</CommandItem>
))}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={`flex flex-col w-full h-max mb-2`}>
{form.models.map((model, index) => (
<div className={`flex flex-row w-full h-max shrink-0 mb-2 select-none`} key={index}>
<Input value={model} readOnly />
<Button
onClick={() => dispatch({ type: "remove-model", payload: model })}
size={`icon`} variant={`outline`}
className={`ml-2 shrink-0`}
>
<Minus className={`w-4 h-4`} />
</Button>
</div>
))}
</div>
{
form.type === nonBilling && (
<div className={`flex flex-row w-full h-max items-center mt-4 mb-6`}>
<EyeOff className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>{t("admin.charge.anonymous")}</Label>
<Switch
checked={form.anonymous}
onCheckedChange={(checked) => dispatch({ type: "set-anonymous", payload: checked })}
/>
</div>
)
}
{
form.type === timesBilling && (
<div className={`flex flex-row w-full h-max items-center`}>
<Cloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>{t("admin.charge.time-count")}</Label>
<NumberInput
value={form.output}
onValueChange={(value) => dispatch({ type: "set-output", payload: value })}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
)
}
{
form.type === tokenBilling && (
<div className={`flex flex-col w-full h-max gap-2`}>
<div className={`flex flex-row w-full h-max items-center`}>
<UploadCloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>
{t("admin.charge.input-count")}
<span className={`token`}> / 1k tokens</span>
</Label>
<NumberInput
value={form.input}
onValueChange={(value) => dispatch({ type: "set-input", payload: value })}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
<div className={`flex flex-row w-full h-max items-center`}>
<DownloadCloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>
{t("admin.charge.output-count")}
<span className={`token`}> / 1k tokens</span>
</Label>
<NumberInput
value={form.output}
onValueChange={(value) => dispatch({ type: "set-output", payload: value })}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
</div>
)
}
<div className={`flex flex-row w-full h-max mt-5 gap-2`}>
<div className={`grow`} />
<Button variant={`outline`} size={`icon`} onClick={() => dispatch({ type: "clear-param" })}>
<Eraser className={`w-4 h-4`} />
</Button>
<Button onClick={post}>
<Plus className={`w-4 h-4 mr-2`} />
{t("admin.charge.add-rule")}
</Button>
</div>
</div>
);
}
function ChargeTable() {
const { t } = useTranslation();
const { toast } = useToast();
const [data, setData] = useState<ChargeProps[]>([
{
id: 1,
type: nonBilling,
models: ["gpt-4", "gpt-4-0613"],
anonymous: true,
input: 0,
output: 0,
},
]);
function refresh() {
}
return (
<div className={`charge-table`}>
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableCell>{t("admin.charge.id")}</TableCell>
<TableCell>{t("admin.charge.type")}</TableCell>
<TableCell>{t("admin.charge.model")}</TableCell>
<TableCell>{t("admin.charge.input")}</TableCell>
<TableCell>{t("admin.charge.output")}</TableCell>
<TableCell>{t("admin.charge.support-anonymous")}</TableCell>
<TableCell>{t("admin.charge.action")}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((charge, idx) => (
<TableRow key={idx}>
<TableCell>{charge.id}</TableCell>
<TableCell>
<Badge>
{charge.type.split("-")[0]}
</Badge>
</TableCell>
<TableCell className={`select-none`}>
<pre>{charge.models.join("\n")}</pre>
</TableCell>
<TableCell>{charge.input === 0 ? 0 : charge.input.toFixed(2)}</TableCell>
<TableCell>{charge.output === 0 ? 0 : charge.output.toFixed(2)}</TableCell>
<TableCell>
{t(String(charge.anonymous))}
</TableCell>
<TableCell className={`flex flex-row flex-wrap gap-2`}>
<OperationAction
tooltip={t("admin.channels.edit")}
>
<Settings2 className={`h-4 w-4`} />
</OperationAction>
<OperationAction
tooltip={t("admin.channels.delete")}
variant={`destructive`}
>
<Trash className={`h-4 w-4`} />
</OperationAction>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className={`mt-6 pr-2 flex flex-row w-full h-max`}>
<div className={`grow`} />
<Button
variant={`outline`}
size={`icon`}
className={`mr-2`}
onClick={refresh}
>
<RotateCw className={`h-4 w-4`} />
</Button>
</div>
</div>
)
}
function ChargeWidget() {
return (
<div className={`charge-widget`}>
<ChargeEditor />
<ChargeTable />
</div>
);
}
export default ChargeWidget;

View File

@ -66,7 +66,7 @@ function MenuBar() {
<MenuItem
title={t("admin.prize")}
icon={<CandlestickChart />}
path={"/prize"}
path={"/charge"}
/>
</div>
);

View File

@ -156,6 +156,7 @@ function UserTable() {
<TableHead>{t("admin.quota")}</TableHead>
<TableHead>{t("admin.used-quota")}</TableHead>
<TableHead>{t("admin.is-subscribed")}</TableHead>
<TableHead>{t("admin.level")}</TableHead>
<TableHead>{t("admin.total-month")}</TableHead>
<TableHead>{t("admin.enterprise")}</TableHead>
<TableHead>{t("admin.is-admin")}</TableHead>
@ -166,10 +167,11 @@ function UserTable() {
{(data.data || []).map((user, idx) => (
<TableRow key={idx}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell className={`whitespace-nowrap`}>{user.username}</TableCell>
<TableCell>{user.quota}</TableCell>
<TableCell>{user.used_quota}</TableCell>
<TableCell>{t(user.is_subscribed.toString())}</TableCell>
<TableCell>{user.level}</TableCell>
<TableCell>{user.total_month}</TableCell>
<TableCell>{t(user.enterprise.toString())}</TableCell>
<TableCell>{t(user.is_admin.toString())}</TableCell>

View File

@ -13,8 +13,8 @@ import {
ChannelModels,
ChannelTypes,
getChannelInfo,
toastState,
} from "@/admin/channel.ts";
import {toastState} from "@/admin/utils.ts";
import { Textarea } from "@/components/ui/textarea.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import { Button } from "@/components/ui/button.tsx";
@ -288,7 +288,7 @@ function ChannelEditor({ display, id, setEnabled }: ChannelEditorProps) {
<DropdownMenuTrigger asChild>
<Button>{t("admin.channels.add-model")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent asChild>
<DropdownMenuContent align={`start`} asChild>
<Command>
<CommandInput
placeholder={t("admin.channels.search-model")}

View File

@ -10,7 +10,8 @@ import { Check, Plus, RotateCw, Settings2, Trash, X } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import OperationAction from "@/components/OperationAction.tsx";
import { useEffect, useMemo, useState } from "react";
import { Channel, getChannelType, toastState } from "@/admin/channel.ts";
import { Channel, getChannelType } from "@/admin/channel.ts";
import {toastState} from "@/admin/utils.ts";
import { useTranslation } from "react-i18next";
import { useEffectAsync } from "@/utils/hook.ts";
import {
@ -87,10 +88,10 @@ function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) {
{chan.state ? (
<Check className={`h-4 w-4 text-green-500`} />
) : (
<X className={`h-4 w-4 text-red-500`} />
<X className={`h-4 w-4 text-destructive`} />
)}
</TableCell>
<TableCell>
<TableCell className={`flex flex-row flex-wrap gap-2`}>
<OperationAction
tooltip={t("admin.channels.edit")}
onClick={() => {

View File

@ -0,0 +1,42 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/components/ui/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -141,7 +141,7 @@ function SettingsDialog() {
<div className={`name`}>{t("settings.history")}</div>
<div className={`grow`} />
<NumberInput
className={cn(`value`, history === 0 && `text-red-500`)}
className={cn(`value`, history === 0 && `text-destructive`)}
value={history}
acceptNaN={false}
min={0}

View File

@ -345,6 +345,7 @@ const resources = {
"post-success-prompt": "公告发布成功。",
"post-failed": "发布失败",
"post-failed-prompt": "发布失败,原因:{{reason}}",
level: "等级",
"is-admin": "管理员",
"used-quota": "已用点数",
"is-subscribed": "是否订阅",
@ -402,6 +403,24 @@ const resources = {
"add-model": "添加模型",
"clear-models": "清空全部模型",
},
charge: {
id: "ID",
type: "类型",
model: "模型",
quota: "点数",
action: "操作",
input: "输入",
output: "输出",
"support-anonymous": "支持匿名",
"non-billing": "不计费",
"times-billing": "按次计费",
"token-billing": "按 Token 计费",
anonymous: "支持匿名调用",
"time-count": "单次请求点数",
"input-count": "输入点数",
"output-count": "输出点数",
"add-rule": "添加规则",
},
},
mask: {
title: "预设设置",
@ -759,6 +778,7 @@ const resources = {
"post-success-prompt": "Broadcast posted successfully.",
"post-failed": "Post Failed",
"post-failed-prompt": "Post failed, reason: {{reason}}",
level: "Level",
"is-admin": "Admin",
"used-quota": "Used Quota",
"is-subscribed": "Subscribed",
@ -1182,6 +1202,7 @@ const resources = {
"post-failed": "Не удалось",
"post-failed-prompt":
"Не удалось отправить объявление, причина: {{reason}}",
level: "Уровень",
"is-admin": "Админ",
"used-quota": "Использовано",
"is-subscribed": "Подписан",

View File

@ -11,7 +11,7 @@ const Article = lazy(() => import("@/routes/Article.tsx"));
const Admin = lazy(() => import("@/routes/Admin.tsx"));
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
const Channel = lazy(() => import("@/routes/admin/Channel.tsx"));
const Prize = lazy(() => import("@/routes/admin/Prize.tsx"));
const Charge = lazy(() => import("@/routes/admin/Charge.tsx"));
const Users = lazy(() => import("@/routes/admin/Users.tsx"));
const Broadcast = lazy(() => import("@/routes/admin/Broadcast.tsx"));
@ -95,11 +95,11 @@ const router = createBrowserRouter([
),
},
{
id: "admin-prize",
path: "prize",
id: "admin-charge",
path: "charge",
element: (
<Suspense>
<Prize />
<Charge />
</Suspense>
),
},

View File

@ -17,7 +17,7 @@ function Admin() {
return (
<div className={`admin-page`}>
<MenuBar />
<div className={`admin-content`}>
<div className={`admin-content thin-scrollbar`}>
<Outlet />
</div>
</div>

View File

@ -0,0 +1,27 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import ChargeWidget from "@/components/admin/ChargeWidget.tsx";
function Charge() {
const { t } = useTranslation();
return (
<div className={`charge`}>
<Card className={`charge-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.prize")}</CardTitle>
</CardHeader>
<CardContent>
<ChargeWidget />
</CardContent>
</Card>
</div>
);
}
export default Charge;

View File

@ -1,5 +0,0 @@
function Prize() {
return <></>;
}
export default Prize;

View File

@ -1,41 +1,23 @@
package auth
import (
"chat/globals"
"chat/channel"
"database/sql"
"github.com/go-redis/redis/v8"
)
// CanEnableModel returns whether the model can be enabled (without subscription)
func CanEnableModel(db *sql.DB, user *User, model string) bool {
switch model {
case globals.GPT3Turbo, globals.GPT3TurboInstruct, globals.GPT3Turbo0301, globals.GPT3Turbo0613:
return true
case globals.GPT4, globals.GPT40613, globals.GPT40314, globals.GPT41106Preview, globals.GPT41106VisionPreview,
globals.GPT4Dalle, globals.GPT4Vision, globals.Dalle3:
return user != nil && user.GetQuota(db) >= 5
case globals.GPT432k, globals.GPT432k0613, globals.GPT432k0314:
return user != nil && user.GetQuota(db) >= 50
case globals.SparkDesk, globals.SparkDeskV2, globals.SparkDeskV3:
return user != nil && user.GetQuota(db) >= 1
case globals.Claude1100k, globals.Claude2100k, globals.Claude2200k:
return user != nil && user.GetQuota(db) >= 1
case globals.ZhiPuChatGLMTurbo, globals.ZhiPuChatGLMPro, globals.ZhiPuChatGLMStd:
return user != nil && user.GetQuota(db) >= 1
case globals.QwenTurbo, globals.QwenPlus, globals.QwenPlusNet, globals.QwenTurboNet:
return user != nil && user.GetQuota(db) >= 1
case globals.StableDiffusion, globals.Midjourney, globals.MidjourneyFast, globals.MidjourneyTurbo:
return user != nil && user.GetQuota(db) >= 1
case globals.LLaMa27B, globals.LLaMa213B, globals.LLaMa270B,
globals.CodeLLaMa34B, globals.CodeLLaMa13B, globals.CodeLLaMa7B:
return user != nil && user.GetQuota(db) >= 1
case globals.Hunyuan, globals.GPT360V9, globals.Baichuan53B:
return user != nil && user.GetQuota(db) >= 1
case globals.SkylarkLite, globals.SkylarkPlus, globals.SkylarkPro, globals.SkylarkChat:
return user != nil && user.GetQuota(db) >= 1
default:
return user != nil
isAuth := user != nil
charge := channel.ChargeInstance.GetCharge(model)
if !charge.IsBilling() {
// return if is the user is authenticated or anonymous is allowed for this model
return charge.SupportAnonymous() || isAuth
}
// return if the user is authenticated and has enough quota
return isAuth && user.GetQuota(db) >= charge.GetLimit()
}
func CanEnableModelWithSubscription(db *sql.DB, cache *redis.Client, user *User, model string) (canEnable bool, usePlan bool) {

179
channel/charge.go Normal file
View File

@ -0,0 +1,179 @@
package channel
import (
"chat/globals"
"chat/utils"
"github.com/spf13/viper"
)
func NewChargeManager() *ChargeManager {
var seq ChargeSequence
if err := viper.UnmarshalKey("charge", &seq); err != nil {
panic(err)
}
m := &ChargeManager{
Sequence: seq,
Models: map[string]*Charge{},
NonBillingModels: []string{},
}
m.Load()
return m
}
func (m *ChargeManager) Load() {
// init support models
m.Models = map[string]*Charge{}
for _, charge := range m.Sequence {
for _, model := range charge.Models {
if _, ok := m.Models[model]; !ok {
m.Models[model] = charge
}
}
}
m.NonBillingModels = []string{}
for _, charge := range m.Sequence {
if !charge.IsBilling() {
for _, model := range charge.Models {
m.NonBillingModels = append(m.NonBillingModels, model)
}
}
}
}
func (m *ChargeManager) GetModels() map[string]*Charge {
return m.Models
}
func (m *ChargeManager) GetNonBillingModels() []string {
return m.NonBillingModels
}
func (m *ChargeManager) IsBilling(model string) bool {
return !utils.Contains(model, m.NonBillingModels)
}
func (m *ChargeManager) GetCharge(model string) Charge {
if charge, ok := m.Models[model]; ok {
return *charge
}
return Charge{
Type: globals.NonBilling,
Anonymous: false,
}
}
func (m *ChargeManager) SaveConfig() error {
viper.Set("charge", m.Sequence)
m.Load()
return viper.WriteConfig()
}
func (m *ChargeManager) GetMaxId() int {
max := 0
for _, charge := range m.Sequence {
if charge.Id > max {
max = charge.Id
}
}
return max
}
func (m *ChargeManager) AddRule(charge Charge) error {
charge.Id = m.GetMaxId() + 1
m.Sequence = append(m.Sequence, &charge)
return m.SaveConfig()
}
func (m *ChargeManager) UpdateRule(charge Charge) error {
for _, item := range m.Sequence {
if item.Id == charge.Id {
*item = charge
break
}
}
return m.SaveConfig()
}
func (m *ChargeManager) SetRule(charge Charge) error {
if charge.Id == -1 {
return m.AddRule(charge)
}
return m.UpdateRule(charge)
}
func (m *ChargeManager) DeleteRule(id int) error {
for i, item := range m.Sequence {
if item.Id == id {
m.Sequence = append(m.Sequence[:i], m.Sequence[i+1:]...)
break
}
}
return m.SaveConfig()
}
func (m *ChargeManager) ListRules() ChargeSequence {
return m.Sequence
}
func (m *ChargeManager) GetRule(id int) *Charge {
for _, item := range m.Sequence {
if item.Id == id {
return item
}
}
return nil
}
func (c *Charge) GetType() string {
if c.Type == "" {
return globals.NonBilling
}
return c.Type
}
func (c *Charge) GetModels() []string {
return c.Models
}
func (c *Charge) GetInput() float32 {
if c.Input <= 0 {
return 0
}
return c.Input
}
func (c *Charge) GetOutput() float32 {
if c.Output <= 0 {
return 0
}
return c.Output
}
func (c *Charge) SupportAnonymous() bool {
return c.Anonymous
}
func (c *Charge) IsBilling() bool {
return c.GetType() != globals.NonBilling
}
func (c *Charge) IsBillingType(t string) bool {
return c.GetType() == t
}
func (c *Charge) GetLimit() float32 {
switch c.GetType() {
case globals.NonBilling:
return 0
case globals.TimesBilling:
return c.GetOutput()
case globals.TokenBilling:
// 1k input tokens + 1k output tokens
return c.GetInput() + c.GetOutput()
default:
return 0
}
}

View File

@ -89,3 +89,37 @@ func UpdateChannel(c *gin.Context) {
"error": utils.GetError(state),
})
}
func SetCharge(c *gin.Context) {
var charge Charge
if err := c.ShouldBindJSON(&charge); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": false,
"error": err.Error(),
})
return
}
state := ChargeInstance.SetRule(charge)
c.JSON(http.StatusOK, gin.H{
"status": state == nil,
"error": utils.GetError(state),
})
}
func GetChargeList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": true,
"data": ChargeInstance.ListRules(),
})
}
func DeleteCharge(c *gin.Context) {
id := c.Param("id")
state := ChargeInstance.DeleteRule(utils.ParseInt(id))
c.JSON(http.StatusOK, gin.H{
"status": state == nil,
"error": utils.GetError(state),
})
}

View File

@ -7,9 +7,11 @@ import (
)
var ManagerInstance *Manager
var ChargeInstance *ChargeManager
func InitManager() {
ManagerInstance = NewManager()
ChargeInstance = NewChargeManager()
}
func NewManager() *Manager {

View File

@ -10,4 +10,8 @@ func Register(app *gin.Engine) {
app.GET("/admin/channel/delete/:id", DeleteChannel)
app.GET("/admin/channel/activate/:id", ActivateChannel)
app.GET("/admin/channel/deactivate/:id", DeactivateChannel)
app.GET("/admin/charge/list", GetChargeList)
app.POST("/admin/charge/set", SetCharge)
app.GET("/admin/charge/delete/:id", DeleteCharge)
}

View File

@ -28,3 +28,20 @@ type Ticker struct {
Sequence Sequence `json:"sequence"`
Cursor int `json:"cursor"`
}
type Charge struct {
Id int `json:"id" mapstructure:"id"`
Type string `json:"type" mapstructure:"type"`
Models []string `json:"models" mapstructure:"models"`
Input float32 `json:"input" mapstructure:"input"`
Output float32 `json:"output" mapstructure:"output"`
Anonymous bool `json:"anonymous" mapstructure:"anonymous"`
}
type ChargeSequence []*Charge
type ChargeManager struct {
Sequence ChargeSequence `json:"sequence"`
Models map[string]*Charge `json:"models"`
NonBillingModels []string `json:"non_billing_models"`
}

View File

@ -3,13 +3,12 @@ package cli
import (
"chat/adapter/chatgpt"
"fmt"
"github.com/spf13/viper"
"strings"
)
func FilterApiKeyCommand(args []string) {
data := strings.Trim(strings.TrimSpace(GetArgString(args, 0)), "\"")
endpoint := viper.GetString("openai.test")
endpoint := "https://api.openai.com"
keys := strings.Split(data, "|")
available := chatgpt.FilterKeysNative(endpoint, keys)

View File

@ -23,3 +23,9 @@ const (
MidjourneyChannelType = "midjourney"
OneAPIChannelType = "oneapi"
)
const (
NonBilling = "non-billing"
TimesBilling = "times-billing"
TokenBilling = "token-billing"
)

View File

@ -104,44 +104,6 @@ var GPT4Array = []string{
GPT4Vision, GPT4Dalle, GPT4All,
}
var GPT432kArray = []string{
GPT432k,
GPT432k0314,
GPT432k0613,
}
var SparkDeskModelArray = []string{
SparkDesk,
SparkDeskV2,
SparkDeskV3,
}
var LongContextModelArray = []string{
GPT3Turbo16k, GPT3Turbo16k0613, GPT3Turbo16k0301,
GPT41106Preview, GPT41106VisionPreview, GPT432k, GPT432k0314, GPT432k0613,
Claude1, Claude1100k,
CodeLLaMa34B, LLaMa270B,
Claude2, Claude2100k, Claude2200k,
}
var FreeModelArray = []string{
GPT3Turbo,
GPT3TurboInstruct,
GPT3Turbo0613,
GPT3Turbo0301,
GPT3Turbo1106,
GPT3Turbo16k,
GPT3Turbo16k0613,
GPT3Turbo16k0301,
Claude1,
Claude2,
ChatBison001,
BingCreative,
BingBalanced,
BingPrecise,
ZhiPuChatGLMLite,
}
func in(value string, slice []string) bool {
for _, item := range slice {
if item == value {
@ -166,15 +128,3 @@ func IsClaude100KModel(model string) bool {
func IsMidjourneyFastModel(model string) bool {
return model == MidjourneyFast
}
func IsSparkDeskModel(model string) bool {
return in(model, SparkDeskModelArray)
}
func IsLongContextModel(model string) bool {
return in(model, LongContextModelArray)
}
func IsFreeModel(model string) bool {
return in(model, FreeModelArray)
}

View File

@ -1,6 +1,7 @@
package manager
import (
"chat/channel"
"chat/globals"
"chat/utils"
"fmt"
@ -28,7 +29,7 @@ func ExtractCacheData(c *gin.Context, props *CacheProps) *CacheData {
}
func SaveCacheData(c *gin.Context, props *CacheProps, data *CacheData) {
if !globals.IsFreeModel(props.Model) {
if channel.ChargeInstance.IsBilling(props.Model) {
return
}

View File

@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"strings"
"time"
)
type Limiter struct {
@ -15,16 +14,10 @@ type Limiter struct {
Count int64
}
func (l *Limiter) RateLimit(ctx *gin.Context, rds *redis.Client, ip string, path string) bool {
key := fmt.Sprintf("rate%s:%s", path, ip)
count, err := rds.Incr(ctx, key).Result()
if err != nil {
return true
}
if count == 1 {
rds.Expire(ctx, key, time.Duration(l.Duration)*time.Second)
}
return count > l.Count
func (l *Limiter) RateLimit(client *redis.Client, ip string, path string) bool {
key := fmt.Sprintf("rate:%s:%s", path, ip)
rate := utils.IncrWithLimit(client, key, 1, l.Count, int64(l.Duration))
return !rate
}
var limits = map[string]Limiter{
@ -62,7 +55,7 @@ func ThrottleMiddleware() gin.HandlerFunc {
cache := utils.GetCacheFromContext(c)
admin.IncrRequest(cache)
limiter := GetPrefixMap[Limiter](path, limits)
if limiter != nil && limiter.RateLimit(c, cache, ip, path) {
if limiter != nil && limiter.RateLimit(cache, ip, path) {
c.JSON(200, gin.H{"status": false, "reason": "You have sent too many requests. Please try again later."})
c.Abort()
return

View File

@ -58,6 +58,7 @@ func IncrWithLimit(cache *redis.Client, key string, delta int64, limit int64, ex
return false
}
if res > limit {
// reset
cache.Set(context.Background(), key, limit, time.Duration(expiration)*time.Second)
return false
}

View File

@ -1,6 +1,7 @@
package utils
import (
"chat/channel"
"chat/globals"
"github.com/pkoukk/tiktoken-go"
"strings"
@ -18,19 +19,10 @@ func GetWeightByModel(model string) int {
return 2
case globals.GPT3Turbo, globals.GPT3Turbo0613, globals.GPT3Turbo1106,
globals.GPT3Turbo16k, globals.GPT3Turbo16k0613,
globals.GPT4, globals.GPT4Vision, globals.GPT4Dalle, globals.GPT4All, globals.GPT40314, globals.GPT40613, globals.GPT41106Preview, globals.GPT41106VisionPreview,
globals.GPT432k, globals.GPT432k0613, globals.GPT432k0314,
globals.LLaMa27B, globals.LLaMa213B, globals.LLaMa270B,
globals.CodeLLaMa34B, globals.CodeLLaMa13B, globals.CodeLLaMa7B,
globals.SparkDesk, globals.SparkDeskV2, globals.SparkDeskV3,
globals.QwenTurbo, globals.QwenPlus, globals.QwenTurboNet, globals.QwenPlusNet,
globals.BingPrecise, globals.BingCreative, globals.BingBalanced,
globals.Hunyuan, globals.GPT360V9, globals.Baichuan53B,
globals.SkylarkLite, globals.SkylarkPlus, globals.SkylarkPro, globals.SkylarkChat:
globals.GPT4, globals.GPT40314, globals.GPT40613, globals.GPT41106Preview, globals.GPT41106VisionPreview,
globals.GPT432k, globals.GPT432k0613, globals.GPT432k0314:
return 3
case globals.GPT3Turbo0301, globals.GPT3Turbo16k0301,
globals.ZhiPuChatGLMTurbo, globals.ZhiPuChatGLMLite, globals.ZhiPuChatGLMStd, globals.ZhiPuChatGLMPro:
case globals.GPT3Turbo0301, globals.GPT3Turbo16k0301:
return 4 // every message follows <|start|>{role/name}\n{content}<|end|>\n
default:
if strings.Contains(model, globals.GPT3Turbo) {
@ -47,7 +39,6 @@ func GetWeightByModel(model string) int {
return GetWeightByModel(globals.Claude2100k)
} else {
// not implemented: See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens
//panic(fmt.Errorf("not implemented for model %s", model))
return 3
}
}
@ -76,107 +67,23 @@ func CountTokenPrice(messages []globals.Message, model string) int {
return NumTokensFromMessages(messages, model)
}
func CountInputToken(model string, v []globals.Message) float32 {
switch model {
case globals.GPT3Turbo, globals.GPT3Turbo0613, globals.GPT3Turbo0301, globals.GPT3TurboInstruct, globals.GPT3Turbo1106,
globals.GPT3Turbo16k, globals.GPT3Turbo16k0613, globals.GPT3Turbo16k0301:
return 0
case globals.GPT41106Preview, globals.GPT41106VisionPreview:
return float32(CountTokenPrice(v, model)) / 1000 * 0.7 * 0.6
case globals.GPT4, globals.GPT4Vision, globals.GPT4All, globals.GPT4Dalle, globals.GPT40314, globals.GPT40613:
return float32(CountTokenPrice(v, model)) / 1000 * 2.1 * 0.6
case globals.GPT432k, globals.GPT432k0613, globals.GPT432k0314:
return float32(CountTokenPrice(v, model)) / 1000 * 4.2
case globals.SparkDesk:
return float32(CountTokenPrice(v, model)) / 1000 * 0.15
case globals.SparkDeskV2, globals.SparkDeskV3:
return float32(CountTokenPrice(v, model)) / 1000 * 0.3
case globals.Claude1, globals.Claude2:
return 0
case globals.Claude1100k, globals.Claude2100k, globals.Claude2200k:
return float32(CountTokenPrice(v, model)) / 1000 * 0.8
case globals.LLaMa270B, globals.CodeLLaMa34B:
return float32(CountTokenPrice(v, model)) / 1000 * 0.25
case globals.LLaMa213B, globals.CodeLLaMa13B, globals.LLaMa27B, globals.CodeLLaMa7B:
return float32(CountTokenPrice(v, model)) / 1000 * 0.1
case globals.ZhiPuChatGLMPro:
return float32(CountTokenPrice(v, model)) / 1000 * 0.1
case globals.ZhiPuChatGLMTurbo, globals.ZhiPuChatGLMStd:
return float32(CountTokenPrice(v, model)) / 1000 * 0.05
case globals.QwenTurbo, globals.QwenTurboNet:
return float32(CountTokenPrice(v, model)) / 1000 * 0.08
case globals.QwenPlus, globals.QwenPlusNet:
return float32(CountTokenPrice(v, model)) / 1000 * 0.2
case globals.Hunyuan:
return float32(CountTokenPrice(v, model)) / 1000 * 1
case globals.GPT360V9:
return float32(CountTokenPrice(v, model)) / 1000 * 0.12
case globals.Baichuan53B:
return float32(CountTokenPrice(v, model)) / 1000 * 0.2
case globals.SkylarkLite:
return float32(CountTokenPrice(v, model)) / 1000 * 0.04
case globals.SkylarkPlus:
return float32(CountTokenPrice(v, model)) / 1000 * 0.08
case globals.SkylarkPro, globals.SkylarkChat:
return float32(CountTokenPrice(v, model)) / 1000 * 0.11
default:
return 0
func CountInputToken(model string, message []globals.Message) float32 {
charge := channel.ChargeInstance.GetCharge(model)
if charge.IsBillingType(globals.TokenBilling) {
return float32(CountTokenPrice(message, model)) / 1000 * charge.GetInput()
}
return 0
}
func CountOutputToken(model string, t int) float32 {
switch model {
case globals.GPT3Turbo, globals.GPT3Turbo0613, globals.GPT3Turbo0301, globals.GPT3TurboInstruct, globals.GPT3Turbo1106,
globals.GPT3Turbo16k, globals.GPT3Turbo16k0613, globals.GPT3Turbo16k0301:
return 0
case globals.GPT41106Preview, globals.GPT41106VisionPreview:
return float32(t*GetWeightByModel(model)) / 1000 * 2.1 * 0.6
case globals.GPT4, globals.GPT4Vision, globals.GPT4All, globals.GPT4Dalle, globals.GPT40314, globals.GPT40613:
return float32(t*GetWeightByModel(model)) / 1000 * 4.3 * 0.6
case globals.GPT432k, globals.GPT432k0613, globals.GPT432k0314:
return float32(t*GetWeightByModel(model)) / 1000 * 8.6
case globals.SparkDesk:
return float32(t*GetWeightByModel(model)) / 1000 * 0.15
case globals.SparkDeskV2, globals.SparkDeskV3:
return float32(t*GetWeightByModel(model)) / 1000 * 0.3
case globals.Claude1, globals.Claude2:
return 0
case globals.Claude1100k, globals.Claude2100k, globals.Claude2200k:
return float32(t*GetWeightByModel(model)) / 1000 * 2.4
case globals.LLaMa270B, globals.CodeLLaMa34B:
return float32(t*GetWeightByModel(model)) / 1000 * 0.25
case globals.LLaMa213B, globals.CodeLLaMa13B, globals.LLaMa27B, globals.CodeLLaMa7B:
return float32(t*GetWeightByModel(model)) / 1000 * 0.1
case globals.ZhiPuChatGLMPro:
return float32(t*GetWeightByModel(model)) / 1000 * 0.1
case globals.ZhiPuChatGLMTurbo, globals.ZhiPuChatGLMStd:
return float32(t*GetWeightByModel(model)) / 1000 * 0.05
case globals.QwenTurbo, globals.QwenTurboNet:
return float32(t*GetWeightByModel(model)) / 1000 * 0.08
case globals.QwenPlus, globals.QwenPlusNet:
return float32(t*GetWeightByModel(model)) / 1000 * 0.2
case globals.Hunyuan:
return float32(t*GetWeightByModel(model)) / 1000 * 1
case globals.GPT360V9:
return float32(t*GetWeightByModel(model)) / 1000 * 0.12
case globals.Baichuan53B:
return float32(t*GetWeightByModel(model)) / 1000 * 0.2
case globals.SkylarkLite:
return float32(t*GetWeightByModel(model)) / 1000 * 0.04
case globals.SkylarkPlus:
return float32(t*GetWeightByModel(model)) / 1000 * 0.08
case globals.SkylarkPro, globals.SkylarkChat:
return float32(t*GetWeightByModel(model)) / 1000 * 0.11
case globals.StableDiffusion:
return 0.25
case globals.Midjourney:
return 0.1
case globals.MidjourneyFast:
return 0.5
case globals.MidjourneyTurbo:
return 1
case globals.Dalle3:
return 5.6
func CountOutputToken(model string, token int) float32 {
charge := channel.ChargeInstance.GetCharge(model)
switch charge.GetType() {
case globals.TokenBilling:
return float32(token*GetWeightByModel(model)) / 1000 * charge.GetOutput()
case globals.TimesBilling:
return charge.GetOutput()
default:
return 0
}