update api menu and readme

This commit is contained in:
Jing Hua 2023-03-12 19:05:26 +08:00
parent a5cd36c1d1
commit 9f1529d07a
18 changed files with 281 additions and 128 deletions

View file

@ -22,8 +22,7 @@
<a href="https://github.com/ztjhz/ChatGPTFreeApp/pulls" target="blank">
<img src="https://img.shields.io/github/issues-pr/ztjhz/ChatGPTFreeApp?style=flat-square" alt="pull-requests"/>
</a>
<a href="https://twitter.com/intent/tweet?text=👋请检查这个惊人的存储库 https://github.com/ztjhz/ChatGPTFreeApp由@nikushii_创建。"><img src="https://img.shields.io/twitter/url?label=分享到推特
&style=social&url=https%3A%2F%2Fgithub.com%ztjhz%2FChatGPTFreeApp"></a>
<a href="https://twitter.com/intent/tweet?text=👋请检查这个惊人的存储库 https://github.com/ztjhz/ChatGPTFreeApp由@nikushii_创建。"><img src="https://img.shields.io/twitter/url?label=%E5%88%86%E4%BA%AB%E5%88%B0%E6%8E%A8%E7%89%B9&style=social&url=https%3A%2F%2Fgithub.com%2Fztjhz%2FChatGPTFreeApp"></a>
</p>
<p align="center">
@ -75,7 +74,7 @@
- 💾 所有聊天记录都会自动备份到您的浏览器本地存储器中
- 📥 轻松导入和导出聊天数据 JSON 文件。
- 📥 下载您的整个聊天记录,以 markdownpdf 或图的形式。
- 📥 下载您的整个聊天记录,以 markdownpdf 或图的形式。
### UI / UX

View file

@ -1,6 +1,7 @@
<h1 align="center"><b>ChatGPT Free App</b></h1>
<p align="center">
English Version |
<a href="README-zh_CN.md">
简体中文版
</a>
@ -28,7 +29,7 @@
<a href="https://github.com/ztjhz/ChatGPTFreeApp/pulls" target="blank">
<img src="https://img.shields.io/github/issues-pr/ztjhz/ChatGPTFreeApp?style=flat-square" alt="pull-requests"/>
</a>
<a href="https://twitter.com/intent/tweet?text=👋%20Check%20this%20amazing%20repo%20https://github.com/ztjhz/ChatGPTFreeApp,%20created%20by%20@nikushii_"><img src="https://img.shields.io/twitter/url?label=Share%20on%20Twitter&style=social&url=https%3A%2F%2Fgithub.com%ztjhz%2FChatGPTFreeApp"></a>
<a href="https://twitter.com/intent/tweet?text=👋%20Check%20this%20amazing%20repo%20https://github.com/ztjhz/ChatGPTFreeApp,%20created%20by%20@nikushii_"><img src="https://img.shields.io/twitter/url?label=Share%20on%20Twitter&style=social&url=https%3A%2F%2Fgithub.com%2Fztjhz%2FChatGPTFreeApp"></a>
</p>
<p align="center">

View file

@ -1,13 +1,14 @@
{
"securityMessage": "We prioritise the security of your API key and handle it with utmost care. Your key is exclusively stored on your browser and never shared with any third-party entity. It is solely used for the intended purpose of accessing the OpenAI API and not for any other unauthorised use.",
"apiEndpoint": {
"option": "Use free API endpoint",
"inputLabel": "Free API Endpoint",
"description": "Use free API endpoint from <0>Ayaka</0>: https://chatgpt-api.shn.hk/v1/ or enter your own API endpoint"
"option": "Use for free",
"inputLabel": "API Endpoint",
"description": "Thank you to <0>Ayaka</0> for providing the free API endpoint: https://chatgpt-api.shn.hk/v1/"
},
"apiKey": {
"option": "Use your own API key",
"howTo": "Get your personal API key <0>here</0>",
"inputLabel": "API Key"
}
},
"customEndpoint": "Use custom endpoint"
}

View file

@ -21,5 +21,6 @@
"newChat": "New Chat",
"lightMode": "Light Mode",
"darkMode": "Dark Mode",
"setting": "Settings"
"setting": "Settings",
"image": "Image"
}

View file

@ -2,12 +2,13 @@
"securityMessage": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不是用于任何其他未经授权的用途。",
"apiEndpoint": {
"option": "使用免费的 API 端点",
"inputLabel": "免费的 API 端点",
"description": "使用 <0>Ayaka</0> 提供的免费 API 端点https://chatgpt-api.shn.hk/v1/,或输入您自己的 API 端点"
"inputLabel": "API 端点",
"description": "感谢 <0>Ayaka</0> 提供免费的 API 终端: https://chatgpt-api.shn.hk/v1/。"
},
"apiKey": {
"option": "使用自己的 API 密钥",
"howTo": "在<0>这里</0>获取您的个人 API 密钥",
"inputLabel": "API 密钥"
}
},
"customEndpoint": "使用自定义端点"
}

View file

@ -21,5 +21,6 @@
"newChat": "新聊天",
"lightMode": "亮色模式",
"darkMode": "黑暗模式",
"setting": "设置"
"setting": "设置",
"image": "图片"
}

67
src/api/api.ts Normal file
View file

@ -0,0 +1,67 @@
import { ConfigInterface, MessageInterface } from '@type/chat';
export const getChatCompletion = async (
endpoint: string,
messages: MessageInterface[],
config: ConfigInterface,
apiKey?: string
) => {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
...config,
}),
});
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
return data;
};
export const getChatCompletionStream = async (
endpoint: string,
messages: MessageInterface[],
config: ConfigInterface,
apiKey?: string
) => {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
...config,
stream: true,
}),
});
if (response.status === 404 || response.status === 405)
throw new Error(
'Message from freechatgpt.chat:\nInvalid API endpoint! We recommend you to check your free API endpoint.'
);
if (response.status === 429 || !response.ok) {
const text = await response.text();
let error = text;
if (text.includes('insufficient_quota')) {
error +=
'\nMessage from freechatgpt.chat:\nToo many request! We recommend changing your API endpoint or API key';
}
throw new Error(error);
}
const stream = response.body;
return stream;
};

View file

@ -49,7 +49,7 @@ export const getChatCompletionStream = async (
let error = text;
if (text.includes('insufficient_quota')) {
error +=
'\nMessage from freechatgpt.chat:\nWe recommend changing your API endpoint or API key';
'\nMessage from freechatgpt.chat:\nToo many request! We recommend changing your API endpoint or API key';
}
throw new Error(error);
}

View file

@ -3,12 +3,12 @@ import { useTranslation, Trans } from 'react-i18next';
import useStore from '@store/store';
import PopupModal from '@components/PopupModal';
import { availableEndpoints, defaultAPIEndpoint } from '@constants/auth';
import DownChevronArrow from '@icon/DownChevronArrow';
const ApiMenu = ({
isModalOpen,
setIsModalOpen,
}: {
isModalOpen: boolean;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { t } = useTranslation(['main', 'api']);
@ -17,108 +17,92 @@ const ApiMenu = ({
const setApiKey = useStore((state) => state.setApiKey);
const apiFree = useStore((state) => state.apiFree);
const setApiFree = useStore((state) => state.setApiFree);
const apiFreeEndpoint = useStore((state) => state.apiFreeEndpoint);
const setApiFreeEndpoint = useStore((state) => state.setApiFreeEndpoint);
const apiEndpoint = useStore((state) => state.apiEndpoint);
const setApiEndpoint = useStore((state) => state.setApiEndpoint);
const [_apiFree, _setApiFree] = useState<boolean>(apiFree);
const [_apiKey, _setApiKey] = useState<string>(apiKey || '');
const [_apiFreeEndpoint, _setApiFreeEndpoint] =
useState<string>(apiFreeEndpoint);
const [_apiEndpoint, _setApiEndpoint] = useState<string>(apiEndpoint);
const [_customEndpoint, _setCustomEndpoint] = useState<boolean>(
!availableEndpoints.includes(apiEndpoint)
);
const handleSave = async () => {
if (_apiFree === true) {
setApiFreeEndpoint(_apiFreeEndpoint);
setApiFree(true);
setIsModalOpen(false);
} else {
const handleSave = () => {
setApiFree(_apiFree);
setApiKey(_apiKey);
setApiFree(false);
setApiEndpoint(_apiEndpoint);
setIsModalOpen(false);
}
};
useEffect(() => {
if (apiKey) {
setApiFree(false);
_setApiFree(false);
_setApiKey(apiKey);
}
}, []);
const handleClose = () => {
_setApiFree(apiFree);
_setApiFreeEndpoint(apiFreeEndpoint);
apiKey && _setApiKey(apiKey);
const handleToggleCustomEndpoint = () => {
if (_customEndpoint) _setApiEndpoint(defaultAPIEndpoint);
else _setApiEndpoint('');
_setCustomEndpoint((prev) => !prev);
};
return isModalOpen ? (
return (
<PopupModal
title={t('api') as string}
setIsModalOpen={setIsModalOpen}
handleConfirm={handleSave}
handleClose={handleClose}
>
<div className='p-6 border-b border-gray-200 dark:border-gray-600'>
<div className='flex items-center mb-2'>
<label className='flex gap-2 text-gray-900 dark:text-gray-300 text-sm items-center mb-4'>
<input
type='radio'
checked={_apiFree === true}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
onChange={() => _setApiFree(true)}
type='checkbox'
checked={_customEndpoint}
className='w-4 h-4'
onChange={handleToggleCustomEndpoint}
/>
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('apiEndpoint.option', { ns: 'api' })}
{t('customEndpoint', { ns: 'api' })}
</label>
</div>
{_apiFree && (
<div className='mt-2 mb-6'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-300 mb-2'>
<Trans
i18nKey='apiEndpoint.description'
ns='api'
components={[
<a
href='https://github.com/ayaka14732/ChatGPTAPIFree'
className='link'
target='_blank'
/>,
]}
/>
</div>
<div className='flex gap-2 items-center justify-center'>
<div className='flex gap-2 items-center justify-center mb-6'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'>
{t('apiEndpoint.inputLabel', { ns: 'api' })}
</div>
{_customEndpoint ? (
<input
type='text'
className='text-gray-800 dark:text-white p-3 text-sm border-none bg-gray-200 dark:bg-gray-600 rounded-md m-0 w-full mr-0 h-8 focus:outline-none'
value={_apiFreeEndpoint}
value={_apiEndpoint}
placeholder='https://chatgpt-api.shn.hk/v1/'
onChange={(e) => {
_setApiFreeEndpoint(e.target.value);
_setApiEndpoint(e.target.value);
}}
/>
</div>
</div>
) : (
<ApiEndpointSelector
_apiEndpoint={_apiEndpoint}
_setApiEndpoint={_setApiEndpoint}
/>
)}
</div>
<div className='flex items-center'>
<label className='flex items-center mb-2 gap-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
<input
type='radio'
checked={_apiFree === true}
className='w-4 h-4'
onChange={() => _setApiFree(true)}
/>
{t('apiEndpoint.option', { ns: 'api' })}
</label>
<label className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
<input
type='radio'
checked={_apiFree === false}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
className='w-4 h-4'
onChange={() => _setApiFree(false)}
/>
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{t('apiKey.option', { ns: 'api' })}
</label>
</div>
{_apiFree === false && (
<div className='flex gap-2 items-center justify-center mt-2'>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm'>
{t('apiEndpoint.inputLabel', { ns: 'api' })}
{t('apiKey.inputLabel', { ns: 'api' })}
</div>
<input
type='text'
@ -144,13 +128,73 @@ const ApiMenu = ({
]}
/>
</div>
<div className='min-w-fit text-gray-900 dark:text-gray-300 text-sm mt-4'>
{t('securityMessage', { ns: 'api' })}
</div>
<div className='mt-4 p-1 border border-gray-500 rounded-md text-sm font-medium text-gray-900 dark:text-gray-300 text-center'>
<Trans
i18nKey='apiEndpoint.description'
ns='api'
components={[
<a
href='https://github.com/ayaka14732/ChatGPTAPIFree'
className='link'
target='_blank'
/>,
]}
/>
</div>
</div>
</PopupModal>
) : (
<></>
);
};
const ApiEndpointSelector = ({
_apiEndpoint,
_setApiEndpoint,
}: {
_apiEndpoint: string;
_setApiEndpoint: React.Dispatch<React.SetStateAction<string>>;
}) => {
const [dropDown, setDropDown] = useState<boolean>(false);
return (
<div className='w-full relative'>
<button
className='btn btn-neutral btn-small flex w-32 flex justify-between w-full'
type='button'
onClick={() => setDropDown((prev) => !prev)}
>
{_apiEndpoint}
<DownChevronArrow />
</button>
<div
id='dropdown'
className={`${
dropDown ? '' : 'hidden'
} absolute top-100 bottom-100 z-10 bg-white rounded-lg shadow-xl border-b border-black/10 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group dark:bg-gray-800 opacity-90 w-32 w-full`}
>
<ul
className='text-sm text-gray-700 dark:text-gray-200 p-0 m-0'
aria-labelledby='dropdownDefaultButton'
>
{availableEndpoints.map((endpoint) => (
<li
className='px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white cursor-pointer'
onClick={() => {
_setApiEndpoint(endpoint);
setDropDown(false);
}}
key={endpoint}
>
{endpoint}
</li>
))}
</ul>
</div>
</div>
);
};

View file

@ -30,7 +30,7 @@ const DownloadChat = React.memo(
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title='Download Chat'
title={t('downloadChat') as string}
cancelButton={false}
>
<div className='p-6 border-b border-gray-200 dark:border-gray-600 flex gap-4'>

View file

@ -19,7 +19,7 @@ const Config = () => {
<PersonIcon />
{t('api')}: {apiFree ? t('free') : t('personal')}
</a>
<ApiMenu isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} />
{isModalOpen && <ApiMenu setIsModalOpen={setIsModalOpen} />}
</>
);
};

7
src/constants/auth.ts Normal file
View file

@ -0,0 +1,7 @@
export const defaultAPIEndpoint = 'https://chatgpt-api.shn.hk/v1/';
export const officialAPIEndpoint = 'https://api.openai.com/v1/chat/completions';
export const availableEndpoints = [
'https://chatgpt-api.shn.hk/v1/',
officialAPIEndpoint,
];

View file

@ -1,14 +1,7 @@
import React from 'react';
import useStore from '@store/store';
import { ChatInterface, MessageInterface } from '@type/chat';
import {
getChatCompletionStream as getChatCompletionStreamFree,
getChatCompletion as getChatCompletionFree,
} from '@api/freeApi';
import {
getChatCompletionStream as getChatCompletionStreamCustom,
getChatCompletion as getChatCompletionCustom,
} from '@api/customApi';
import { getChatCompletion, getChatCompletionStream } from '@api/api';
import { parseEventSource } from '@api/helper';
import { limitMessageTokens } from '@utils/messageUtils';
import { defaultChatConfig } from '@constants/chat';
@ -28,13 +21,18 @@ const useSubmit = () => {
): Promise<string> => {
let data;
if (apiFree) {
data = await getChatCompletionFree(
useStore.getState().apiFreeEndpoint,
data = await getChatCompletion(
useStore.getState().apiEndpoint,
message,
defaultChatConfig
);
} else if (apiKey) {
data = await getChatCompletionCustom(apiKey, message, defaultChatConfig);
data = await getChatCompletion(
useStore.getState().apiEndpoint,
message,
defaultChatConfig,
apiKey
);
}
return data.choices[0].message.content;
};
@ -62,16 +60,17 @@ const useSubmit = () => {
if (messages.length === 0) throw new Error('Message exceed max token!');
if (apiFree) {
stream = await getChatCompletionStreamFree(
useStore.getState().apiFreeEndpoint,
stream = await getChatCompletionStream(
useStore.getState().apiEndpoint,
messages,
chats[currentChatIndex].config
);
} else if (apiKey) {
stream = await getChatCompletionStreamCustom(
apiKey,
stream = await getChatCompletionStream(
useStore.getState().apiEndpoint,
messages,
chats[currentChatIndex].config
chats[currentChatIndex].config,
apiKey
);
} else {
throw new Error('No API key supplied! Please check your API settings.');
@ -132,7 +131,7 @@ const useSubmit = () => {
content: `Generate a title in less than 6 words for the following message:\nUser: ${user_message}\nAssistant: ${assistant_message}`,
};
let title = await generateTitle([message]);
let title = (await generateTitle([message])).trim();
if (title.startsWith('"') && title.endsWith('"')) {
title = title.slice(1, -1);
}

View file

@ -6,9 +6,13 @@ import LanguageDetector from 'i18next-browser-languagedetector';
export const i18nLanguages = ['en', 'zh-CN'];
i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
ns: 'main',
ns: ['main', 'api', 'about', 'model'],
defaultNS: 'main',
});

View file

@ -1,17 +1,18 @@
import { defaultAPIEndpoint } from '@constants/auth';
import { StoreSlice } from './store';
export interface AuthSlice {
apiKey?: string;
apiFree: boolean;
apiFreeEndpoint: string;
apiEndpoint: string;
setApiKey: (apiKey: string) => void;
setApiFree: (apiFree: boolean) => void;
setApiFreeEndpoint: (apiFreeEndpoint: string) => void;
setApiEndpoint: (apiEndpoint: string) => void;
}
export const createAuthSlice: StoreSlice<AuthSlice> = (set, get) => ({
apiFree: true,
apiFreeEndpoint: 'https://chatgpt-api.shn.hk/v1/',
apiEndpoint: defaultAPIEndpoint,
setApiKey: (apiKey: string) => {
set((prev: AuthSlice) => ({
...prev,
@ -24,10 +25,10 @@ export const createAuthSlice: StoreSlice<AuthSlice> = (set, get) => ({
apiFree: apiFree,
}));
},
setApiFreeEndpoint: (apiFreeEndpoint: string) => {
setApiEndpoint: (apiEndpoint: string) => {
set((prev: AuthSlice) => ({
...prev,
apiFreeEndpoint: apiFreeEndpoint,
apiEndpoint: apiEndpoint,
}));
},
});

View file

@ -1,9 +1,21 @@
import { LocalStorageInterface } from '@type/chat';
import {
LocalStorageInterfaceV0ToV1,
LocalStorageInterfaceV1ToV2,
} from '@type/chat';
import { defaultChatConfig } from '@constants/chat';
import { defaultAPIEndpoint, officialAPIEndpoint } from '@constants/auth';
export const migrateV0 = (persistedState: LocalStorageInterface) => {
export const migrateV0 = (persistedState: LocalStorageInterfaceV0ToV1) => {
persistedState.chats.forEach((chat) => {
chat.titleSet = false;
if (!chat.config) chat.config = { ...defaultChatConfig };
});
};
export const migrateV1 = (persistedState: LocalStorageInterfaceV1ToV2) => {
if (persistedState.apiFree) {
persistedState.apiEndpoint = persistedState.apiFreeEndpoint;
} else {
persistedState.apiEndpoint = officialAPIEndpoint;
}
};

View file

@ -4,8 +4,11 @@ import { ChatSlice, createChatSlice } from './chat-slice';
import { InputSlice, createInputSlice } from './input-slice';
import { AuthSlice, createAuthSlice } from './auth-slice';
import { ConfigSlice, createConfigSlice } from './config-slice';
import { LocalStorageInterface } from '@type/chat';
import { migrateV0 } from './migrate';
import {
LocalStorageInterfaceV0ToV1,
LocalStorageInterfaceV1ToV2,
} from '@type/chat';
import { migrateV0, migrateV1 } from './migrate';
export type StoreState = ChatSlice & InputSlice & AuthSlice & ConfigSlice;
@ -29,14 +32,16 @@ const useStore = create<StoreState>()(
currentChatIndex: state.currentChatIndex,
apiKey: state.apiKey,
apiFree: state.apiFree,
apiFreeEndpoint: state.apiFreeEndpoint,
apiEndpoint: state.apiEndpoint,
theme: state.theme,
}),
version: 1,
version: 2,
migrate: (persistedState, version) => {
switch (version) {
case 0:
migrateV0(persistedState as LocalStorageInterface);
migrateV0(persistedState as LocalStorageInterfaceV0ToV1);
case 1:
migrateV1(persistedState as LocalStorageInterfaceV1ToV2);
break;
}
return persistedState as StoreState;

View file

@ -20,7 +20,7 @@ export interface ConfigInterface {
presence_penalty: number;
}
export interface LocalStorageInterface {
export interface LocalStorageInterfaceV0ToV1 {
chats: ChatInterface[];
currentChatIndex: number;
apiKey: string;
@ -28,3 +28,13 @@ export interface LocalStorageInterface {
apiFreeEndpoint: string;
theme: Theme;
}
export interface LocalStorageInterfaceV1ToV2 {
chats: ChatInterface[];
currentChatIndex: number;
apiKey: string;
apiFree: boolean;
apiFreeEndpoint: string;
apiEndpoint?: string;
theme: Theme;
}