diff --git a/README-zh_CN.md b/README-zh_CN.md index e0f74f7..fd3be47 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -22,8 +22,7 @@ pull-requests - +

@@ -75,7 +74,7 @@ - 💾 所有聊天记录都会自动备份到您的浏览器本地存储器中 - 📥 轻松导入和导出聊天数据 JSON 文件。 -- 📥 下载您的整个聊天记录,以 markdown,pdf 或图像的形式。 +- 📥 下载您的整个聊天记录,以 markdown,pdf 或图片的形式。 ### UI / UX diff --git a/README.md b/README.md index d4add12..b9d5453 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

ChatGPT Free App

+ English Version | 简体中文版 @@ -28,7 +29,7 @@ pull-requests - +

diff --git a/public/locales/en/api.json b/public/locales/en/api.json index 778757c..a39d281 100644 --- a/public/locales/en/api.json +++ b/public/locales/en/api.json @@ -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: 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 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", "inputLabel": "API Key" - } + }, + "customEndpoint": "Use custom endpoint" } diff --git a/public/locales/en/main.json b/public/locales/en/main.json index f24f1c9..ad8ddd0 100644 --- a/public/locales/en/main.json +++ b/public/locales/en/main.json @@ -21,5 +21,6 @@ "newChat": "New Chat", "lightMode": "Light Mode", "darkMode": "Dark Mode", - "setting": "Settings" + "setting": "Settings", + "image": "Image" } diff --git a/public/locales/zh-CN/api.json b/public/locales/zh-CN/api.json index ade8390..795fb28 100644 --- a/public/locales/zh-CN/api.json +++ b/public/locales/zh-CN/api.json @@ -2,12 +2,13 @@ "securityMessage": "我们高度优先考虑您的 API 密钥的安全,并非常小心地处理它。您的密钥将专门存储在您的浏览器中,并且永远不会与任何第三方实体共享。它仅用于访问 OpenAI API 的预期用途,而不是用于任何其他未经授权的用途。", "apiEndpoint": { "option": "使用免费的 API 端点", - "inputLabel": "免费的 API 端点", - "description": "使用 <0>Ayaka 提供的免费 API 端点:https://chatgpt-api.shn.hk/v1/,或输入您自己的 API 端点" + "inputLabel": "API 端点", + "description": "感谢 <0>Ayaka 提供免费的 API 终端: https://chatgpt-api.shn.hk/v1/。" }, "apiKey": { "option": "使用自己的 API 密钥", "howTo": "在<0>这里获取您的个人 API 密钥", "inputLabel": "API 密钥" - } + }, + "customEndpoint": "使用自定义端点" } diff --git a/public/locales/zh-CN/main.json b/public/locales/zh-CN/main.json index 8a4485c..a579ad9 100644 --- a/public/locales/zh-CN/main.json +++ b/public/locales/zh-CN/main.json @@ -21,5 +21,6 @@ "newChat": "新聊天", "lightMode": "亮色模式", "darkMode": "黑暗模式", - "setting": "设置" + "setting": "设置", + "image": "图片" } diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..43322e4 --- /dev/null +++ b/src/api/api.ts @@ -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; +}; diff --git a/src/api/freeApi.ts b/src/api/freeApi.ts index 742fd72..25e6e7a 100644 --- a/src/api/freeApi.ts +++ b/src/api/freeApi.ts @@ -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); } diff --git a/src/components/ApiMenu/ApiMenu.tsx b/src/components/ApiMenu/ApiMenu.tsx index df0539f..ba31a14 100644 --- a/src/components/ApiMenu/ApiMenu.tsx +++ b/src/components/ApiMenu/ApiMenu.tsx @@ -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>; }) => { 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(apiFree); const [_apiKey, _setApiKey] = useState(apiKey || ''); - const [_apiFreeEndpoint, _setApiFreeEndpoint] = - useState(apiFreeEndpoint); + const [_apiEndpoint, _setApiEndpoint] = useState(apiEndpoint); + const [_customEndpoint, _setCustomEndpoint] = useState( + !availableEndpoints.includes(apiEndpoint) + ); - const handleSave = async () => { - if (_apiFree === true) { - setApiFreeEndpoint(_apiFreeEndpoint); - setApiFree(true); - setIsModalOpen(false); - } else { - setApiKey(_apiKey); - setApiFree(false); - setIsModalOpen(false); - } + const handleSave = () => { + setApiFree(_apiFree); + setApiKey(_apiKey); + 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 (

-
+ + +
+
+ {t('apiEndpoint.inputLabel', { ns: 'api' })} +
+ {_customEndpoint ? ( + { + _setApiEndpoint(e.target.value); + }} + /> + ) : ( + + )} +
+ +
+ {t('apiEndpoint.option', { ns: 'api' })} + - {_apiFree && ( -
-
- , - ]} - /> -
-
-
- {t('apiEndpoint.inputLabel', { ns: 'api' })} -
- { - _setApiFreeEndpoint(e.target.value); - }} - /> -
-
- )} - -
+
+ {t('apiKey.option', { ns: 'api' })} + {_apiFree === false && (
- {t('apiEndpoint.inputLabel', { ns: 'api' })} + {t('apiKey.inputLabel', { ns: 'api' })}
+
{t('securityMessage', { ns: 'api' })}
+ +
+ , + ]} + /> +
- ) : ( - <> + ); +}; + +const ApiEndpointSelector = ({ + _apiEndpoint, + _setApiEndpoint, +}: { + _apiEndpoint: string; + _setApiEndpoint: React.Dispatch>; +}) => { + const [dropDown, setDropDown] = useState(false); + + return ( +
+ + +
); }; diff --git a/src/components/Chat/ChatContent/DownloadChat.tsx b/src/components/Chat/ChatContent/DownloadChat.tsx index cda4829..1591e41 100644 --- a/src/components/Chat/ChatContent/DownloadChat.tsx +++ b/src/components/Chat/ChatContent/DownloadChat.tsx @@ -30,7 +30,7 @@ const DownloadChat = React.memo( {isModalOpen && (
diff --git a/src/components/Menu/MenuOptions/Api.tsx b/src/components/Menu/MenuOptions/Api.tsx index 0e5772b..e3f4d0c 100644 --- a/src/components/Menu/MenuOptions/Api.tsx +++ b/src/components/Menu/MenuOptions/Api.tsx @@ -19,7 +19,7 @@ const Config = () => { {t('api')}: {apiFree ? t('free') : t('personal')} - + {isModalOpen && } ); }; diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..4c719f5 --- /dev/null +++ b/src/constants/auth.ts @@ -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, +]; diff --git a/src/hooks/useSubmit.ts b/src/hooks/useSubmit.ts index 3412e9f..b4c2ab0 100644 --- a/src/hooks/useSubmit.ts +++ b/src/hooks/useSubmit.ts @@ -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 => { 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); } diff --git a/src/i18n.ts b/src/i18n.ts index 0b1afeb..4b3f1bc 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -6,10 +6,14 @@ import LanguageDetector from 'i18next-browser-languagedetector'; export const i18nLanguages = ['en', 'zh-CN']; -i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({ - fallbackLng: 'en', - ns: 'main', - defaultNS: 'main', -}); +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + ns: ['main', 'api', 'about', 'model'], + defaultNS: 'main', + }); export default i18n; diff --git a/src/store/auth-slice.ts b/src/store/auth-slice.ts index e99f841..675aa73 100644 --- a/src/store/auth-slice.ts +++ b/src/store/auth-slice.ts @@ -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 = (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 = (set, get) => ({ apiFree: apiFree, })); }, - setApiFreeEndpoint: (apiFreeEndpoint: string) => { + setApiEndpoint: (apiEndpoint: string) => { set((prev: AuthSlice) => ({ ...prev, - apiFreeEndpoint: apiFreeEndpoint, + apiEndpoint: apiEndpoint, })); }, }); diff --git a/src/store/migrate.ts b/src/store/migrate.ts index 3e53c2e..3b6dec4 100644 --- a/src/store/migrate.ts +++ b/src/store/migrate.ts @@ -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; + } +}; diff --git a/src/store/store.ts b/src/store/store.ts index 405f3c2..a70ba9a 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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()( 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; diff --git a/src/types/chat.ts b/src/types/chat.ts index 118406c..912c2ce 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -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; +}