首页} />
+ {account.user && (
+
+ Playground} />
+
+ )}
+
关于} />
diff --git a/web/src/menu-items/panel.js b/web/src/menu-items/panel.js
index b97a48e8..ff6181c9 100644
--- a/web/src/menu-items/panel.js
+++ b/web/src/menu-items/panel.js
@@ -13,7 +13,8 @@ import {
IconBrandTelegram,
IconReceipt2,
IconBrush,
- IconBrandGithubCopilot
+ IconBrandGithubCopilot,
+ IconBallFootball
} from '@tabler/icons-react';
// constant
@@ -31,7 +32,8 @@ const icons = {
IconBrandTelegram,
IconReceipt2,
IconBrush,
- IconBrandGithubCopilot
+ IconBrandGithubCopilot,
+ IconBallFootball
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@@ -75,6 +77,14 @@ const panel = {
icon: icons.IconKey,
breadcrumbs: false
},
+ {
+ id: 'playground',
+ title: 'Playground',
+ type: 'item',
+ url: '/panel/playground',
+ icon: icons.IconBallFootball,
+ breadcrumbs: false
+ },
{
id: 'log',
title: '日志',
diff --git a/web/src/routes/MainRoutes.js b/web/src/routes/MainRoutes.js
index 52334952..ef72ce69 100644
--- a/web/src/routes/MainRoutes.js
+++ b/web/src/routes/MainRoutes.js
@@ -18,6 +18,7 @@ const Telegram = Loadable(lazy(() => import('views/Telegram')));
const Pricing = Loadable(lazy(() => import('views/Pricing')));
const Midjourney = Loadable(lazy(() => import('views/Midjourney')));
const ModelPrice = Loadable(lazy(() => import('views/ModelPrice')));
+const Playground = Loadable(lazy(() => import('views/Playground')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@@ -91,6 +92,10 @@ const MainRoutes = {
{
path: 'model_price',
element:
+ },
+ {
+ path: 'playground',
+ element:
}
]
};
diff --git a/web/src/routes/OtherRoutes.js b/web/src/routes/OtherRoutes.js
index 479e9ad8..c7d6e1f8 100644
--- a/web/src/routes/OtherRoutes.js
+++ b/web/src/routes/OtherRoutes.js
@@ -15,6 +15,7 @@ const Home = Loadable(lazy(() => import('views/Home')));
const About = Loadable(lazy(() => import('views/About')));
const NotFoundView = Loadable(lazy(() => import('views/Error')));
const Jump = Loadable(lazy(() => import('views/Jump')));
+const Playground = Loadable(lazy(() => import('views/Playground')));
// ==============================|| AUTHENTICATION ROUTING ||============================== //
@@ -61,6 +62,10 @@ const OtherRoutes = {
{
path: '/jump',
element:
+ },
+ {
+ path: '/playground',
+ element:
}
]
};
diff --git a/web/src/utils/common.js b/web/src/utils/common.js
index b15a5a0e..459bb54e 100644
--- a/web/src/utils/common.js
+++ b/web/src/utils/common.js
@@ -1,6 +1,7 @@
import { enqueueSnackbar } from 'notistack';
import { snackbarConstants } from 'constants/SnackbarConstants';
import { API } from './api';
+import { CHAT_LINKS } from 'constants/chatLinks';
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
@@ -228,3 +229,35 @@ export function trims(values) {
return values;
}
+
+export function getChatLinks(filterShow = false) {
+ let links = [];
+ let siteInfo = JSON.parse(localStorage.getItem('siteInfo'));
+ let chatLinks = JSON.parse(siteInfo?.chat_links || '[]');
+
+ if (chatLinks.length === 0) {
+ links = CHAT_LINKS;
+ if (siteInfo?.chat_link) {
+ // 循环找到name为ChatGPT Next的链接
+ for (let i = 0; i < links.length; i++) {
+ if (links[i].name === 'ChatGPT Next') {
+ links[i].url = siteInfo.chat_link + `/#/?settings={"key":"sk-{key}","url":"{server}"}`;
+ links[i].show = true;
+ break;
+ }
+ }
+ }
+ } else {
+ links = chatLinks;
+ }
+
+ if (filterShow) {
+ links = links.filter((link) => link.show);
+ }
+
+ return links;
+}
+
+export function replaceChatPlaceholders(text, key, server) {
+ return text.replace('{key}', key).replace('{server}', server);
+}
diff --git a/web/src/views/Playground/index.js b/web/src/views/Playground/index.js
new file mode 100644
index 00000000..e83c65bb
--- /dev/null
+++ b/web/src/views/Playground/index.js
@@ -0,0 +1,109 @@
+import PropTypes from 'prop-types';
+import { useEffect, useState, useCallback } from 'react';
+import { API } from 'utils/api';
+import { getChatLinks, showError, replaceChatPlaceholders } from 'utils/common';
+import { Typography, Tabs, Tab, Box, Card } from '@mui/material';
+import SubCard from 'ui-component/cards/SubCard';
+// import { Link } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+
+function TabPanel(props) {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+TabPanel.propTypes = {
+ children: PropTypes.node,
+ index: PropTypes.number.isRequired,
+ value: PropTypes.number.isRequired
+};
+
+function a11yProps(index) {
+ return {
+ id: `playground-tab-${index}`,
+ 'aria-controls': `playground-tabpanel-${index}`
+ };
+}
+
+const Playground = () => {
+ const [value, setValue] = useState('');
+ const [tabIndex, setTabIndex] = useState(0);
+ const [isLoading, setIsLoading] = useState(true);
+ const siteInfo = useSelector((state) => state.siteInfo);
+ const chatLinks = getChatLinks(true);
+ const [iframeSrc, setIframeSrc] = useState(null);
+
+ const loadTokens = useCallback(async () => {
+ setIsLoading(true);
+ const res = await API.get(`/api/token/playground`);
+ const { success, message, data } = res.data;
+ if (success) {
+ setValue(data);
+ } else {
+ showError(message);
+ }
+ setIsLoading(false);
+ }, []);
+
+ const handleTabChange = useCallback(
+ (event, newIndex) => {
+ setTabIndex(newIndex);
+ let server = '';
+ if (siteInfo?.server_address) {
+ server = siteInfo.server_address;
+ } else {
+ server = window.location.host;
+ }
+ server = encodeURIComponent(server);
+ const key = 'sk-' + value;
+
+ setIframeSrc(replaceChatPlaceholders(chatLinks[newIndex].url, key, server));
+ },
+ [siteInfo, value, chatLinks]
+ );
+
+ useEffect(() => {
+ loadTokens().then(() => {
+ if (value !== '') {
+ handleTabChange(null, 0);
+ }
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadTokens, value]);
+
+ if (chatLinks.length === 0 || isLoading || value === '') {
+ return (
+
+ {isLoading ? 'Loading...' : 'No playground available'}
+
+ );
+ } else {
+ return (
+
+
+ {chatLinks.map((link, index) => link.show && )}
+
+
+
+
+
+ );
+ }
+};
+
+export default Playground;
diff --git a/web/src/views/Setting/component/ChatLinksDataGrid.js b/web/src/views/Setting/component/ChatLinksDataGrid.js
new file mode 100644
index 00000000..4367f87b
--- /dev/null
+++ b/web/src/views/Setting/component/ChatLinksDataGrid.js
@@ -0,0 +1,255 @@
+import PropTypes from 'prop-types';
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { GridRowModes, DataGrid, GridToolbarContainer, GridActionsCellItem } from '@mui/x-data-grid';
+import { Box, Button } from '@mui/material';
+import AddIcon from '@mui/icons-material/Add';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/DeleteOutlined';
+import SaveIcon from '@mui/icons-material/Save';
+import CancelIcon from '@mui/icons-material/Close';
+import { showError } from 'utils/common';
+
+function validation(row) {
+ if (row.name === '') {
+ return '名称不能为空';
+ }
+
+ if (row.url === '') {
+ return 'URL不能为空';
+ }
+
+ return false;
+}
+
+function randomId() {
+ return Math.random().toString(36).substr(2, 9);
+}
+
+function EditToolbar({ setRows, setRowModesModel }) {
+ const handleClick = () => {
+ const id = randomId();
+ setRows((oldRows) => [{ id, name: '', url: '', show: true, isNew: true }, ...oldRows]);
+ setRowModesModel((oldModel) => ({
+ [id]: { mode: GridRowModes.Edit, fieldToFocus: 'name' },
+ ...oldModel
+ }));
+ };
+
+ return (
+
+ } onClick={handleClick}>
+ 新增
+
+
+ );
+}
+
+EditToolbar.propTypes = {
+ setRows: PropTypes.func.isRequired,
+ setRowModesModel: PropTypes.func.isRequired
+};
+
+const ChatLinksDataGrid = ({ links, onChange }) => {
+ const [rows, setRows] = useState([]);
+ const [rowModesModel, setRowModesModel] = useState({});
+
+ const setLinks = useCallback(
+ (linksRow) => {
+ let linksJson = [];
+ // 删除 linksrow 中的 isNew 属性
+ linksRow.forEach((row) => {
+ let { isNew, ...rest } = row; // eslint-disable-line no-unused-vars
+ linksJson.push(rest);
+ });
+ onChange({ target: { name: 'ChatLinks', value: JSON.stringify(linksJson, null, 2) } });
+ },
+ [onChange]
+ );
+
+ const handleEditClick = useCallback(
+ (id) => () => {
+ setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
+ },
+ [rowModesModel]
+ );
+
+ const handleSaveClick = useCallback(
+ (id) => () => {
+ setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
+ },
+ [rowModesModel]
+ );
+
+ const handleDeleteClick = useCallback(
+ (id) => () => {
+ setLinks(rows.filter((row) => row.id !== id));
+ },
+ [rows, setLinks]
+ );
+
+ const handleCancelClick = useCallback(
+ (id) => () => {
+ setRowModesModel({
+ ...rowModesModel,
+ [id]: { mode: GridRowModes.View, ignoreModifications: true }
+ });
+
+ const editedRow = rows.find((row) => row.id === id);
+ if (editedRow.isNew) {
+ setRows(rows.filter((row) => row.id !== id));
+ }
+ },
+ [rowModesModel, rows]
+ );
+
+ const processRowUpdate = (newRow, oldRows) => {
+ if (!newRow.isNew && newRow.name === oldRows.name && newRow.url === oldRows.url && newRow.show === oldRows.show) {
+ return oldRows;
+ }
+ const updatedRow = { ...newRow, isNew: false };
+ const error = validation(updatedRow);
+ if (error) {
+ return Promise.reject(new Error(error));
+ }
+ setLinks(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
+ return updatedRow;
+ };
+
+ const handleProcessRowUpdateError = useCallback((error) => {
+ showError(error.message);
+ }, []);
+
+ const handleRowModesModelChange = (newRowModesModel) => {
+ setRowModesModel(newRowModesModel);
+ };
+
+ const modelRatioColumns = useMemo(
+ () => [
+ {
+ field: 'name',
+ sortable: true,
+ headerName: '名称',
+ flex: 1,
+ minWidth: 220,
+ editable: true,
+ hideable: false
+ },
+ {
+ field: 'url',
+ sortable: false,
+ headerName: '链接',
+ flex: 1,
+ minWidth: 300,
+ editable: true,
+ hideable: false
+ },
+ {
+ field: 'show',
+ sortable: false,
+ headerName: '是否显示在playground',
+ flex: 1,
+ minWidth: 200,
+ type: 'boolean',
+ editable: true,
+ hideable: false
+ },
+ {
+ field: 'actions',
+ type: 'actions',
+ headerName: '操作',
+ width: 100,
+ cellClassName: 'actions',
+ hideable: false,
+ getActions: ({ id }) => {
+ const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
+
+ if (isInEditMode) {
+ return [
+ }
+ key={'Save-' + id}
+ label="Save"
+ sx={{
+ color: 'primary.main'
+ }}
+ onClick={handleSaveClick(id)}
+ />,
+ }
+ key={'Cancel-' + id}
+ label="Cancel"
+ className="textPrimary"
+ onClick={handleCancelClick(id)}
+ color="inherit"
+ />
+ ];
+ }
+
+ return [
+ }
+ label="Edit"
+ className="textPrimary"
+ onClick={handleEditClick(id)}
+ color="inherit"
+ />,
+ }
+ label="Delete"
+ onClick={handleDeleteClick(id)}
+ color="inherit"
+ />
+ ];
+ }
+ }
+ ],
+ [handleEditClick, handleSaveClick, handleDeleteClick, handleCancelClick, rowModesModel]
+ );
+
+ useEffect(() => {
+ let itemJson = JSON.parse(links);
+ setRows(itemJson);
+ }, [links]);
+
+ return (
+
+
+
+ );
+};
+
+ChatLinksDataGrid.propTypes = {
+ links: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+export default ChatLinksDataGrid;
diff --git a/web/src/views/Setting/component/OperationSetting.js b/web/src/views/Setting/component/OperationSetting.js
index 43b3e32d..e4fadfec 100644
--- a/web/src/views/Setting/component/OperationSetting.js
+++ b/web/src/views/Setting/component/OperationSetting.js
@@ -1,12 +1,14 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useContext } from 'react';
import SubCard from 'ui-component/cards/SubCard';
-import { Stack, FormControl, InputLabel, OutlinedInput, Checkbox, Button, FormControlLabel, TextField } from '@mui/material';
+import { Stack, FormControl, InputLabel, OutlinedInput, Checkbox, Button, FormControlLabel, TextField, Alert } from '@mui/material';
import { showSuccess, showError, verifyJSON } from 'utils/common';
import { API } from 'utils/api';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
+import ChatLinksDataGrid from './ChatLinksDataGrid';
import dayjs from 'dayjs';
+import { LoadStatusContext } from 'contexts/StatusContext';
require('dayjs/locale/zh-cn');
const OperationSetting = () => {
@@ -20,6 +22,7 @@ const OperationSetting = () => {
GroupRatio: '',
TopUpLink: '',
ChatLink: '',
+ ChatLinks: '',
QuotaPerUnit: 0,
AutomaticDisableChannelEnabled: '',
AutomaticEnableChannelEnabled: '',
@@ -37,6 +40,7 @@ const OperationSetting = () => {
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
let [historyTimestamp, setHistoryTimestamp] = useState(now.getTime() / 1000 - 30 * 24 * 3600); // a month ago new Date().getTime() / 1000 + 3600
+ const loadStatus = useContext(LoadStatusContext);
const getOptions = async () => {
try {
@@ -78,6 +82,8 @@ const OperationSetting = () => {
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
+ getOptions();
+ await loadStatus();
} else {
showError(message);
}
@@ -118,6 +124,15 @@ const OperationSetting = () => {
await updateOption('GroupRatio', inputs.GroupRatio);
}
break;
+ case 'chatlinks':
+ if (originInputs['ChatLinks'] !== inputs.ChatLinks) {
+ if (!verifyJSON(inputs.ChatLinks)) {
+ showError('links不是合法的 JSON 字符串');
+ return;
+ }
+ await updateOption('ChatLinks', inputs.ChatLinks);
+ }
+ break;
case 'quota':
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
@@ -520,6 +535,39 @@ const OperationSetting = () => {
+
+
+
+
+ 配置聊天链接,该配置在令牌中的聊天生效以及首页的Playground中的聊天生效.
+ 链接中可以使{'{key}'}替换用户的令牌,{'{server}'}替换服务器地址。例如:
+ {'https://chat.oneapi.pro/#/?settings={"key":"sk-{key}","url":"{server}"}'}
+
+ 如果未配置,会默认配置以下4个链接:
+
+ ChatGPT Next : {'https://chat.oneapi.pro/#/?settings={"key":"{key}","url":"{server}"}'}
+
+ chatgpt-web-midjourney-proxy : {'https://vercel.ddaiai.com/#/?settings={"key":"{key}","url":"{server}"}'}
+
+ AMA 问天 : {'ama://set-api-key?server={server}&key={key}'}
+
+ opencat : {'opencat://team/join?domain={server}&token={key}'}
+
+
+
+
+
+
+
+
+
);
};
diff --git a/web/src/views/Setting/component/OtherSetting.js b/web/src/views/Setting/component/OtherSetting.js
index 519b7b08..dda07e22 100644
--- a/web/src/views/Setting/component/OtherSetting.js
+++ b/web/src/views/Setting/component/OtherSetting.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useContext } from 'react';
import SubCard from 'ui-component/cards/SubCard';
import {
Stack,
@@ -19,6 +19,7 @@ import Grid from '@mui/material/Unstable_Grid2';
import { showError, showSuccess } from 'utils/common'; //,
import { API } from 'utils/api';
import { marked } from 'marked';
+import { LoadStatusContext } from 'contexts/StatusContext';
const OtherSetting = () => {
let [inputs, setInputs] = useState({
@@ -35,6 +36,7 @@ const OtherSetting = () => {
tag_name: '',
content: ''
});
+ const loadStatus = useContext(LoadStatusContext);
const getOptions = async () => {
try {
@@ -70,8 +72,9 @@ const OtherSetting = () => {
});
const { success, message } = res.data;
if (success) {
- setInputs((inputs) => ({ ...inputs, [key]: value }));
showSuccess('保存成功');
+ getOptions();
+ await loadStatus();
} else {
showError(message);
}
diff --git a/web/src/views/Setting/component/SystemSetting.js b/web/src/views/Setting/component/SystemSetting.js
index e4cf906e..cc60c349 100644
--- a/web/src/views/Setting/component/SystemSetting.js
+++ b/web/src/views/Setting/component/SystemSetting.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useContext } from 'react';
import SubCard from 'ui-component/cards/SubCard';
import {
Stack,
@@ -21,6 +21,7 @@ import Grid from '@mui/material/Unstable_Grid2';
import { showError, showSuccess, removeTrailingSlash } from 'utils/common'; //,
import { API } from 'utils/api';
import { createFilterOptions } from '@mui/material/Autocomplete';
+import { LoadStatusContext } from 'contexts/StatusContext';
const filter = createFilterOptions();
const SystemSetting = () => {
@@ -56,6 +57,7 @@ const SystemSetting = () => {
let [loading, setLoading] = useState(false);
const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false);
+ const loadStatus = useContext(LoadStatusContext);
const getOptions = async () => {
try {
@@ -116,6 +118,8 @@ const SystemSetting = () => {
...inputs,
[key]: value
}));
+ getOptions();
+ await loadStatus();
showSuccess('设置成功!');
} else {
showError(message);
diff --git a/web/src/views/Token/component/TableRow.js b/web/src/views/Token/component/TableRow.js
index 9e7e84fd..07df8c43 100644
--- a/web/src/views/Token/component/TableRow.js
+++ b/web/src/views/Token/component/TableRow.js
@@ -20,25 +20,10 @@ import {
} from '@mui/material';
import TableSwitch from 'ui-component/Switch';
-import { renderQuota, timestamp2string, copy } from 'utils/common';
+import { renderQuota, timestamp2string, copy, getChatLinks, replaceChatPlaceholders } from 'utils/common';
import { IconDotsVertical, IconEdit, IconTrash, IconCaretDownFilled } from '@tabler/icons-react';
-const COPY_OPTIONS = [
- {
- key: 'next',
- text: 'ChatGPT Next',
- url: 'https://chat.oneapi.pro/#/?settings={"key":"sk-{key}","url":"{serverAddress}"}',
- encode: false
- },
- { key: 'ama', text: 'AMA 问天', url: 'ama://set-api-key?server={serverAddress}&key=sk-{key}', encode: true },
- { key: 'opencat', text: 'OpenCat', url: 'opencat://team/join?domain={serverAddress}&token=sk-{key}', encode: true }
-];
-
-function replacePlaceholders(text, key, serverAddress) {
- return text.replace('{key}', key).replace('{serverAddress}', serverAddress);
-}
-
function createMenu(menuItems) {
return (
<>
@@ -73,6 +58,7 @@ export default function TokensTableRow({ item, manageToken, handleOpenModal, set
const [openDelete, setOpenDelete] = useState(false);
const [statusSwitch, setStatusSwitch] = useState(item.status);
const siteInfo = useSelector((state) => state.siteInfo);
+ const chatLinks = getChatLinks();
const handleDeleteOpen = () => {
handleCloseMenu();
@@ -134,25 +120,19 @@ export default function TokensTableRow({ item, manageToken, handleOpenModal, set
]);
const handleCopy = (option, type) => {
- let serverAddress = '';
+ let server = '';
if (siteInfo?.server_address) {
- serverAddress = siteInfo.server_address;
+ server = siteInfo.server_address;
} else {
- serverAddress = window.location.host;
+ server = window.location.host;
}
- if (option.encode) {
- serverAddress = encodeURIComponent(serverAddress);
- }
+ server = encodeURIComponent(server);
let url = option.url;
- if (option.key === 'next' && siteInfo?.chat_link) {
- url = siteInfo.chat_link + `/#/?settings={"key":"sk-{key}","url":"{serverAddress}"}`;
- }
-
- const key = item.key;
- const text = replacePlaceholders(url, key, serverAddress);
+ const key = 'sk-' + item.key;
+ const text = replaceChatPlaceholders(url, key, server);
if (type === 'link') {
window.open(text);
} else {
@@ -162,8 +142,8 @@ export default function TokensTableRow({ item, manageToken, handleOpenModal, set
};
const copyItems = createMenu(
- COPY_OPTIONS.map((option) => ({
- text: option.text,
+ chatLinks.map((option) => ({
+ text: option.name,
icon: undefined,
onClick: () => handleCopy(option, 'copy'),
color: undefined
@@ -171,8 +151,8 @@ export default function TokensTableRow({ item, manageToken, handleOpenModal, set
);
const linkItems = createMenu(
- COPY_OPTIONS.map((option) => ({
- text: option.text,
+ chatLinks.map((option) => ({
+ text: option.name,
icon: undefined,
onClick: () => handleCopy(option, 'link'),
color: undefined
@@ -227,9 +207,9 @@ export default function TokensTableRow({ item, manageToken, handleOpenModal, set
-
+ handleOpenMenu(e, 'link')} aria-label="split button">
-