diff --git a/README.en.md b/README.en.md index 031b8c2d95..a2167d065d 100644 --- a/README.en.md +++ b/README.en.md @@ -21,6 +21,8 @@ [✓] Users manager +[✓] Random Key +
## Screenshots @@ -32,6 +34,7 @@ ![cover3](./docs/basesettings.jpg) ![cover3](./docs/prompt_en.jpg) ![cover3](./docs/user-manager.jpg) +![cover3](./docs/key-manager-en.jpg) - [ChatGPT Web](#chatgpt-web) - [Introduction](#introduction) diff --git a/README.md b/README.md index b36eb73cf4..3caf2d042a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ [✓] 每个会话设置独有 Prompt [✓] 用户管理 + +[✓] 多 Key 随机
## 截图 @@ -31,6 +33,7 @@ ![cover3](./docs/basesettings.jpg) ![cover3](./docs/prompt.jpg) ![cover3](./docs/user-manager.jpg) +![cover3](./docs/key-manager.jpg) - [ChatGPT Web](#chatgpt-web) - [介绍](#介绍) @@ -410,7 +413,18 @@ A: 一种可能原因是经过 Nginx 反向代理,开启了 buffer,则 Nginx ## 赞助 -如果你觉得这个项目对你有帮助,请给我点个Star。 +如果你觉得这个项目对你有帮助,请给我点个Star。并且情况允许的话,可以给我一点点支持,总之非常感谢支持~ + +
+
+ 微信 +

WeChat Pay

+
+
+ 支付宝 +

Alipay

+
+
## License MIT © [Kerwin1202](./license) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index da14bc351f..904f6717d8 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -11,16 +11,6 @@ services: - database environment: TZ: Asia/Shanghai - # 二选一 - OPENAI_API_KEY: - # 二选一 - OPENAI_ACCESS_TOKEN: - # API接口地址,可选,设置 OPENAI_API_KEY 时可用 - OPENAI_API_BASE_URL: - # ChatGPTAPI 或者 ChatGPTUnofficialProxyAPI - OPENAI_API_MODEL: - # 反向代理,可选 - API_REVERSE_PROXY: # 访问jwt加密参数,可选 不为空则允许登录 同时需要设置 MONGODB_URL AUTH_SECRET_KEY: # 每小时最大请求次数,可选,默认无限 @@ -35,8 +25,6 @@ services: SOCKS_PROXY_USERNAME: # Socks代理密码,可选,和 SOCKS_PROXY_HOST & SOCKS_PROXY_PORT 一起时生效 SOCKS_PROXY_PASSWORD: - # HTTPS_PROXY 代理,可选 - HTTPS_PROXY: http://xxxx:7890 # 网站名称 SITE_TITLE: ChatGpt Web # mongodb 的连接字符串 diff --git a/docs/alipay.png b/docs/alipay.png new file mode 100644 index 0000000000..bb5bbca7a8 Binary files /dev/null and b/docs/alipay.png differ diff --git a/docs/key-manager-en.jpg b/docs/key-manager-en.jpg new file mode 100644 index 0000000000..cc23811422 Binary files /dev/null and b/docs/key-manager-en.jpg differ diff --git a/docs/key-manager.jpg b/docs/key-manager.jpg new file mode 100644 index 0000000000..8c2edc6532 Binary files /dev/null and b/docs/key-manager.jpg differ diff --git a/docs/wechat.png b/docs/wechat.png new file mode 100644 index 0000000000..92bc6aa079 Binary files /dev/null and b/docs/wechat.png differ diff --git a/package.json b/package.json index cfb33b1a53..d3e10d1867 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chatgpt-web", - "version": "2.12.4", + "version": "2.13.0", "private": false, "description": "ChatGPT Web", "author": "ChenZhaoYu ", diff --git a/service/src/chatgpt/index.ts b/service/src/chatgpt/index.ts index ed6a4f207c..ae7cb05dc9 100644 --- a/service/src/chatgpt/index.ts +++ b/service/src/chatgpt/index.ts @@ -5,14 +5,14 @@ import { ChatGPTAPI, ChatGPTUnofficialProxyAPI } from 'chatgpt' import { SocksProxyAgent } from 'socks-proxy-agent' import httpsProxyAgent from 'https-proxy-agent' import fetch from 'node-fetch' -import type { AuditConfig, CHATMODEL } from 'src/storage/model' +import type { AuditConfig, CHATMODEL, KeyConfig, UserInfo } from 'src/storage/model' import jwt_decode from 'jwt-decode' import dayjs from 'dayjs' import type { TextAuditService } from '../utils/textAudit' import { textAuditServices } from '../utils/textAudit' -import { getCacheConfig, getOriginConfig } from '../storage/config' +import { getCacheApiKeys, getCacheConfig, getOriginConfig } from '../storage/config' import { sendResponse } from '../utils' -import { isNotEmptyString } from '../utils/is' +import { hasAnyRole, isNotEmptyString } from '../utils/is' import type { ChatContext, ChatGPTUnofficialProxyAPIOptions, JWT, ModelConfig } from '../types' import { getChatByMessageId } from '../storage/mongo' import type { RequestOptions } from './types' @@ -32,20 +32,17 @@ const ErrorCodeMessage: Record = { let auditService: TextAuditService -export async function initApi(chatModel: CHATMODEL) { +export async function initApi(key: KeyConfig, chatModel: CHATMODEL) { // More Info: https://github.com/transitive-bullshit/chatgpt-api const config = await getCacheConfig() - if (!config.apiKey && !config.accessToken) - throw new Error('Missing OPENAI_API_KEY or OPENAI_ACCESS_TOKEN environment variable') - const model = chatModel as string - if (config.apiModel === 'ChatGPTAPI') { + if (key.keyModel === 'ChatGPTAPI') { const OPENAI_API_BASE_URL = config.apiBaseUrl const options: ChatGPTAPIOptions = { - apiKey: config.apiKey, + apiKey: key.key, completionParams: { model }, debug: !config.apiDisableDebug, messageStore: undefined, @@ -73,7 +70,7 @@ export async function initApi(chatModel: CHATMODEL) { } else { const options: ChatGPTUnofficialProxyAPIOptions = { - accessToken: config.accessToken, + accessToken: key.key, apiReverseProxyUrl: isNotEmptyString(config.reverseProxy) ? config.reverseProxy : 'https://ai.fakeopen.com/api/conversation', model, debug: !config.apiDisableDebug, @@ -86,27 +83,30 @@ export async function initApi(chatModel: CHATMODEL) { } async function chatReplyProcess(options: RequestOptions) { - const config = await getCacheConfig() const model = options.chatModel + const key = options.key + if (key == null || key === undefined) + throw new Error('没有可用的配置。请再试一次 | No available configuration. Please try again.') + const { message, lastContext, process, systemMessage, temperature, top_p } = options try { const timeoutMs = (await getCacheConfig()).timeoutMs let options: SendMessageOptions = { timeoutMs } - if (config.apiModel === 'ChatGPTAPI') { + if (key.keyModel === 'ChatGPTAPI') { if (isNotEmptyString(systemMessage)) options.systemMessage = systemMessage options.completionParams = { model, temperature, top_p } } if (lastContext != null) { - if (config.apiModel === 'ChatGPTAPI') + if (key.keyModel === 'ChatGPTAPI') options.parentMessageId = lastContext.parentMessageId else options = { ...lastContext } } - const api = await initApi(model) + const api = await initApi(key, model) const response = await api.sendMessage(message, { ...options, onProgress: (partialResponse) => { @@ -123,6 +123,9 @@ async function chatReplyProcess(options: RequestOptions) { return sendResponse({ type: 'Fail', message: ErrorCodeMessage[code] }) return sendResponse({ type: 'Fail', message: error.message ?? 'Please check the back-end console' }) } + finally { + releaseApiKey(key) + } } export function initAuditService(audit: AuditConfig) { @@ -304,6 +307,39 @@ async function getMessageById(id: string): Promise { else { return undefined } } +const _lockedKeys: string[] = [] +async function randomKeyConfig(keys: KeyConfig[]): Promise < KeyConfig | null > { + if (keys.length <= 0) + return null + let unsedKeys = keys.filter(d => !_lockedKeys.includes(d.key)) + const start = Date.now() + while (unsedKeys.length <= 0) { + if (Date.now() - start > 3000) + break + await new Promise(resolve => setTimeout(resolve, 1000)) + unsedKeys = keys.filter(d => !_lockedKeys.includes(d.key)) + } + if (unsedKeys.length <= 0) + return null + const thisKey = unsedKeys[Math.floor(Math.random() * unsedKeys.length)] + _lockedKeys.push(thisKey.key) + return thisKey +} + +async function getRandomApiKey(user: UserInfo): Promise { + const keys = (await getCacheApiKeys()).filter(d => hasAnyRole(d.userRoles, user.roles)) + return randomKeyConfig(keys) +} + +async function releaseApiKey(key: KeyConfig) { + if (key == null || key === undefined) + return + + const index = _lockedKeys.indexOf(key.key) + if (index >= 0) + _lockedKeys.splice(index, 1) +} + export type { ChatContext, ChatMessage } -export { chatReplyProcess, chatConfig, containsSensitiveWords } +export { chatReplyProcess, chatConfig, containsSensitiveWords, getRandomApiKey } diff --git a/service/src/chatgpt/types.ts b/service/src/chatgpt/types.ts index b30b87147e..e9d193af36 100644 --- a/service/src/chatgpt/types.ts +++ b/service/src/chatgpt/types.ts @@ -1,5 +1,5 @@ import type { ChatMessage } from 'chatgpt' -import type { CHATMODEL } from 'src/storage/model' +import type { CHATMODEL, KeyConfig } from 'src/storage/model' export interface RequestOptions { message: string @@ -9,6 +9,7 @@ export interface RequestOptions { temperature?: number top_p?: number chatModel: CHATMODEL + key: KeyConfig } export interface BalanceResponse { diff --git a/service/src/index.ts b/service/src/index.ts index b5a99ff358..b3cc788c26 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -4,11 +4,11 @@ import * as dotenv from 'dotenv' import { ObjectId } from 'mongodb' import type { RequestProps } from './types' import type { ChatContext, ChatMessage } from './chatgpt' -import { chatConfig, chatReplyProcess, containsSensitiveWords, initAuditService } from './chatgpt' +import { chatConfig, chatReplyProcess, containsSensitiveWords, getRandomApiKey, initAuditService } from './chatgpt' import { auth } from './middleware/auth' -import { clearConfigCache, getCacheConfig, getOriginConfig } from './storage/config' -import type { AuditConfig, CHATMODEL, ChatInfo, ChatOptions, Config, MailConfig, SiteConfig, UsageResponse, UserInfo } from './storage/model' -import { Status } from './storage/model' +import { clearConfigCache, getApiKeys, getCacheConfig, getOriginConfig } from './storage/config' +import type { AuditConfig, CHATMODEL, ChatInfo, ChatOptions, Config, KeyConfig, MailConfig, SiteConfig, UsageResponse, UserInfo } from './storage/model' +import { Status, UserRole } from './storage/model' import { clearChat, createChatRoom, @@ -28,6 +28,7 @@ import { insertChat, insertChatUsage, renameChatRoom, + updateApiKeyStatus, updateChat, updateConfig, updateRoomPrompt, @@ -36,6 +37,7 @@ import { updateUserInfo, updateUserPassword, updateUserStatus, + upsertKey, verifyUser, } from './storage/mongo' import { limiter } from './middleware/limiter' @@ -390,7 +392,7 @@ router.post('/chat-process', [auth, limiter], async (req, res) => { const userId = req.headers.userId.toString() const user = await getUserById(userId) if (config.auditConfig.enabled || config.auditConfig.customizeEnabled) { - if (user.email.toLowerCase() !== process.env.ROOT_USER && await containsSensitiveWords(config.auditConfig, prompt)) { + if (!user.roles.includes(UserRole.Admin) && await containsSensitiveWords(config.auditConfig, prompt)) { res.send({ status: 'Fail', message: '含有敏感词 | Contains sensitive words', data: null }) return } @@ -427,12 +429,13 @@ router.post('/chat-process', [auth, limiter], async (req, res) => { temperature, top_p, chatModel: user.config.chatModel, + key: await getRandomApiKey(user), }) // return the whole response including usage res.write(`\n${JSON.stringify(result.data)}`) } catch (error) { - res.write(JSON.stringify(error)) + res.write(JSON.stringify({ message: error?.message })) } finally { res.end() @@ -516,9 +519,10 @@ router.post('/user-register', async (req, res) => { return } const newPassword = md5(password) - await createUser(username, newPassword) + const isRoot = username.toLowerCase() === process.env.ROOT_USER + await createUser(username, newPassword, isRoot) - if (username.toLowerCase() === process.env.ROOT_USER) { + if (isRoot) { res.send({ status: 'Success', message: '注册成功 | Register success', data: null }) } else { @@ -536,7 +540,7 @@ router.post('/config', rootAuth, async (req, res) => { const userId = req.headers.userId.toString() const user = await getUserById(userId) - if (user == null || user.status !== Status.Normal || user.email.toLowerCase() !== process.env.ROOT_USER) + if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin)) throw new Error('无权限 | No permission.') const response = await chatConfig() @@ -584,7 +588,7 @@ router.post('/user-login', async (req, res) => { avatar: user.avatar, description: user.description, userId: user._id, - root: username.toLowerCase() === process.env.ROOT_USER, + root: !user.roles.includes(UserRole.Admin), config: user.config, }, config.siteConfig.loginSalt.trim()) res.send({ status: 'Success', message: '登录成功 | Login successfully', data: { token } }) @@ -678,7 +682,10 @@ router.get('/users', rootAuth, async (req, res) => { router.post('/user-status', rootAuth, async (req, res) => { try { const { userId, status } = req.body as { userId: string; status: Status } + const user = await getUserById(userId) await updateUserStatus(userId, status) + if ((user.status === Status.PreVerify || user.status === Status.AdminVerify) && status === Status.Normal) + await sendNoticeMail(user.email) res.send({ status: 'Success', message: '更新成功 | Update successfully' }) } catch (error) { @@ -839,6 +846,40 @@ router.post('/audit-test', rootAuth, async (req, res) => { } }) +router.get('/setting-keys', rootAuth, async (req, res) => { + try { + const result = await getApiKeys() + res.send({ status: 'Success', message: null, data: result }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/setting-key-status', rootAuth, async (req, res) => { + try { + const { id, status } = req.body as { id: string; status: Status } + await updateApiKeyStatus(id, status) + res.send({ status: 'Success', message: '更新成功 | Update successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/setting-key-upsert', rootAuth, async (req, res) => { + try { + const keyConfig = req.body as KeyConfig + if (keyConfig._id !== undefined) + keyConfig._id = new ObjectId(keyConfig._id) + await upsertKey(keyConfig) + res.send({ status: 'Success', message: '成功 | Successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + router.post('/statistics/by-day', auth, async (req, res) => { try { const userId = req.headers.userId diff --git a/service/src/middleware/rootAuth.ts b/service/src/middleware/rootAuth.ts index d478e4bde0..e308a861d0 100644 --- a/service/src/middleware/rootAuth.ts +++ b/service/src/middleware/rootAuth.ts @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken' import * as dotenv from 'dotenv' -import { Status } from '../storage/model' +import { Status, UserRole } from '../storage/model' import { getUserById } from '../storage/mongo' import { getCacheConfig } from '../storage/config' @@ -14,7 +14,7 @@ const rootAuth = async (req, res, next) => { const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) req.headers.userId = info.userId const user = await getUserById(info.userId) - if (user == null || user.status !== Status.Normal || user.email.toLowerCase() !== process.env.ROOT_USER) + if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin)) res.send({ status: 'Fail', message: '无权限 | No permission.', data: null }) else next() diff --git a/service/src/storage/config.ts b/service/src/storage/config.ts index 636de25905..1ee488a7f9 100644 --- a/service/src/storage/config.ts +++ b/service/src/storage/config.ts @@ -2,8 +2,8 @@ import { ObjectId } from 'mongodb' import * as dotenv from 'dotenv' import type { TextAuditServiceProvider } from 'src/utils/textAudit' import { isNotEmptyString, isTextAuditServiceProvider } from '../utils/is' -import { AuditConfig, Config, MailConfig, SiteConfig, TextAudioType } from './model' -import { getConfig } from './mongo' +import { AuditConfig, CHATMODELS, Config, KeyConfig, MailConfig, SiteConfig, TextAudioType, UserRole } from './model' +import { getConfig, getKeys, upsertKey } from './mongo' dotenv.config() @@ -116,3 +116,46 @@ export function clearConfigCache() { cacheExpiration = 0 cachedConfig = null } + +let apiKeysCachedConfig: KeyConfig[] | undefined +let apiKeysCacheExpiration = 0 + +export async function getCacheApiKeys(): Promise { + const now = Date.now() + if (apiKeysCachedConfig && apiKeysCacheExpiration > now) + return Promise.resolve(apiKeysCachedConfig) + + const loadedConfig = (await getApiKeys()).keys + + apiKeysCachedConfig = loadedConfig + apiKeysCacheExpiration = now + 10 * 60 * 1000 + + return Promise.resolve(apiKeysCachedConfig) +} + +export async function getApiKeys() { + const result = await getKeys() + if (result.keys.length <= 0) { + const config = await getCacheConfig() + if (config.apiModel === 'ChatGPTAPI') + result.keys.push(await upsertKey(new KeyConfig(config.apiKey, 'ChatGPTAPI', [], [], ''))) + + if (config.apiModel === 'ChatGPTUnofficialProxyAPI') + result.keys.push(await upsertKey(new KeyConfig(config.accessToken, 'ChatGPTUnofficialProxyAPI', [], [], ''))) + + result.total++ + } + result.keys.forEach((key) => { + if (key.userRoles == null || key.userRoles.length <= 0) { + key.userRoles.push(UserRole.Admin) + key.userRoles.push(UserRole.User) + key.userRoles.push(UserRole.Guest) + } + if (key.chatModels == null || key.chatModels.length <= 0) { + CHATMODELS.forEach((chatModel) => { + key.chatModels.push(chatModel) + }) + } + }) + return result +} diff --git a/service/src/storage/model.ts b/service/src/storage/model.ts index 0e507592ed..5660994320 100644 --- a/service/src/storage/model.ts +++ b/service/src/storage/model.ts @@ -8,6 +8,13 @@ export enum Status { ResponseDeleted = 3, PreVerify = 4, AdminVerify = 5, + Disabled = 6, +} + +export enum UserRole { + Admin = 0, + User = 1, + Guest = 2, } export class UserInfo { @@ -22,7 +29,7 @@ export class UserInfo { description?: string updateTime?: string config?: UserConfig - root?: boolean + roles?: UserRole[] constructor(email: string, password: string) { this.name = email this.email = email @@ -31,7 +38,7 @@ export class UserInfo { this.createTime = new Date().toLocaleString() this.verifyTime = null this.updateTime = new Date().toLocaleString() - this.root = false + this.roles = [UserRole.User] } } @@ -42,6 +49,18 @@ export class UserConfig { // https://platform.openai.com/docs/models/overview export type CHATMODEL = 'gpt-3.5-turbo' | 'gpt-3.5-turbo-0301' | 'gpt-4' | 'gpt-4-0314' | 'gpt-4-32k' | 'gpt-4-32k-0314' | 'ext-davinci-002-render-sha-mobile' | 'gpt-4-mobile' | 'gpt-4-browsing' +export const CHATMODELS: CHATMODEL[] = [ + 'gpt-3.5-turbo', + 'gpt-3.5-turbo-0301', + 'gpt-4', + 'gpt-4-0314', + 'gpt-4-32k', + 'gpt-4-32k-0314', + 'ext-davinci-002-render-sha-mobile', + 'gpt-4-mobile', + 'gpt-4-browsing', +] + export class ChatRoom { _id: ObjectId roomId: number @@ -139,7 +158,7 @@ export class Config { public apiDisableDebug?: boolean, public accessToken?: string, public apiBaseUrl?: string, - public apiModel?: string, + public apiModel?: APIMODEL, public reverseProxy?: string, public socksProxy?: string, public socksAuth?: string, @@ -189,3 +208,23 @@ export enum TextAudioType { Response = 1 << 1, // 二进制 10 All = Request | Response, // 二进制 11 } + +export class KeyConfig { + _id: ObjectId + key: string + keyModel: APIMODEL + chatModels: CHATMODEL[] + userRoles: UserRole[] + status: Status + remark: string + constructor(key: string, keyModel: APIMODEL, chatModels: CHATMODEL[], userRoles: UserRole[], remark: string) { + this.key = key + this.keyModel = keyModel + this.chatModels = chatModels + this.userRoles = userRoles + this.status = Status.Normal + this.remark = remark + } +} + +export type APIMODEL = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' diff --git a/service/src/storage/mongo.ts b/service/src/storage/mongo.ts index ea9d576315..44c6c24bed 100644 --- a/service/src/storage/mongo.ts +++ b/service/src/storage/mongo.ts @@ -1,8 +1,8 @@ import { MongoClient, ObjectId } from 'mongodb' import * as dotenv from 'dotenv' import dayjs from 'dayjs' -import { ChatInfo, ChatRoom, ChatUsage, Status, UserConfig, UserInfo } from './model' -import type { CHATMODEL, ChatOptions, Config, UsageResponse } from './model' +import { ChatInfo, ChatRoom, ChatUsage, Status, UserConfig, UserInfo, UserRole } from './model' +import type { CHATMODEL, ChatOptions, Config, KeyConfig, UsageResponse } from './model' dotenv.config() @@ -15,6 +15,7 @@ const roomCol = client.db(dbName).collection('chat_room') const userCol = client.db(dbName).collection('user') const configCol = client.db(dbName).collection('config') const usageCol = client.db(dbName).collection('chat_usage') +const keyCol = client.db(dbName).collection('key_config') /** * 插入聊天信息 @@ -176,11 +177,13 @@ export async function deleteChat(roomId: number, uuid: number, inversion: boolea await chatCol.updateOne(query, update) } -export async function createUser(email: string, password: string): Promise { +export async function createUser(email: string, password: string, isRoot: boolean): Promise { email = email.toLowerCase() const userInfo = new UserInfo(email, password) - if (email === process.env.ROOT_USER) + if (isRoot) { userInfo.status = Status.Normal + userInfo.roles = [UserRole.Admin] + } await userCol.insertOne(userInfo) return userInfo @@ -203,11 +206,13 @@ export async function updateUserPassword(userId: string, password: string) { export async function getUser(email: string): Promise { email = email.toLowerCase() - return await userCol.findOne({ email }) as UserInfo + const userInfo = await userCol.findOne({ email }) as UserInfo + initUserInfo(userInfo) + return userInfo } export async function getUsers(page: number, size: number): Promise<{ users: UserInfo[]; total: number }> { - const cursor = userCol.find({}).sort({ createTime: -1 }) + const cursor = userCol.find({ status: { $ne: Status.Deleted } }).sort({ createTime: -1 }) const total = await cursor.count() const skip = (page - 1) * size const limit = size @@ -215,20 +220,27 @@ export async function getUsers(page: number, size: number): Promise<{ users: Use const users: UserInfo[] = [] await pagedCursor.forEach(doc => users.push(doc)) users.forEach((user) => { - if (user.root == null) - user.root = process.env.ROOT_USER === user.email.toLowerCase() + initUserInfo(user) }) return { users, total } } export async function getUserById(userId: string): Promise { const userInfo = await userCol.findOne({ _id: new ObjectId(userId) }) as UserInfo + initUserInfo(userInfo) + return userInfo +} + +function initUserInfo(userInfo: UserInfo) { if (userInfo.config == null) userInfo.config = new UserConfig() if (userInfo.config.chatModel == null) userInfo.config.chatModel = 'gpt-3.5-turbo' - - return userInfo + if (userInfo.roles == null || userInfo.roles.length <= 0) { + userInfo.roles = [UserRole.User] + if (process.env.ROOT_USER === userInfo.email.toLowerCase()) + userInfo.roles.push(UserRole.Admin) + } } export async function verifyUser(email: string, status: Status) { @@ -321,3 +333,23 @@ export async function getUserStatisticsByDay(userId: ObjectId, start: number, en return result } + +export async function getKeys(): Promise<{ keys: KeyConfig[]; total: number }> { + const cursor = await keyCol.find({ status: { $ne: Status.Disabled } }) + const total = await cursor.count() + const keys = [] + await cursor.forEach(doc => keys.push(doc)) + return { keys, total } +} + +export async function upsertKey(key: KeyConfig): Promise { + if (key._id === undefined) + await keyCol.insertOne(key) + else + await keyCol.replaceOne({ _id: key._id }, key, { upsert: true }) + return key +} + +export async function updateApiKeyStatus(id: string, status: Status) { + return await keyCol.updateOne({ _id: new ObjectId(id) }, { $set: { status } }) +} diff --git a/service/src/types.ts b/service/src/types.ts index 60ce406b24..67c34df623 100644 --- a/service/src/types.ts +++ b/service/src/types.ts @@ -26,7 +26,7 @@ export interface ChatGPTUnofficialProxyAPIOptions { } export interface ModelConfig { - apiModel?: ApiModel + apiModel?: APIMODEL reverseProxy?: string timeoutMs?: number socksProxy?: string @@ -37,7 +37,7 @@ export interface ModelConfig { accessTokenExpiredTime?: string } -export type ApiModel = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined +export type APIMODEL = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined export interface JWT { 'https://api.openai.com/profile': { diff --git a/service/src/utils/is.ts b/service/src/utils/is.ts index 05e8af0c1a..92e4438b42 100644 --- a/service/src/utils/is.ts +++ b/service/src/utils/is.ts @@ -1,3 +1,4 @@ +import type { UserRole } from '../storage/model' import { TextAudioType } from '../storage/model' import type { TextAuditServiceProvider } from './textAudit' @@ -37,3 +38,10 @@ export function isTextAudioType(value: any): value is TextAudioType { || value === TextAudioType.All ) } +export function hasAnyRole(userRoles: UserRole[] | undefined, roles: UserRole[]): boolean { + if (!userRoles || userRoles.length === 0 || !roles || roles.length === 0) + return false + + const roleNames = roles.map(role => role.toString()) + return roleNames.some(roleName => userRoles.some(userRole => userRole.toString() === roleName)) +} diff --git a/src/api/index.ts b/src/api/index.ts index eeaed0d4c5..739f1ed38e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,6 @@ import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import { get, post } from '@/utils/request' -import type { AuditConfig, CHATMODEL, ConfigState, MailConfig, SiteConfig, Status } from '@/components/common/Setting/model' +import type { AuditConfig, CHATMODEL, ConfigState, KeyConfig, MailConfig, SiteConfig, Status } from '@/components/common/Setting/model' import { useAuthStore, useSettingStore } from '@/store' export function fetchChatAPI( @@ -258,3 +258,24 @@ export function fetchUserStatistics(start: number, end: number) { data: { start, end }, }) } + +export function fetchGetKeys(page: number, size: number) { + return get({ + url: '/setting-keys', + data: { page, size }, + }) +} + +export function fetchUpdateApiKeyStatus(id: string, status: Status) { + return post({ + url: '/setting-key-status', + data: { id, status }, + }) +} + +export function fetchUpsertApiKey(keyConfig: KeyConfig) { + return post({ + url: '/setting-key-upsert', + data: keyConfig, + }) +} diff --git a/src/components/common/Setting/About.vue b/src/components/common/Setting/About.vue index 803537e2f0..5d15956a77 100644 --- a/src/components/common/Setting/About.vue +++ b/src/components/common/Setting/About.vue @@ -1,6 +1,6 @@ + + diff --git a/src/components/common/Setting/User.vue b/src/components/common/Setting/User.vue index 048d54750d..49b9a64334 100644 --- a/src/components/common/Setting/User.vue +++ b/src/components/common/Setting/User.vue @@ -1,7 +1,7 @@ + + + + diff --git a/src/components/common/Setting/model.ts b/src/components/common/Setting/model.ts index 3eaa7b8308..e74230ab53 100644 --- a/src/components/common/Setting/model.ts +++ b/src/components/common/Setting/model.ts @@ -4,7 +4,7 @@ export class ConfigState { accessToken?: string accessTokenExpiredTime?: string apiBaseUrl?: string - apiModel?: ApiModel + apiModel?: APIMODEL reverseProxy?: string socksProxy?: string socksAuth?: string @@ -22,8 +22,6 @@ export class UserConfig { chatModel?: CHATMODEL } -export type ApiModel = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined - export class SiteConfig { siteTitle?: string loginEnabled?: boolean @@ -71,4 +69,67 @@ export enum Status { ResponseDeleted = 3, PreVerify = 4, AdminVerify = 5, + Disabled = 6, +} + +export enum UserRole { + Admin = 0, + User = 1, + Guest = 2, +} + +export class KeyConfig { + _id?: string + key: string + keyModel: APIMODEL + chatModels: CHATMODEL[] + userRoles: UserRole[] + status: Status + remark: string + constructor(key: string, keyModel: APIMODEL, chatModels: CHATMODEL[], userRoles: UserRole[], remark: string) { + this.key = key + this.keyModel = keyModel + this.chatModels = chatModels + this.userRoles = userRoles + this.status = Status.Normal + this.remark = remark + } } + +export type APIMODEL = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined + +export const apiModelOptions = ['ChatGPTAPI', 'ChatGPTUnofficialProxyAPI'].map((model: string) => { + return { + label: model, + key: model, + value: model, + } +}) + +export const chatModelOptions = [ + 'gpt-3.5-turbo', + 'gpt-3.5-turbo-0301', + 'gpt-4', + 'gpt-4-0314', + 'gpt-4-32k', + 'gpt-4-32k-0314', + 'text-davinci-002-render-sha-mobile', + 'gpt-4-mobile', +].map((model: string) => { + let label = model + if (model === 'text-davinci-002-render-sha-mobile') + label = 'gpt-3.5-mobile' + return { + label, + key: model, + value: model, + } +}) + +export const userRoleOptions = Object.values(UserRole).filter(d => isNaN(Number(d))).map((role) => { + return { + label: role as string, + key: role as string, + value: UserRole[role as keyof typeof UserRole], + } +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index e5f30eb574..09b4e5b521 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -65,6 +65,9 @@ export default { deleteUser: 'Delete User', deleteUserConfirm: 'Are you sure to delete this user?', verifiedUser: 'Verified User', + deleteKey: 'Delete Key', + editKeyButton: 'Edit Key', + deleteKeyConfirm: 'Are you sure to delete this key?', }, setting: { setting: 'Setting', @@ -128,6 +131,10 @@ export default { auditCustomizeWords: 'Sensitive Words', accessTokenExpiredTime: 'Expired Time', userConfig: 'Users', + keysConfig: 'Keys Manager', + userRoles: 'User Role', + status: 'Status', + chatModels: 'Chat Models', }, store: { siderButton: 'Prompt Store', diff --git a/src/locales/ko-KR.ts b/src/locales/ko-KR.ts index 359e8fe7ab..abbad854e4 100644 --- a/src/locales/ko-KR.ts +++ b/src/locales/ko-KR.ts @@ -65,6 +65,9 @@ export default { deleteUser: 'Delete User', deleteUserConfirm: 'Are you sure to delete this user?', verifiedUser: 'Verified User', + deleteKey: 'Delete Key', + editKeyButton: 'Edit Key', + deleteKeyConfirm: 'Are you sure to delete this key?', }, setting: { setting: '설정', @@ -126,6 +129,10 @@ export default { auditCustomizeWords: '단어 맞춤설정', accessTokenExpiredTime: '만료된 시간', userConfig: 'Users', + keysConfig: 'Keys Manager', + userRoles: 'User Role', + status: 'Status', + chatModels: 'Chat Models', }, store: { siderButton: '프롬프트 스토어', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 0cf2b570ed..cca8d1d2aa 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -65,6 +65,9 @@ export default { deleteUser: '删除用户', deleteUserConfirm: '你确定要删除这个用户吗?', verifiedUser: '通过验证', + deleteKey: '删除 Key', + editKeyButton: '编辑 Key', + deleteKeyConfirm: '你确定要删除这个 key 吗?', }, setting: { setting: '设置', @@ -128,6 +131,10 @@ export default { auditCustomizeWords: '敏感词', accessTokenExpiredTime: '过期时间', userConfig: '用户管理', + keysConfig: 'Keys 管理', + userRoles: '用户权限', + status: '状态', + chatModels: '对话模型', }, store: { siderButton: '提示词商店', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 8420d06d25..0e46ed0275 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -65,6 +65,9 @@ export default { deleteUser: '删除用户', deleteUserConfirm: '你确定要删除这个用户吗?', verifiedUser: '通过验证', + deleteKey: '删除 Key', + editKeyButton: '编辑 Key', + deleteKeyConfirm: '你确定要删除这个 key 吗?', }, setting: { setting: '設定', @@ -128,6 +131,10 @@ export default { auditCustomizeWords: '敏感词', accessTokenExpiredTime: '过期时间', userConfig: '用户管理', + keysConfig: 'Keys 管理', + userRoles: '用户权限', + status: '状态', + chatModels: '对话模型', }, store: { siderButton: '提示詞商店',