diff --git a/README.md b/README.md index d73964965..b38faea9c 100644 --- a/README.md +++ b/README.md @@ -144,20 +144,23 @@ One click deploy with Vercel 5. Launch the app by running `yarn dev` or `npm run dev` ### Running it locally using docker compose + 1. Ensure that you have the following installed: - [docker](https://www.docker.com/) (v24.0.7 or above) - ```bash - curl https://get.docker.com | sh \ - && sudo usermod -aG docker $USER - ``` + ```bash + curl https://get.docker.com | sh \ + && sudo usermod -aG docker $USER + ``` 2. Build the docker image + ``` docker compose build ``` 3. Build and start the container using docker compose + ``` docker compose build docker compose up -d @@ -168,6 +171,24 @@ One click deploy with Vercel docker compose down ``` +### Running it locally via desktop app + +1. Ensure that you have the following installed: + + - [yarn](https://yarnpkg.com/) or [npm](https://www.npmjs.com/) (6.14.15 or above) + +2. Build the executable (Windows) + + ``` + yarn make --win + ``` + +3. Build for other OS + ``` + yarn make _ADD_BUILD_ARGS_HERE + ``` + To find out available building arguments, go to [electron-builder reference](https://www.electron.build/cli.html) + # ⭐️ Star History [![Star History Chart](https://api.star-history.com/svg?repos=ztjhz/BetterChatGPT&type=Date)](https://github.com/ztjhz/BetterChatGPT/stargazers) diff --git a/package.json b/package.json index 63a8feb6f..6334f9ad0 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "preview": "vite preview", "electron": "concurrently -k \"BROWSER=none yarn dev\" \"wait-on tcp:5173 && electron .\"", "pack": "yarn build && electron-builder --dir", - "make": "yarn build && electron-builder" + "make": "yarn build && electron-builder", + "debug": "concurrently -k \"cross-env BROWSER=none yarn dev\" \"wait-on tcp:5173 && electron --inspect=5858 .\"" }, "build": { "appId": "better-chatgpt", @@ -62,6 +63,7 @@ "react-i18next": "^12.2.0", "react-markdown": "^8.0.5", "react-scroll-to-bottom": "^4.2.0", + "react-toastify": "^10.0.5", "rehype-highlight": "^6.0.0", "rehype-katex": "^6.0.2", "remark-gfm": "^3.0.1", @@ -80,6 +82,7 @@ "@vitejs/plugin-react-swc": "^3.0.0", "autoprefixer": "^10.4.13", "concurrently": "^8.0.1", + "cross-env": "^7.0.3", "electron": "^23.2.0", "electron-builder": "^23.6.0", "postcss": "^8.4.21", diff --git a/public/locales/en/api.json b/public/locales/en/api.json index 91e75952a..2485db13f 100644 --- a/public/locales/en/api.json +++ b/public/locales/en/api.json @@ -9,6 +9,10 @@ "howTo": "Get your personal API key <0>here.", "inputLabel": "API Key" }, + "apiVersion": { + "inputLabel": "Api Version", + "description": "Api Version e.g. 2024-04-01-preview" + }, "customEndpoint": "Use custom API endpoint", "advancedConfig": "View advanced API configuration <0>here", "noApiKeyWarning": "No API key supplied! Please check your API settings." diff --git a/src/App.tsx b/src/App.tsx index 7f9fb8d63..9f6d509f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { ChatInterface } from '@type/chat'; import { Theme } from '@type/theme'; import ApiPopup from '@components/ApiPopup'; import Toast from '@components/Toast'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; function App() { const initialiseNewChat = useInitialiseNewChat(); @@ -80,6 +82,7 @@ function App() { + ); } diff --git a/src/api/api.ts b/src/api/api.ts index 505fd4b37..ba1dbc38f 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,5 +1,10 @@ import { ShareGPTSubmitBodyInterface } from '@type/api'; -import { ConfigInterface, MessageInterface, ModelOptions } from '@type/chat'; +import { + ConfigInterface, + ImageContentInterface, + MessageInterface, + ModelOptions, +} from '@type/chat'; import { isAzureEndpoint } from '@utils/api'; export const getChatCompletion = async ( @@ -7,7 +12,8 @@ export const getChatCompletion = async ( messages: MessageInterface[], config: ConfigInterface, apiKey?: string, - customHeaders?: Record + customHeaders?: Record, + apiVersionToUse?: string ) => { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -29,9 +35,10 @@ export const getChatCompletion = async ( // set api version to 2023-07-01-preview for gpt-4 and gpt-4-32k, otherwise use 2023-03-15-preview const apiVersion = - model === 'gpt-4' || model === 'gpt-4-32k' + apiVersionToUse ?? + (model === 'gpt-4' || model === 'gpt-4-32k' ? '2023-07-01-preview' - : '2023-03-15-preview'; + : '2023-03-15-preview'); const path = `openai/deployments/${model}/chat/completions?api-version=${apiVersion}`; @@ -42,6 +49,7 @@ export const getChatCompletion = async ( endpoint += path; } } + endpoint = endpoint.trim(); const response = await fetch(endpoint, { method: 'POST', @@ -63,7 +71,8 @@ export const getChatCompletionStream = async ( messages: MessageInterface[], config: ConfigInterface, apiKey?: string, - customHeaders?: Record + customHeaders?: Record, + apiVersionToUse?: string ) => { const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -83,9 +92,10 @@ export const getChatCompletionStream = async ( // set api version to 2023-07-01-preview for gpt-4 and gpt-4-32k, otherwise use 2023-03-15-preview const apiVersion = - model === 'gpt-4' || model === 'gpt-4-32k' + apiVersionToUse ?? + (model === 'gpt-4' || model === 'gpt-4-32k' ? '2023-07-01-preview' - : '2023-03-15-preview'; + : '2023-03-15-preview'); const path = `openai/deployments/${model}/chat/completions?api-version=${apiVersion}`; if (!endpoint.endsWith(path)) { @@ -95,7 +105,7 @@ export const getChatCompletionStream = async ( endpoint += path; } } - + endpoint = endpoint.trim(); const response = await fetch(endpoint, { method: 'POST', headers, diff --git a/src/components/ApiMenu/ApiMenu.tsx b/src/components/ApiMenu/ApiMenu.tsx index 23a99d1c2..027bf7098 100644 --- a/src/components/ApiMenu/ApiMenu.tsx +++ b/src/components/ApiMenu/ApiMenu.tsx @@ -21,16 +21,20 @@ const ApiMenu = ({ const setApiKey = useStore((state) => state.setApiKey); const apiEndpoint = useStore((state) => state.apiEndpoint); const setApiEndpoint = useStore((state) => state.setApiEndpoint); + const apiVersion = useStore((state) => state.apiVersion); + const setApiVersion = useStore((state) => state.setApiVersion); const [_apiKey, _setApiKey] = useState(apiKey || ''); const [_apiEndpoint, _setApiEndpoint] = useState(apiEndpoint); const [_customEndpoint, _setCustomEndpoint] = useState( !availableEndpoints.includes(apiEndpoint) ); + const [_apiVersion, _setApiVersion] = useState(apiVersion || ''); const handleSave = () => { setApiKey(_apiKey); setApiEndpoint(_apiEndpoint); + setApiVersion(_apiVersion); setIsModalOpen(false); }; @@ -92,6 +96,20 @@ const ApiMenu = ({ /> +
+
+ {t('apiVersion.inputLabel', { ns: 'api' })} +
+ { + _setApiVersion(e.target.value); + }} + /> +

{ const inputRole = useStore((state) => state.inputRole); @@ -79,7 +80,10 @@ const ChatContent = () => { diff --git a/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx b/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx index e61ba00f9..1c1ab1720 100644 --- a/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx +++ b/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx @@ -6,11 +6,12 @@ import { matchSorter } from 'match-sorter'; import { Prompt } from '@type/prompt'; import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick'; +import { ContentInterface } from '@type/chat'; const CommandPrompt = ({ _setContent, }: { - _setContent: React.Dispatch>; + _setContent: React.Dispatch>; }) => { const { t } = useTranslation(); const prompts = useStore((state) => state.prompts); @@ -69,7 +70,7 @@ const CommandPrompt = ({

  • { - _setContent((prev) => prev + cp.prompt); + _setContent((prev) => [{type: 'text', text: prev + cp.prompt}, ...prev.slice(1)]); setDropDown(false); }} key={cp.id} diff --git a/src/components/Chat/ChatContent/Message/Message.tsx b/src/components/Chat/ChatContent/Message/Message.tsx index 53904b7a3..04a18b357 100644 --- a/src/components/Chat/ChatContent/Message/Message.tsx +++ b/src/components/Chat/ChatContent/Message/Message.tsx @@ -4,7 +4,7 @@ import useStore from '@store/store'; import Avatar from './Avatar'; import MessageContent from './MessageContent'; -import { Role } from '@type/chat'; +import { ContentInterface, Role } from '@type/chat'; import RoleSelector from './RoleSelector'; // const backgroundStyle: { [role in Role]: string } = { @@ -22,7 +22,7 @@ const Message = React.memo( sticky = false, }: { role: Role; - content: string; + content: ContentInterface[], messageIndex: number; sticky?: boolean; }) => { diff --git a/src/components/Chat/ChatContent/Message/MessageContent.tsx b/src/components/Chat/ChatContent/Message/MessageContent.tsx index 0f60b6b60..9136dac5e 100644 --- a/src/components/Chat/ChatContent/Message/MessageContent.tsx +++ b/src/components/Chat/ChatContent/Message/MessageContent.tsx @@ -3,6 +3,7 @@ import useStore from '@store/store'; import ContentView from './View/ContentView'; import EditView from './View/EditView'; +import { ContentInterface } from '@type/chat'; const MessageContent = ({ role, @@ -11,7 +12,7 @@ const MessageContent = ({ sticky = false, }: { role: string; - content: string; + content: ContentInterface[]; messageIndex: number; sticky?: boolean; }) => { diff --git a/src/components/Chat/ChatContent/Message/NewMessageButton.tsx b/src/components/Chat/ChatContent/Message/NewMessageButton.tsx index 2dd034d3c..a33b12aa8 100644 --- a/src/components/Chat/ChatContent/Message/NewMessageButton.tsx +++ b/src/components/Chat/ChatContent/Message/NewMessageButton.tsx @@ -3,7 +3,7 @@ import useStore from '@store/store'; import PlusIcon from '@icon/PlusIcon'; -import { ChatInterface } from '@type/chat'; +import { ChatInterface, TextContentInterface } from '@type/chat'; import { generateDefaultChat } from '@constants/chat'; const NewMessageButton = React.memo( @@ -38,7 +38,7 @@ const NewMessageButton = React.memo( JSON.stringify(useStore.getState().chats) ); updatedChats[currentChatIndex].messages.splice(messageIndex + 1, 0, { - content: '', + content: [{ type: 'text', text: '' } as TextContentInterface], role: 'user', }); setChats(updatedChats); diff --git a/src/components/Chat/ChatContent/Message/View/ContentView.tsx b/src/components/Chat/ChatContent/Message/View/ContentView.tsx index ad4c415a9..11556aec5 100644 --- a/src/components/Chat/ChatContent/Message/View/ContentView.tsx +++ b/src/components/Chat/ChatContent/Message/View/ContentView.tsx @@ -19,7 +19,7 @@ import CrossIcon from '@icon/CrossIcon'; import useSubmit from '@hooks/useSubmit'; -import { ChatInterface } from '@type/chat'; +import { ChatInterface, ContentInterface, ImageContentInterface, TextContentInterface } from '@type/chat'; import { codeLanguageSubset } from '@constants/chat'; @@ -41,7 +41,7 @@ const ContentView = memo( messageIndex, }: { role: string; - content: string; + content: ContentInterface[], setIsEdit: React.Dispatch>; messageIndex: number; }) => { @@ -100,7 +100,7 @@ const ContentView = memo( }; const handleCopy = () => { - navigator.clipboard.writeText(content); + navigator.clipboard.writeText((content[0] as TextContentInterface).text); }; return ( @@ -129,12 +129,19 @@ const ContentView = memo( p, }} > - {content} + {(content[0] as TextContentInterface).text} ) : ( - {content} + {(content[0] as TextContentInterface).text} )}
  • +
    + {(content.slice(1) as ImageContentInterface[]).map((image, index) => ( +
    + {`uploaded-${index}`} +
    + ))} +
    {isDelete || ( <> diff --git a/src/components/Chat/ChatContent/Message/View/EditView.tsx b/src/components/Chat/ChatContent/Message/View/EditView.tsx index e0d9503e2..7bfdf2830 100644 --- a/src/components/Chat/ChatContent/Message/View/EditView.tsx +++ b/src/components/Chat/ChatContent/Message/View/EditView.tsx @@ -1,22 +1,29 @@ -import React, { memo, useEffect, useState } from 'react'; +import React, { memo, useEffect, useState, useRef, ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import useSubmit from '@hooks/useSubmit'; -import { ChatInterface } from '@type/chat'; +import { + ChatInterface, + ContentInterface, + ImageContentInterface, + TextContentInterface, +} from '@type/chat'; import PopupModal from '@components/PopupModal'; import TokenCount from '@components/TokenCount'; import CommandPrompt from '../CommandPrompt'; +import FolderIcon from '@icon/FolderIcon'; +import { defaultModel, modelTypes } from '@constants/chat'; const EditView = ({ - content, + content: content, setIsEdit, messageIndex, sticky, }: { - content: string; + content: ContentInterface[]; setIsEdit: React.Dispatch>; messageIndex: number; sticky?: boolean; @@ -25,7 +32,7 @@ const EditView = ({ const setChats = useStore((state) => state.setChats); const currentChatIndex = useStore((state) => state.currentChatIndex); - const [_content, _setContent] = useState(content); + const [_content, _setContent] = useState(content); const [isModalOpen, setIsModalOpen] = useState(false); const textareaRef = React.createRef(); @@ -63,15 +70,73 @@ const EditView = ({ } }; + // convert message blob urls to base64 + const blobToBase64 = async (blob: Blob) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = reject; + reader.onload = () => { + resolve(reader.result); + }; + reader.readAsDataURL(blob); + }); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files!; + const newImageURLs = Array.from(files).map((file: Blob) => + URL.createObjectURL(file) + ); + const newImages = await Promise.all( + newImageURLs.map(async (url) => { + const blob = await fetch(url).then((r) => r.blob()); + return { + type: 'image_url', + image_url: { + detail: 'auto', + url: (await blobToBase64(blob)) as string, + }, + } as ImageContentInterface; + }) + ); + const updatedContent = [..._content, ...newImages]; + + _setContent(updatedContent); + }; + + const handleImageDetailChange = (index: number, detail: string) => { + const updatedImages = [..._content]; + updatedImages[index + 1].image_url.detail = detail; + _setContent(updatedImages); + }; + + const handleRemoveImage = (index: number) => { + const updatedImages = [..._content]; + updatedImages.splice(index + 1, 1); + + _setContent(updatedImages); + }; + const handleSave = () => { - if (sticky && (_content === '' || useStore.getState().generating)) return; + if ( + sticky && + ((_content[0] as TextContentInterface).text === '' || + useStore.getState().generating) + ) + return; const updatedChats: ChatInterface[] = JSON.parse( JSON.stringify(useStore.getState().chats) ); const updatedMessages = updatedChats[currentChatIndex].messages; + if (sticky) { updatedMessages.push({ role: inputRole, content: _content }); - _setContent(''); + _setContent([ + { + type: 'text', + text: '', + } as TextContentInterface, + ]); resetTextAreaHeight(); } else { updatedMessages[messageIndex].content = _content; @@ -87,11 +152,17 @@ const EditView = ({ JSON.stringify(useStore.getState().chats) ); const updatedMessages = updatedChats[currentChatIndex].messages; + if (sticky) { - if (_content !== '') { + if ((_content[0] as TextContentInterface).text !== '') { updatedMessages.push({ role: inputRole, content: _content }); } - _setContent(''); + _setContent([ + { + type: 'text', + text: '', + } as TextContentInterface, + ]); resetTextAreaHeight(); } else { updatedMessages[messageIndex].content = _content; @@ -101,7 +172,11 @@ const EditView = ({ ); setIsEdit(false); } - setChats(updatedChats); + try { + setChats(updatedChats); + } catch (e) { + console.log(e); + } handleSubmit(); }; @@ -110,7 +185,7 @@ const EditView = ({ textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } - }, [_content]); + }, [(_content[0] as TextContentInterface).text]); useEffect(() => { if (textareaRef.current) { @@ -132,9 +207,12 @@ const EditView = ({ ref={textareaRef} className='m-0 resize-none rounded-lg bg-transparent overflow-y-hidden focus:ring-0 focus-visible:ring-0 leading-7 w-full placeholder:text-gray-500/40' onChange={(e) => { - _setContent(e.target.value); + _setContent((prev) => [ + { type: 'text', text: e.target.value }, + ...prev.slice(1), + ]); }} - value={_content} + value={(_content[0] as TextContentInterface).text} placeholder={t('submitPlaceholder') as string} onKeyDown={handleKeyDown} rows={1} @@ -142,11 +220,15 @@ const EditView = ({
    {isModalOpen && ( ) => void; + handleImageDetailChange: (index: number, e: string) => void; + handleRemoveImage: (index: number) => void; handleGenerate: () => void; handleSave: () => void; setIsModalOpen: React.Dispatch>; setIsEdit: React.Dispatch>; - _setContent: React.Dispatch>; + _setContent: React.Dispatch>; + _content: ContentInterface[]; }) => { const { t } = useTranslation(); const generating = useStore.getState().generating; const advancedMode = useStore((state) => state.advancedMode); + const model = useStore((state) => { + const isInitialised = + state.chats && state.chats.length > 0 && state.currentChatIndex >= 0; + return isInitialised + ? state.chats![state.currentChatIndex].config.model + : defaultModel; + }); + const fileInputRef = useRef(null); + + const handleUploadButtonClick = () => { + // Trigger the file input when the custom button is clicked + (fileInputRef.current! as HTMLInputElement).click(); + }; return ( -
    -
    - {sticky && ( - - )} +
    + {modelTypes[model] == 'image' && ( +
    +
    + {_content.slice(1).map((image, index) => ( +
    + {`uploaded-${index}`} +
    + + +
    +
    + ))} - {sticky || ( - - )} - -
    - - {sticky || ( + {/* Hidden file input */} + +
    + )} +
    +
    + {sticky && ( + + )} + + {sticky || ( + + )} + - )} + + {sticky || ( + + )} +
    + {sticky && advancedMode && } +
    - {sticky && advancedMode && } -
    ); } diff --git a/src/constants/chat.ts b/src/constants/chat.ts index f806ef1b6..1191bed54 100644 --- a/src/constants/chat.ts +++ b/src/constants/chat.ts @@ -1,5 +1,12 @@ import { v4 as uuidv4 } from 'uuid'; -import { ChatInterface, ConfigInterface, ModelOptions } from '@type/chat'; +import { + ChatInterface, + ConfigInterface, + ModelCost, + ModelOptions, + ModelType, + TextContentInterface, +} from '@type/chat'; import useStore from '@store/store'; const date = new Date(); @@ -35,9 +42,10 @@ export const modelOptions: ModelOptions[] = [ // 'gpt-4-32k-0314', ]; +export const defaultApiVersion = '2024-04-01-preview'; export const defaultModel = 'gpt-3.5-turbo'; -export const modelMaxToken = { +export const modelMaxToken: { [key: string]: number } = { 'gpt-3.5-turbo': 4096, 'gpt-3.5-turbo-0301': 4096, 'gpt-3.5-turbo-0613': 4096, @@ -59,7 +67,7 @@ export const modelMaxToken = { 'gpt-4o-2024-05-13': 128000, }; -export const modelCost = { +export const modelCost: ModelCost = { 'gpt-3.5-turbo': { prompt: { price: 0.0015, unit: 1000 }, completion: { price: 0.002, unit: 1000 }, @@ -157,7 +165,17 @@ export const generateDefaultChat = ( title: title ? title : 'New Chat', messages: useStore.getState().defaultSystemMessage.length > 0 - ? [{ role: 'system', content: useStore.getState().defaultSystemMessage }] + ? [ + { + role: 'system', + content: [ + { + type: 'text', + text: useStore.getState().defaultSystemMessage, + } as TextContentInterface, + ], + }, + ] : [], config: { ...useStore.getState().defaultChatConfig }, titleSet: false, @@ -201,3 +219,9 @@ export const codeLanguageSubset = [ 'xml', 'yaml', ]; + +export const modelTypes: { [key: string]: string } = { + 'gpt-4o': 'image', + 'gpt-4o-2024-05-13': 'image', + 'gpt-4-vision-preview': 'image', +}; diff --git a/src/hooks/useSubmit.ts b/src/hooks/useSubmit.ts index cd6efc5aa..f6315f181 100644 --- a/src/hooks/useSubmit.ts +++ b/src/hooks/useSubmit.ts @@ -1,7 +1,10 @@ -import React from 'react'; import useStore from '@store/store'; import { useTranslation } from 'react-i18next'; -import { ChatInterface, MessageInterface } from '@type/chat'; +import { + ChatInterface, + MessageInterface, + TextContentInterface, +} from '@type/chat'; import { getChatCompletion, getChatCompletionStream } from '@api/api'; import { parseEventSource } from '@api/helper'; import { limitMessageTokens, updateTotalTokenUsed } from '@utils/messageUtils'; @@ -34,7 +37,10 @@ const useSubmit = () => { data = await getChatCompletion( useStore.getState().apiEndpoint, message, - _defaultChatConfig + _defaultChatConfig, + undefined, + undefined, + useStore.getState().apiVersion ); } else if (apiKey) { // own apikey @@ -42,7 +48,9 @@ const useSubmit = () => { useStore.getState().apiEndpoint, message, _defaultChatConfig, - apiKey + apiKey, + undefined, + useStore.getState().apiVersion ); } } catch (error: unknown) { @@ -59,7 +67,12 @@ const useSubmit = () => { updatedChats[currentChatIndex].messages.push({ role: 'assistant', - content: '', + content: [ + { + type: 'text', + text: '', + } as TextContentInterface, + ], }); setChats(updatedChats); @@ -88,7 +101,10 @@ const useSubmit = () => { stream = await getChatCompletionStream( useStore.getState().apiEndpoint, messages, - chats[currentChatIndex].config + chats[currentChatIndex].config, + undefined, + undefined, + useStore.getState().apiVersion ); } else if (apiKey) { // own apikey @@ -96,7 +112,9 @@ const useSubmit = () => { useStore.getState().apiEndpoint, messages, chats[currentChatIndex].config, - apiKey + apiKey, + undefined, + useStore.getState().apiVersion ); } @@ -132,7 +150,10 @@ const useSubmit = () => { JSON.stringify(useStore.getState().chats) ); const updatedMessages = updatedChats[currentChatIndex].messages; - updatedMessages[updatedMessages.length - 1].content += resultString; + ( + updatedMessages[updatedMessages.length - 1] + .content[0] as TextContentInterface + ).text += resultString; setChats(updatedChats); } } @@ -173,7 +194,12 @@ const useSubmit = () => { const message: MessageInterface = { role: 'user', - content: `Generate a title in less than 6 words for the following message (language: ${i18n.language}):\n"""\nUser: ${user_message}\nAssistant: ${assistant_message}\n"""`, + content: [ + { + type: 'text', + text: `Generate a title in less than 6 words for the following message (language: ${i18n.language}):\n"""\nUser: ${user_message}\nAssistant: ${assistant_message}\n"""`, + } as TextContentInterface, + ], }; let title = (await generateTitle([message])).trim(); @@ -192,7 +218,7 @@ const useSubmit = () => { const model = _defaultChatConfig.model; updateTotalTokenUsed(model, [message], { role: 'assistant', - content: title, + content: [{ type: 'text', text: title } as TextContentInterface], }); } } diff --git a/src/store/auth-slice.ts b/src/store/auth-slice.ts index 52088906a..b76affe94 100644 --- a/src/store/auth-slice.ts +++ b/src/store/auth-slice.ts @@ -4,15 +4,18 @@ import { StoreSlice } from './store'; export interface AuthSlice { apiKey?: string; apiEndpoint: string; + apiVersion?: string; firstVisit: boolean; setApiKey: (apiKey: string) => void; setApiEndpoint: (apiEndpoint: string) => void; + setApiVersion: (apiVersion: string) => void; setFirstVisit: (firstVisit: boolean) => void; } export const createAuthSlice: StoreSlice = (set, get) => ({ apiKey: import.meta.env.VITE_OPENAI_API_KEY || undefined, apiEndpoint: defaultAPIEndpoint, + apiVersion: undefined, firstVisit: true, setApiKey: (apiKey: string) => { set((prev: AuthSlice) => ({ @@ -26,6 +29,12 @@ export const createAuthSlice: StoreSlice = (set, get) => ({ apiEndpoint: apiEndpoint, })); }, + setApiVersion: (apiVersion: string) => { + set((prev: AuthSlice) => ({ + ...prev, + apiVersion: apiVersion, + })); + }, setFirstVisit: (firstVisit: boolean) => { set((prev: AuthSlice) => ({ ...prev, diff --git a/src/store/chat-slice.ts b/src/store/chat-slice.ts index 0a3b682dc..f46ab3925 100644 --- a/src/store/chat-slice.ts +++ b/src/store/chat-slice.ts @@ -1,5 +1,6 @@ import { StoreSlice } from './store'; import { ChatInterface, FolderCollection, MessageInterface } from '@type/chat'; +import { toast } from 'react-toastify'; export interface ChatSlice { messages: MessageInterface[]; @@ -16,46 +17,54 @@ export interface ChatSlice { setFolders: (folders: FolderCollection) => void; } -export const createChatSlice: StoreSlice = (set, get) => ({ - messages: [], - currentChatIndex: -1, - generating: false, - error: '', - folders: {}, - setMessages: (messages: MessageInterface[]) => { - set((prev: ChatSlice) => ({ - ...prev, - messages: messages, - })); - }, - setChats: (chats: ChatInterface[]) => { - set((prev: ChatSlice) => ({ - ...prev, - chats: chats, - })); - }, - setCurrentChatIndex: (currentChatIndex: number) => { - set((prev: ChatSlice) => ({ - ...prev, - currentChatIndex: currentChatIndex, - })); - }, - setGenerating: (generating: boolean) => { - set((prev: ChatSlice) => ({ - ...prev, - generating: generating, - })); - }, - setError: (error: string) => { - set((prev: ChatSlice) => ({ - ...prev, - error: error, - })); - }, - setFolders: (folders: FolderCollection) => { - set((prev: ChatSlice) => ({ - ...prev, - folders: folders, - })); - }, -}); +export const createChatSlice: StoreSlice = (set, get) => { + return { + messages: [], + currentChatIndex: -1, + generating: false, + error: '', + folders: {}, + setMessages: (messages: MessageInterface[]) => { + set((prev: ChatSlice) => ({ + ...prev, + messages: messages, + })); + }, + setChats: (chats: ChatInterface[]) => { + try { + set((prev: ChatSlice) => ({ + ...prev, + chats: chats, + })); + } catch (e: unknown) { + // Notify if storage quota exceeded + toast((e as Error).message); + throw e; + } + }, + setCurrentChatIndex: (currentChatIndex: number) => { + set((prev: ChatSlice) => ({ + ...prev, + currentChatIndex: currentChatIndex, + })); + }, + setGenerating: (generating: boolean) => { + set((prev: ChatSlice) => ({ + ...prev, + generating: generating, + })); + }, + setError: (error: string) => { + set((prev: ChatSlice) => ({ + ...prev, + error: error, + })); + }, + setFolders: (folders: FolderCollection) => { + set((prev: ChatSlice) => ({ + ...prev, + folders: folders, + })); + }, + }; +}; diff --git a/src/store/migrate.ts b/src/store/migrate.ts index b96ba6011..a87c57fb5 100644 --- a/src/store/migrate.ts +++ b/src/store/migrate.ts @@ -11,9 +11,12 @@ import { LocalStorageInterfaceV5ToV6, LocalStorageInterfaceV6ToV7, LocalStorageInterfaceV7oV8, + LocalStorageInterfaceV8oV8_1, + TextContentInterface, } from '@type/chat'; import { _defaultChatConfig, + defaultApiVersion, defaultModel, defaultUserMaxToken, } from '@constants/chat'; @@ -104,3 +107,17 @@ export const migrateV7 = (persistedState: LocalStorageInterfaceV7oV8) => { chat.id = uuidv4(); }); }; + +export const migrateV8_1 = (persistedState: LocalStorageInterfaceV8oV8_1) => { + persistedState.chats.forEach((chat) => { + persistedState.apiVersion = defaultApiVersion; + chat.messages.forEach((msg) => { + if (typeof msg.content === 'string') { + const content: TextContentInterface[] = [ + { type: 'text', text: msg.content }, + ]; + msg.content = content; + } + }); + }); +}; diff --git a/src/store/store.ts b/src/store/store.ts index 69aba7de0..153c0f0d7 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -15,6 +15,7 @@ import { LocalStorageInterfaceV5ToV6, LocalStorageInterfaceV6ToV7, LocalStorageInterfaceV7oV8, + LocalStorageInterfaceV8oV8_1, } from '@type/chat'; import { migrateV0, @@ -25,6 +26,7 @@ import { migrateV5, migrateV6, migrateV7, + migrateV8_1, } from './migrate'; export type StoreState = ChatSlice & @@ -43,6 +45,7 @@ export const createPartializedState = (state: StoreState) => ({ chats: state.chats, currentChatIndex: state.currentChatIndex, apiKey: state.apiKey, + apiVersion: state.apiVersion, apiEndpoint: state.apiEndpoint, theme: state.theme, autoTitle: state.autoTitle, @@ -74,7 +77,7 @@ const useStore = create()( { name: 'free-chat-gpt', partialize: (state) => createPartializedState(state), - version: 8, + version: 8.1, migrate: (persistedState, version) => { switch (version) { case 0: @@ -93,6 +96,8 @@ const useStore = create()( migrateV6(persistedState as LocalStorageInterfaceV6ToV7); case 7: migrateV7(persistedState as LocalStorageInterfaceV7oV8); + case 8: + migrateV8_1(persistedState as LocalStorageInterfaceV8oV8_1); break; } return persistedState as StoreState; diff --git a/src/types/chat.ts b/src/types/chat.ts index 5b706952a..fbc73f6b1 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -1,12 +1,35 @@ import { Prompt } from './prompt'; import { Theme } from './theme'; +// The types in this file must mimick the structure of the the API request + +export type Content = 'text' | 'image_url'; +export type ImageDetail = 'low' | 'high' | 'auto'; +export const imageDetails: ImageDetail[] = ['low', 'high', 'auto']; export type Role = 'user' | 'assistant' | 'system'; export const roles: Role[] = ['user', 'assistant', 'system']; +export interface ImageContentInterface extends ContentInterface { + type: 'image_url'; + image_url: { + url: string; // base64 or image URL + detail: ImageDetail; + }; +} + +export interface TextContentInterface extends ContentInterface { + type: 'text'; + text: string; +} + +export interface ContentInterface { + [x: string]: any; + type: Content; +} + export interface MessageInterface { role: Role; - content: string; + content: ContentInterface[]; } export interface ChatInterface { @@ -61,11 +84,27 @@ export type ModelOptions = | 'gpt-3.5-turbo' | 'gpt-3.5-turbo-16k' | 'gpt-3.5-turbo-1106' - | 'gpt-3.5-turbo-0125'; + | 'gpt-3.5-turbo-0125' + | 'gpt-4-vision-preview'; // | 'gpt-3.5-turbo-0301'; // | 'gpt-4-0314' // | 'gpt-4-32k-0314' +export type ModelType = 'text' | 'image'; +interface Pricing { + price: number; + unit: number; +} + +interface CostDetails { + prompt: Pricing; + completion: Pricing; +} + +export interface ModelCost { + [modelName: string]: CostDetails; +} + export type TotalTokenUsed = { [model in ModelOptions]?: { promptTokens: number; @@ -159,3 +198,10 @@ export interface LocalStorageInterfaceV7oV8 foldersExpanded: boolean[]; folders: FolderCollection; } +export interface LocalStorageInterfaceV8oV8_1 + extends LocalStorageInterfaceV7oV8 { + apiVersion: string; +} + +// export interface LocalStorageInterfaceV8_1ToV9 +// extends LocalStorageInterfaceV8oV8_1 { diff --git a/src/types/export.ts b/src/types/export.ts index f275ee7a6..5a42a03ee 100644 --- a/src/types/export.ts +++ b/src/types/export.ts @@ -1,4 +1,9 @@ -import { ChatInterface, FolderCollection, Role } from './chat'; +import { + ChatInterface, + ContentInterface, + FolderCollection, + Role, +} from './chat'; export interface ExportBase { version: number; @@ -8,7 +13,6 @@ export interface ExportV1 extends ExportBase { chats?: ChatInterface[]; folders: FolderCollection; } - export type OpenAIChat = { title: string; mapping: { @@ -18,9 +22,11 @@ export type OpenAIChat = { author: { role: Role; }; - content: { - parts?: string[]; - }; + content: + | { + parts?: string[]; + } + | ContentInterface; } | null; parent: string | null; children: string[]; diff --git a/src/utils/import.ts b/src/utils/import.ts index 4224a3d38..d24f52ce8 100644 --- a/src/utils/import.ts +++ b/src/utils/import.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ChatInterface, ConfigInterface, + ContentInterface, FolderCollection, MessageInterface, } from '@type/chat'; @@ -88,6 +89,10 @@ export const validateFolders = ( export const validateExportV1 = (data: ExportV1): data is ExportV1 => { return validateAndFixChats(data.chats) && validateFolders(data.folders); }; +// Type guard to check if content is ContentInterface +const isContentInterface = (content: any): content is ContentInterface => { + return typeof content === 'object' && 'type' in content; +}; // Convert OpenAI chat format to BetterChatGPT format export const convertOpenAIToBetterChatGPTFormat = ( @@ -102,8 +107,21 @@ export const convertOpenAIToBetterChatGPTFormat = ( // Extract message if it exists if (node.message) { const { role } = node.message.author; - const content = node.message.content.parts?.join('') || ''; - if (content.length > 0) messages.push({ role, content }); + const content = node.message.content; + if (Array.isArray(content.parts)) { + const textContent = content.parts.join('') || ''; + if (textContent.length > 0) { + messages.push({ + role, + content: [{ type: 'text', text: textContent }], + }); + } + } else if (isContentInterface(content)) { + messages.push({ role, content: [content] }); + } + // TODO: Remove this after stable build + // const content = node.message.content.parts?.join('') || ''; + // if (content.length > 0) messages.push({ role, content }); } // Traverse the last child node if any children exist diff --git a/src/utils/messageUtils.ts b/src/utils/messageUtils.ts index e4aacde0c..25e3dc000 100644 --- a/src/utils/messageUtils.ts +++ b/src/utils/messageUtils.ts @@ -1,4 +1,4 @@ -import { MessageInterface, ModelOptions, TotalTokenUsed } from '@type/chat'; +import { MessageInterface, ModelOptions, TextContentInterface, TotalTokenUsed } from '@type/chat'; import useStore from '@store/store'; @@ -29,7 +29,7 @@ export const getChatGPTEncoding = ( const serialized = [ messages .map(({ role, content }) => { - return `<|im_start|>${role}${roleSep}${content}<|im_end|>`; + return `<|im_start|>${role}${roleSep}${(content[0] as TextContentInterface).text}<|im_end|>`; }) .join(msgSep), `<|im_start|>assistant${roleSep}`, diff --git a/yarn.lock b/yarn.lock index 717c9659e..adc08d197 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1205,6 +1205,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clsx@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1336,6 +1341,13 @@ crc@^3.8.0: dependencies: buffer "^5.1.0" +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + cross-fetch@3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" @@ -3305,6 +3317,13 @@ react-scroll-to-bottom@^4.2.0: prop-types "15.7.2" simple-update-in "2.2.0" +react-toastify@^10.0.5: + version "10.0.5" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-10.0.5.tgz#6b8f8386060c5c856239f3036d1e76874ce3bd1e" + integrity sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw== + dependencies: + clsx "^2.1.0" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"