mirror of
https://github.com/NovaOSS/nova-betterchat.git
synced 2024-11-25 21:43:59 +01:00
First Commit
This commit is contained in:
parent
b2ef37fb4f
commit
05cfd7f34f
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>ChatGPT Free</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "chatgpt",
|
"name": "chatgpt-free-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
@ -9,11 +9,16 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.0.1",
|
||||||
"highlight.js": "^11.7.0",
|
"highlight.js": "^11.7.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^8.0.5",
|
||||||
|
"react-scroll-to-bottom": "^4.2.0",
|
||||||
|
"zustand": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||||
|
|
49
src/App.tsx
49
src/App.tsx
|
@ -1,5 +1,52 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import Chat from './components/Chat';
|
||||||
|
import Menu from './components/Menu';
|
||||||
|
import ConfigMenu from './components/ConfigMenu';
|
||||||
|
|
||||||
|
import useSaveToLocalStorage from '@hooks/useSaveToLocalStorage';
|
||||||
|
import useUpdateCharts from '@hooks/useUpdateChats';
|
||||||
|
|
||||||
|
import { ChatInterface } from '@type/chat';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <div>App</div>;
|
useSaveToLocalStorage();
|
||||||
|
useUpdateCharts();
|
||||||
|
|
||||||
|
const [setChats, setMessages, setCurrentChatIndex] = useStore((state) => [
|
||||||
|
state.setChats,
|
||||||
|
state.setMessages,
|
||||||
|
state.setCurrentChatIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// localStorage.removeItem('chats');
|
||||||
|
const storedChats = localStorage.getItem('chats');
|
||||||
|
if (storedChats) {
|
||||||
|
try {
|
||||||
|
const chats: ChatInterface[] = JSON.parse(storedChats);
|
||||||
|
setChats(chats);
|
||||||
|
setMessages(chats[0].messages);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setChats([]);
|
||||||
|
setMessages([]);
|
||||||
|
setCurrentChatIndex(-1);
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setChats([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='overflow-hidden w-full h-full relative'>
|
||||||
|
<Menu />
|
||||||
|
<Chat />
|
||||||
|
<ConfigMenu />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
72
src/api/customApi.ts
Normal file
72
src/api/customApi.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
export const endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
|
export const validateApiKey = async (apiKey: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
if (response.status === 401) return false;
|
||||||
|
else if (response.status === 400) return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChatCompletion = async (
|
||||||
|
apiKey: string,
|
||||||
|
messages: MessageInterface[]
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChatCompletionStream = async (
|
||||||
|
apiKey: string,
|
||||||
|
messages: MessageInterface[]
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
const stream = response.body;
|
||||||
|
return stream;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
44
src/api/freeApi.ts
Normal file
44
src/api/freeApi.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
export const endpoint = 'https://chatgpt-api.shn.hk/v1/';
|
||||||
|
|
||||||
|
export const getChatCompletion = async (messages: MessageInterface[]) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChatCompletionStream = async (messages: MessageInterface[]) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
const stream = response.body;
|
||||||
|
return stream;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
19
src/api/helper.ts
Normal file
19
src/api/helper.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { EventSourceData } from '@type/api';
|
||||||
|
|
||||||
|
export const parseEventSource = (
|
||||||
|
data: string
|
||||||
|
): '[DONE]' | EventSourceData[] => {
|
||||||
|
const result = data
|
||||||
|
.split('\n\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((chunk) => {
|
||||||
|
const jsonString = chunk
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^data: /, ''))
|
||||||
|
.join('');
|
||||||
|
console.log(jsonString);
|
||||||
|
if (jsonString === '[DONE]') return jsonString;
|
||||||
|
else return JSON.parse(jsonString);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
22
src/assets/icons/ChatIcon.tsx
Normal file
22
src/assets/icons/ChatIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ChatIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatIcon;
|
23
src/assets/icons/CopyIcon.tsx
Normal file
23
src/assets/icons/CopyIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CopyIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'></path>
|
||||||
|
<rect x='8' y='2' width='8' height='4' rx='1' ry='1'></rect>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyIcon;
|
23
src/assets/icons/CrossIcon.tsx
Normal file
23
src/assets/icons/CrossIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CrossIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<line x1='18' y1='6' x2='6' y2='18'></line>
|
||||||
|
<line x1='6' y1='6' x2='18' y2='18'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrossIcon;
|
21
src/assets/icons/CrossIcon2.tsx
Normal file
21
src/assets/icons/CrossIcon2.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CrossIcon2 = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden='true'
|
||||||
|
className='w-5 h-5'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrossIcon2;
|
25
src/assets/icons/DeleteIcon.tsx
Normal file
25
src/assets/icons/DeleteIcon.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DeleteIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<polyline points='3 6 5 6 21 6'></polyline>
|
||||||
|
<path d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'></path>
|
||||||
|
<line x1='10' y1='11' x2='10' y2='17'></line>
|
||||||
|
<line x1='14' y1='11' x2='14' y2='17'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteIcon;
|
23
src/assets/icons/DownArrow.tsx
Normal file
23
src/assets/icons/DownArrow.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DownArrow = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4 m-1'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<line x1='12' y1='5' x2='12' y2='19'></line>
|
||||||
|
<polyline points='19 12 12 19 5 12'></polyline>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownArrow;
|
23
src/assets/icons/DownChevronArrow.tsx
Normal file
23
src/assets/icons/DownChevronArrow.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DownChevronArrow = ({ className }: { className?: string }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={'w-4 h-4' + ' ' + className}
|
||||||
|
aria-hidden='true'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth='2'
|
||||||
|
d='M19 9l-7 7-7-7'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownChevronArrow;
|
23
src/assets/icons/EditIcon.tsx
Normal file
23
src/assets/icons/EditIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const EditIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M12 20h9'></path>
|
||||||
|
<path d='M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z'></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditIcon;
|
23
src/assets/icons/EditIcon2.tsx
Normal file
23
src/assets/icons/EditIcon2.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const EditIcon2 = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'></path>
|
||||||
|
<path d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditIcon2;
|
16
src/assets/icons/HeartIcon.tsx
Normal file
16
src/assets/icons/HeartIcon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const HeartIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
className='h-4 w-4 p-[1px]'
|
||||||
|
fill='white'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
>
|
||||||
|
<path d='M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z' />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeartIcon;
|
24
src/assets/icons/LinkIcon.tsx
Normal file
24
src/assets/icons/LinkIcon.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const LinkIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'></path>
|
||||||
|
<polyline points='15 3 21 3 21 9'></polyline>
|
||||||
|
<line x1='10' y1='14' x2='21' y2='3'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkIcon;
|
24
src/assets/icons/LogoutIcon.tsx
Normal file
24
src/assets/icons/LogoutIcon.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const LogoutIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'></path>
|
||||||
|
<polyline points='16 17 21 12 16 7'></polyline>
|
||||||
|
<line x1='21' y1='12' x2='9' y2='12'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogoutIcon;
|
22
src/assets/icons/MoonIcon.tsx
Normal file
22
src/assets/icons/MoonIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const MoonIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z'></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoonIcon;
|
23
src/assets/icons/PersonIcon.tsx
Normal file
23
src/assets/icons/PersonIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PersonIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'></path>
|
||||||
|
<circle cx='12' cy='7' r='4'></circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonIcon;
|
23
src/assets/icons/PlusIcon.tsx
Normal file
23
src/assets/icons/PlusIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PlusIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<line x1='12' y1='5' x2='12' y2='19'></line>
|
||||||
|
<line x1='5' y1='12' x2='19' y2='12'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlusIcon;
|
24
src/assets/icons/RefreshIcon.tsx
Normal file
24
src/assets/icons/RefreshIcon.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const RefreshIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='1.5'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-3 w-3'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<polyline points='1 4 1 10 7 10'></polyline>
|
||||||
|
<polyline points='23 20 23 14 17 14'></polyline>
|
||||||
|
<path d='M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15'></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RefreshIcon;
|
23
src/assets/icons/SendIcon.tsx
Normal file
23
src/assets/icons/SendIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SendIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4 mr-1'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<line x1='22' y1='2' x2='11' y2='13'></line>
|
||||||
|
<polygon points='22 2 15 22 11 13 2 9 22 2'></polygon>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SendIcon;
|
11
src/assets/icons/SettingIcon.tsx
Normal file
11
src/assets/icons/SettingIcon.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SettingIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 512 512'>
|
||||||
|
<path d='M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z' />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingIcon;
|
30
src/assets/icons/SunIcon.tsx
Normal file
30
src/assets/icons/SunIcon.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SunIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<circle cx='12' cy='12' r='5'></circle>
|
||||||
|
<line x1='12' y1='1' x2='12' y2='3'></line>
|
||||||
|
<line x1='12' y1='21' x2='12' y2='23'></line>
|
||||||
|
<line x1='4.22' y1='4.22' x2='5.64' y2='5.64'></line>
|
||||||
|
<line x1='18.36' y1='18.36' x2='19.78' y2='19.78'></line>
|
||||||
|
<line x1='1' y1='12' x2='3' y2='12'></line>
|
||||||
|
<line x1='21' y1='12' x2='23' y2='12'></line>
|
||||||
|
<line x1='4.22' y1='19.78' x2='5.64' y2='18.36'></line>
|
||||||
|
<line x1='18.36' y1='5.64' x2='19.78' y2='4.22'></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SunIcon;
|
22
src/assets/icons/TickIcon.tsx
Normal file
22
src/assets/icons/TickIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TickIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-4 w-4'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<polyline points='20 6 9 17 4 12'></polyline>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TickIcon;
|
19
src/components/Chat/Chat.tsx
Normal file
19
src/components/Chat/Chat.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import ChatContent from './ChatContent';
|
||||||
|
import ChatInput from './ChatInput';
|
||||||
|
import MobileBar from '../MobileBar';
|
||||||
|
|
||||||
|
const Chat = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-1 flex-col md:pl-[260px]'>
|
||||||
|
<MobileBar />
|
||||||
|
<main className='relative h-full w-full transition-width flex flex-col overflow-hidden items-stretch flex-1'>
|
||||||
|
<ChatContent />
|
||||||
|
{/* <ChatInput /> */}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Chat;
|
116
src/components/Chat/ChatContent/ChatContent.tsx
Normal file
116
src/components/Chat/ChatContent/ChatContent.tsx
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ScrollToBottom from 'react-scroll-to-bottom';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import ScrollToBottomButton from './ScrollToBottomButton';
|
||||||
|
import ChatTitle from './ChatTitle';
|
||||||
|
import Message from './Message';
|
||||||
|
import NewMessageButton from './Message/NewMessageButton';
|
||||||
|
|
||||||
|
import { getChatCompletionStream as getChatCompletionStreamFree } from '@api/freeApi';
|
||||||
|
import { getChatCompletionStream as getChatCompletionStreamCustom } from '@api/customApi';
|
||||||
|
import { parseEventSource } from '@api/helper';
|
||||||
|
|
||||||
|
import RefreshIcon from '@icon/RefreshIcon';
|
||||||
|
import { MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
const ChatContent = () => {
|
||||||
|
const [messages, inputRole, apiFree, apiKey, setMessages] = useStore(
|
||||||
|
(state) => [
|
||||||
|
state.messages,
|
||||||
|
state.inputRole,
|
||||||
|
state.apiFree,
|
||||||
|
state.apiKey,
|
||||||
|
state.setMessages,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(messages)
|
||||||
|
);
|
||||||
|
updatedMessages.push({ role: 'assistant', content: '' });
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
let stream;
|
||||||
|
|
||||||
|
if (apiFree) {
|
||||||
|
stream = await getChatCompletionStreamFree(messages);
|
||||||
|
} else if (apiKey) {
|
||||||
|
stream = await getChatCompletionStreamCustom(apiKey, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
let reading = true;
|
||||||
|
while (reading) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
const result = parseEventSource(new TextDecoder().decode(value));
|
||||||
|
|
||||||
|
if (result === '[DONE]' || done) {
|
||||||
|
reading = false;
|
||||||
|
} else {
|
||||||
|
const resultString = result.reduce((output: string, curr) => {
|
||||||
|
if (curr === '[DONE]') return output;
|
||||||
|
else {
|
||||||
|
const content = curr.choices[0].delta.content;
|
||||||
|
if (content) output += content;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}, '');
|
||||||
|
console.log(resultString);
|
||||||
|
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(useStore.getState().messages)
|
||||||
|
);
|
||||||
|
updatedMessages[updatedMessages.length - 1].content += resultString;
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex-1 overflow-hidden'>
|
||||||
|
<ScrollToBottom
|
||||||
|
className='h-full dark:bg-gray-800'
|
||||||
|
followButtonClassName='hidden'
|
||||||
|
>
|
||||||
|
<ScrollToBottomButton />
|
||||||
|
<div className='flex flex-col items-center text-sm dark:bg-gray-800'>
|
||||||
|
<ChatTitle />
|
||||||
|
{messages?.length === 0 && <NewMessageButton messageIndex={-1} />}
|
||||||
|
{messages?.map((message, index) => (
|
||||||
|
<>
|
||||||
|
<Message
|
||||||
|
role={message.role}
|
||||||
|
content={message.content}
|
||||||
|
messageIndex={index}
|
||||||
|
/>
|
||||||
|
<NewMessageButton messageIndex={index} />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
<Message role={inputRole} content='' messageIndex={-1} sticky />
|
||||||
|
|
||||||
|
<div className='text-center mt-6 flex justify-center gap-2'>
|
||||||
|
<button
|
||||||
|
className='btn relative btn-primary mt-2'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button className='btn btn-neutral border-0 md:border mt-2'>
|
||||||
|
<div className='flex items-center justify-center gap-2'>
|
||||||
|
<RefreshIcon />
|
||||||
|
Regenerate response
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='w-full h-32 md:h-48 flex-shrink-0'></div>
|
||||||
|
</div>
|
||||||
|
</ScrollToBottom>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatContent;
|
11
src/components/Chat/ChatContent/ChatTitle.tsx
Normal file
11
src/components/Chat/ChatContent/ChatTitle.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ChatTitle = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex w-full items-center justify-center gap-1 border-b border-black/10 bg-gray-50 p-3 text-gray-500 dark:border-gray-900/50 dark:bg-gray-700 dark:text-gray-300'>
|
||||||
|
Model: Default
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatTitle;
|
62
src/components/Chat/ChatContent/Message/Avatar.tsx
Normal file
62
src/components/Chat/ChatContent/Message/Avatar.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Role } from '@type/chat';
|
||||||
|
import SettingIcon from '@icon/SettingIcon';
|
||||||
|
import PersonIcon from '@icon/PersonIcon';
|
||||||
|
|
||||||
|
const Avatar = ({ role }: { role: Role }) => {
|
||||||
|
return (
|
||||||
|
<div className='w-[30px] flex flex-col relative items-end'>
|
||||||
|
{role === 'user' && <UserAvatar />}
|
||||||
|
{role === 'assistant' && <AssistantAvatar />}
|
||||||
|
{role === 'system' && <SystemAvatar />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserAvatar = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='relative h-[30px] w-[30px] p-1 rounded-sm text-white flex items-center justify-center'
|
||||||
|
style={{ backgroundColor: 'rgb(200, 70, 70)' }}
|
||||||
|
>
|
||||||
|
<PersonIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantAvatar = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='relative h-[30px] w-[30px] p-1 rounded-sm text-white flex items-center justify-center'
|
||||||
|
style={{ backgroundColor: 'rgb(16, 163, 127)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width='41'
|
||||||
|
height='41'
|
||||||
|
viewBox='0 0 41 41'
|
||||||
|
fill='none'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
strokeWidth='1.5'
|
||||||
|
className='h-6 w-6'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z'
|
||||||
|
fill='currentColor'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SystemAvatar = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='relative h-[30px] w-[30px] p-1 rounded-sm text-white flex items-center justify-center'
|
||||||
|
style={{ backgroundColor: 'rgb(126, 163, 227)' }}
|
||||||
|
>
|
||||||
|
<SettingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
50
src/components/Chat/ChatContent/Message/Message.tsx
Normal file
50
src/components/Chat/ChatContent/Message/Message.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import MessageContent from './MessageContent';
|
||||||
|
|
||||||
|
import { Role } from '@type/chat';
|
||||||
|
import RoleSelector from './RoleSelector';
|
||||||
|
|
||||||
|
const backgroundStyle: { [role in Role]: string } = {
|
||||||
|
user: 'dark:bg-gray-800',
|
||||||
|
assistant: 'bg-gray-50 dark:bg-[#444654]',
|
||||||
|
system: 'bg-gray-50 dark:bg-[#444654]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Message = ({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
messageIndex,
|
||||||
|
sticky = false,
|
||||||
|
}: {
|
||||||
|
role: Role;
|
||||||
|
content: string;
|
||||||
|
messageIndex: number;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group ${backgroundStyle[role]}`}
|
||||||
|
key={Math.random()}
|
||||||
|
>
|
||||||
|
<div className='text-base gap-4 md:gap-6 m-auto md:max-w-2xl lg:max-w-2xl xl:max-w-3xl p-4 md:py-6 flex lg:px-0'>
|
||||||
|
<Avatar role={role} />
|
||||||
|
<div className='w-[calc(100%-50px)] '>
|
||||||
|
<RoleSelector
|
||||||
|
role={role}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
sticky={sticky}
|
||||||
|
/>
|
||||||
|
<MessageContent
|
||||||
|
content={content}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
sticky={sticky}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Message;
|
289
src/components/Chat/ChatContent/Message/MessageContent.tsx
Normal file
289
src/components/Chat/ChatContent/Message/MessageContent.tsx
Normal file
|
@ -0,0 +1,289 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import CopyIcon from '@icon/CopyIcon';
|
||||||
|
import EditIcon2 from '@icon/EditIcon2';
|
||||||
|
import DeleteIcon from '@icon/DeleteIcon';
|
||||||
|
import TickIcon from '@icon/TickIcon';
|
||||||
|
import CrossIcon from '@icon/CrossIcon';
|
||||||
|
import DownChevronArrow from '@icon/DownChevronArrow';
|
||||||
|
|
||||||
|
import { MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
const MessageContent = ({
|
||||||
|
content,
|
||||||
|
messageIndex,
|
||||||
|
sticky = false,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
messageIndex: number;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [isEdit, setIsEdit] = useState<boolean>(sticky);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative flex flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]'>
|
||||||
|
<div className='flex flex-grow flex-col gap-3'></div>
|
||||||
|
{isEdit ? (
|
||||||
|
<EditView
|
||||||
|
content={content}
|
||||||
|
setIsEdit={setIsEdit}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
sticky={sticky}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ContentView
|
||||||
|
content={content}
|
||||||
|
setIsEdit={setIsEdit}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentView = ({
|
||||||
|
content,
|
||||||
|
setIsEdit,
|
||||||
|
messageIndex,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
messageIndex: number;
|
||||||
|
}) => {
|
||||||
|
const [isDelete, setIsDelete] = useState<boolean>(false);
|
||||||
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useStore((state) => [
|
||||||
|
state.messages,
|
||||||
|
state.setMessages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
const updatedMessages = JSON.parse(JSON.stringify(messages));
|
||||||
|
updatedMessages.splice(messageIndex, 1);
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (direction: 'up' | 'down') => {
|
||||||
|
const updatedMessages = JSON.parse(JSON.stringify(messages));
|
||||||
|
const temp = updatedMessages[messageIndex];
|
||||||
|
if (direction === 'up') {
|
||||||
|
updatedMessages[messageIndex] = updatedMessages[messageIndex - 1];
|
||||||
|
updatedMessages[messageIndex - 1] = temp;
|
||||||
|
} else {
|
||||||
|
updatedMessages[messageIndex] = updatedMessages[messageIndex + 1];
|
||||||
|
updatedMessages[messageIndex + 1] = temp;
|
||||||
|
}
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='markdown prose w-full break-words dark:prose-invert dark'>
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
code({ node, inline, className, children, ...props }) {
|
||||||
|
const highlight = hljs.highlightAuto(children.toString());
|
||||||
|
return (
|
||||||
|
<div className='bg-black rounded-md'>
|
||||||
|
<div className='flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans'>
|
||||||
|
<span className=''>{highlight.language}</span>
|
||||||
|
<button
|
||||||
|
className='flex ml-auto gap-2'
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(children.toString())
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 3000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<TickIcon />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CopyIcon />
|
||||||
|
Copy code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='p-4 overflow-y-auto'>
|
||||||
|
<code
|
||||||
|
className={`!whitespace-pre hljs language-${highlight.language}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(highlight.value, {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end gap-2 w-full mt-2'>
|
||||||
|
{isDelete || (
|
||||||
|
<>
|
||||||
|
{messageIndex !== 0 && (
|
||||||
|
<UpButton onClick={() => handleMove('up')} />
|
||||||
|
)}
|
||||||
|
{messageIndex !== messages?.length - 1 && (
|
||||||
|
<DownButton onClick={() => handleMove('down')} />
|
||||||
|
)}
|
||||||
|
<EditButton setIsEdit={setIsEdit} />
|
||||||
|
<DeleteButton setIsDelete={setIsDelete} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDelete && (
|
||||||
|
<>
|
||||||
|
<button className='p-1 hover:text-white' onClick={handleDelete}>
|
||||||
|
<TickIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='p-1 hover:text-white'
|
||||||
|
onClick={() => setIsDelete(false)}
|
||||||
|
>
|
||||||
|
<CrossIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MessageButton = ({
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
icon: React.ReactElement;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='text-gray-400 flex self-end lg:self-center justify-center gap-3 md:gap-4 visible'>
|
||||||
|
<button
|
||||||
|
className='p-1 rounded-md hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible'
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditButton = ({
|
||||||
|
setIsEdit,
|
||||||
|
}: {
|
||||||
|
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
return <MessageButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteButton = ({
|
||||||
|
setIsDelete,
|
||||||
|
}: {
|
||||||
|
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<MessageButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DownButton = ({
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}) => {
|
||||||
|
return <MessageButton icon={<DownChevronArrow />} onClick={onClick} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UpButton = ({
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<MessageButton
|
||||||
|
icon={<DownChevronArrow className='rotate-180' />}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditView = ({
|
||||||
|
content,
|
||||||
|
setIsEdit,
|
||||||
|
messageIndex,
|
||||||
|
sticky,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
messageIndex: number;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages, inputRole] = useStore((state) => [
|
||||||
|
state.messages,
|
||||||
|
state.setMessages,
|
||||||
|
state.inputRole,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [_content, _setContent] = useState<string>(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className='m-0 resize-none border border-gray-400/30 rounded-lg bg-transparent p-1 focus:ring-0 focus-visible:ring-0'
|
||||||
|
onChange={(e) => {
|
||||||
|
_setContent(e.target.value);
|
||||||
|
}}
|
||||||
|
value={_content}
|
||||||
|
rows={10}
|
||||||
|
></textarea>
|
||||||
|
<div className='text-center mt-2 flex justify-center'>
|
||||||
|
<button
|
||||||
|
className='btn relative btn-primary mr-2'
|
||||||
|
onClick={() => {
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(messages)
|
||||||
|
);
|
||||||
|
if (sticky) {
|
||||||
|
updatedMessages.push({ role: inputRole, content: _content });
|
||||||
|
} else {
|
||||||
|
updatedMessages[messageIndex].content = _content;
|
||||||
|
}
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
setIsEdit(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-center gap-2'>Save</div>
|
||||||
|
</button>
|
||||||
|
{sticky || (
|
||||||
|
<button
|
||||||
|
className='btn relative btn-neutral'
|
||||||
|
onClick={() => setIsEdit(false)}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-center gap-2'>Cancel</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageContent;
|
66
src/components/Chat/ChatContent/Message/NewMessageButton.tsx
Normal file
66
src/components/Chat/ChatContent/Message/NewMessageButton.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import PlusIcon from '@icon/PlusIcon';
|
||||||
|
|
||||||
|
import { ChatInterface, MessageInterface } from '@type/chat';
|
||||||
|
import { defaultSystemMessage } from '@constants/chat';
|
||||||
|
|
||||||
|
const NewMessageButton = ({ messageIndex }: { messageIndex: number }) => {
|
||||||
|
const [
|
||||||
|
messages,
|
||||||
|
chats,
|
||||||
|
setMessages,
|
||||||
|
currentChatIndex,
|
||||||
|
setChats,
|
||||||
|
setCurrentChatIndex,
|
||||||
|
] = useStore((state) => [
|
||||||
|
state.messages,
|
||||||
|
state.chats,
|
||||||
|
state.setMessages,
|
||||||
|
state.currentChatIndex,
|
||||||
|
state.setChats,
|
||||||
|
state.setCurrentChatIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const addChat = () => {
|
||||||
|
if (chats) {
|
||||||
|
const updatedChats: ChatInterface[] = JSON.parse(JSON.stringify(chats));
|
||||||
|
updatedChats.unshift({
|
||||||
|
title: `Chat ${Math.random()}`,
|
||||||
|
messages: [{ role: 'system', content: defaultSystemMessage }],
|
||||||
|
});
|
||||||
|
setChats(updatedChats);
|
||||||
|
setMessages(updatedChats[0].messages);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMessage = () => {
|
||||||
|
if (currentChatIndex === -1) {
|
||||||
|
addChat();
|
||||||
|
} else {
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(messages)
|
||||||
|
);
|
||||||
|
updatedMessages.splice(messageIndex + 1, 0, {
|
||||||
|
content: '',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='h-0 relative' key={messageIndex}>
|
||||||
|
<div
|
||||||
|
className='absolute top-0 right-0 translate-x-1/2 translate-y-[-50%] text-gray-600 dark:text-white cursor-pointer bg-gray-200 dark:bg-gray-600/80 rounded-full p-1 text-sm hover:bg-gray-300 dark:hover:bg-gray-800/80 transition-bg duration-200'
|
||||||
|
onClick={addMessage}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewMessageButton;
|
70
src/components/Chat/ChatContent/Message/RoleSelector.tsx
Normal file
70
src/components/Chat/ChatContent/Message/RoleSelector.tsx
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import DownChevronArrow from '@icon/DownChevronArrow';
|
||||||
|
import { MessageInterface, Role, roles } from '@type/chat';
|
||||||
|
|
||||||
|
const RoleSelector = ({
|
||||||
|
role,
|
||||||
|
messageIndex,
|
||||||
|
sticky,
|
||||||
|
}: {
|
||||||
|
role: Role;
|
||||||
|
messageIndex: number;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages, setInputRole] = useStore((state) => [
|
||||||
|
state.messages,
|
||||||
|
state.setMessages,
|
||||||
|
state.setInputRole,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [dropDown, setDropDown] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='prose dark:prose-invert relative'>
|
||||||
|
<button
|
||||||
|
className='btn btn-neutral btn-small flex gap-1'
|
||||||
|
type='button'
|
||||||
|
onClick={() => setDropDown((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||||
|
<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`}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
className='text-sm text-gray-700 dark:text-gray-200 p-0 m-0'
|
||||||
|
aria-labelledby='dropdownDefaultButton'
|
||||||
|
>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<li
|
||||||
|
className='px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white cursor-pointer'
|
||||||
|
onClick={() => {
|
||||||
|
if (!sticky) {
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(messages)
|
||||||
|
);
|
||||||
|
updatedMessages[messageIndex].role = r;
|
||||||
|
setMessages(updatedMessages);
|
||||||
|
} else {
|
||||||
|
setInputRole(r);
|
||||||
|
}
|
||||||
|
setDropDown(false);
|
||||||
|
}}
|
||||||
|
key={r}
|
||||||
|
>
|
||||||
|
{r.charAt(0).toUpperCase() + r.slice(1)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSelector;
|
1
src/components/Chat/ChatContent/Message/index.ts
Normal file
1
src/components/Chat/ChatContent/Message/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './Message';
|
18
src/components/Chat/ChatContent/ScrollToBottomButton.tsx
Normal file
18
src/components/Chat/ChatContent/ScrollToBottomButton.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useScrollToBottom } from 'react-scroll-to-bottom';
|
||||||
|
|
||||||
|
import DownArrow from '@icon/DownArrow';
|
||||||
|
|
||||||
|
const ScrollToBottomButton = () => {
|
||||||
|
const scrollToBottom = useScrollToBottom();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className='cursor-pointer absolute right-6 bottom-[60px] md:bottom-[60px] z-10 rounded-full border border-gray-200 bg-gray-50 text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
>
|
||||||
|
<DownArrow />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToBottomButton;
|
1
src/components/Chat/ChatContent/index.ts
Normal file
1
src/components/Chat/ChatContent/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './ChatContent';
|
34
src/components/Chat/ChatInput.tsx
Normal file
34
src/components/Chat/ChatInput.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React from 'react';
|
||||||
|
import RefreshIcon from '@icon/RefreshIcon';
|
||||||
|
import SendIcon from '@icon/SendIcon';
|
||||||
|
|
||||||
|
const ChatInput = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full border-t md:border-t-0 dark:border-white/20 md:border-transparent md:dark:border-transparent md:bg-vert-light-gradient bg-white dark:bg-gray-800 md:!bg-transparent dark:md:bg-vert-dark-gradient'>
|
||||||
|
<form className='stretch mx-2 flex flex-row gap-3 pt-2 last:mb-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6'>
|
||||||
|
<div className='relative flex h-full flex-1 md:flex-col'>
|
||||||
|
<TextField />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextField = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col w-full py-2 flex-grow md:py-3 md:pl-4 relative border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]'>
|
||||||
|
<textarea
|
||||||
|
tabIndex={0}
|
||||||
|
data-id='2557e994-6f98-4656-a955-7808084f8b8c'
|
||||||
|
rows={1}
|
||||||
|
className='m-0 w-full resize-none border-0 bg-transparent p-0 pl-2 pr-7 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:pl-0'
|
||||||
|
style={{ maxHeight: '200px', height: '24px', overflowY: 'hidden' }}
|
||||||
|
></textarea>
|
||||||
|
<button className='absolute p-1 rounded-md text-gray-500 bottom-1.5 right-1 md:bottom-2.5 md:right-2 hover:bg-gray-100 dark:hover:text-gray-400 dark:hover:bg-gray-900 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent'>
|
||||||
|
<SendIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatInput;
|
1
src/components/Chat/index.ts
Normal file
1
src/components/Chat/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './Chat';
|
141
src/components/ConfigMenu/ConfigMenu.tsx
Normal file
141
src/components/ConfigMenu/ConfigMenu.tsx
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import CrossIcon2 from '@icon/CrossIcon2';
|
||||||
|
import { validateApiKey } from '@api/customApi';
|
||||||
|
|
||||||
|
const ConfigMenu = () => {
|
||||||
|
const [apiKey, setApiKey, apiFree, setApiFree, openConfig, setOpenConfig] =
|
||||||
|
useStore((state) => [
|
||||||
|
state.apiKey,
|
||||||
|
state.setApiKey,
|
||||||
|
state.apiFree,
|
||||||
|
state.setApiFree,
|
||||||
|
state.openConfig,
|
||||||
|
state.setOpenConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [_apiKey, _setApiKey] = useState<string>(apiKey || '');
|
||||||
|
const [error, setError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (apiFree === true) {
|
||||||
|
setOpenConfig(false);
|
||||||
|
} else {
|
||||||
|
const valid = await validateApiKey(_apiKey);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
setApiKey(_apiKey);
|
||||||
|
localStorage.setItem('apiKey', _apiKey);
|
||||||
|
setError(false);
|
||||||
|
setOpenConfig(false);
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setError(false);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpenConfig(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return openConfig ? (
|
||||||
|
<div className='fixed top-0 left-0 z-[999] w-full p-4 overflow-x-hidden overflow-y-auto h-full flex justify-center items-center bg-gray-800/90'>
|
||||||
|
<div className='relative w-full h-full max-w-2xl md:h-auto'>
|
||||||
|
<div className='relative bg-white rounded-lg shadow dark:bg-gray-700'>
|
||||||
|
<div className='flex items-center justify-between p-4 border-b rounded-t dark:border-gray-600'>
|
||||||
|
<h3 className='ml-2 text-lg font-semibold text-gray-900 dark:text-white'>
|
||||||
|
Config
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white'
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<CrossIcon2 />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='p-6 border-b border-gray-200 dark:border-gray-600'>
|
||||||
|
<div className='flex items-center mb-2'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||||
|
Use free API from{' '}
|
||||||
|
<a
|
||||||
|
href='https://github.com/ayaka14732/ChatGPTAPIFree'
|
||||||
|
className='underline dark:hover:text-white hover:text-black'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
Ayaka
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
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'
|
||||||
|
onChange={() => setApiFree(false)}
|
||||||
|
/>
|
||||||
|
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||||
|
Use your own API key
|
||||||
|
</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'>
|
||||||
|
API Key
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='text-white p-3 text-sm border-none bg-gray-600 rounded-md p-0 m-0 w-full mr-0 h-8 focus:outline-none'
|
||||||
|
value={_apiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
_setApiKey(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className='bg-red-600/50 p-2 rounded-sm mt-3 text-gray-900 dark:text-gray-300 text-sm'>
|
||||||
|
Invalid API key!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-center p-6 gap-4'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-primary'
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-neutral'
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigMenu;
|
1
src/components/ConfigMenu/index.ts
Normal file
1
src/components/ConfigMenu/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './ConfigMenu';
|
164
src/components/Menu/ChatHistoryList.tsx
Normal file
164
src/components/Menu/ChatHistoryList.tsx
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
import React, { MouseEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import ChatIcon from '@icon/ChatIcon';
|
||||||
|
import EditIcon from '@icon/EditIcon';
|
||||||
|
import DeleteIcon from '@icon/DeleteIcon';
|
||||||
|
import TickIcon from '@icon/TickIcon';
|
||||||
|
import CrossIcon from '@icon/CrossIcon';
|
||||||
|
|
||||||
|
const ChatHistoryList = () => {
|
||||||
|
const [chats, setCurrentChatIndex, setMessages] = useStore((state) => [
|
||||||
|
state.chats,
|
||||||
|
state.setCurrentChatIndex,
|
||||||
|
state.setMessages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex-col flex-1 overflow-y-auto border-b border-white/20'>
|
||||||
|
<div className='flex flex-col gap-2 text-gray-100 text-sm'>
|
||||||
|
{chats &&
|
||||||
|
chats.map((chat, index) => (
|
||||||
|
<ChatHistory
|
||||||
|
title={chat.title}
|
||||||
|
key={`${chat.title}-${index}`}
|
||||||
|
chatIndex={index}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentChatIndex(index);
|
||||||
|
setMessages(chats[index].messages);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* <ShowMoreButton /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShowMoreButton = () => {
|
||||||
|
return (
|
||||||
|
<button className='btn relative btn-dark btn-small m-auto mb-2'>
|
||||||
|
<div className='flex items-center justify-center gap-2'>Show more</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatHistoryClass = {
|
||||||
|
normal:
|
||||||
|
'flex py-3 px-3 items-center gap-3 relative rounded-md hover:bg-[#2A2B32] cursor-pointer break-all hover:pr-4 group',
|
||||||
|
active:
|
||||||
|
'flex py-3 px-3 items-center gap-3 relative rounded-md cursor-pointer break-all pr-14 bg-gray-800 hover:bg-gray-800 group',
|
||||||
|
normalGradient:
|
||||||
|
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-900 group-hover:from-[#2A2B32]',
|
||||||
|
activeGradient:
|
||||||
|
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatHistory = ({
|
||||||
|
title,
|
||||||
|
chatIndex,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
chatIndex: number;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||||
|
}) => {
|
||||||
|
const [chats, setChats, currentChatIndex, setMessages, setCurrentChatIndex] =
|
||||||
|
useStore((state) => [
|
||||||
|
state.chats,
|
||||||
|
state.setChats,
|
||||||
|
state.currentChatIndex,
|
||||||
|
state.setMessages,
|
||||||
|
state.setCurrentChatIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [isDelete, setIsDelete] = useState<boolean>(false);
|
||||||
|
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||||
|
const [_title, _setTitle] = useState<string>(title);
|
||||||
|
|
||||||
|
const active = currentChatIndex === chatIndex;
|
||||||
|
|
||||||
|
const handleTick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const updatedChats = JSON.parse(JSON.stringify(chats));
|
||||||
|
if (isEdit) {
|
||||||
|
updatedChats[chatIndex].title = _title;
|
||||||
|
setIsEdit(false);
|
||||||
|
} else if (isDelete) {
|
||||||
|
updatedChats.splice(chatIndex, 1);
|
||||||
|
setCurrentChatIndex(-1);
|
||||||
|
setMessages([]);
|
||||||
|
setIsDelete(false);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setChats(updatedChats);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCross = () => {
|
||||||
|
setIsDelete(false);
|
||||||
|
setIsEdit(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={active ? ChatHistoryClass.active : ChatHistoryClass.normal}
|
||||||
|
onClick={(e) => onClick && onClick(e)}
|
||||||
|
>
|
||||||
|
<ChatIcon />
|
||||||
|
<div className='flex-1 text-ellipsis max-h-5 overflow-hidden break-all relative'>
|
||||||
|
{isEdit ? (
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='text-sm border-none bg-transparent p-0 m-0 w-full mr-0'
|
||||||
|
value={_title}
|
||||||
|
onChange={(e) => {
|
||||||
|
_setTitle(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
_title
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? ChatHistoryClass.activeGradient
|
||||||
|
: ChatHistoryClass.normalGradient
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<div className='absolute flex right-1 z-10 text-gray-300 visible'>
|
||||||
|
{isDelete || isEdit ? (
|
||||||
|
<>
|
||||||
|
<button className='p-1 hover:text-white' onClick={handleTick}>
|
||||||
|
<TickIcon />
|
||||||
|
</button>
|
||||||
|
<button className='p-1 hover:text-white' onClick={handleCross}>
|
||||||
|
<CrossIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className='p-1 hover:text-white'
|
||||||
|
onClick={() => setIsEdit(true)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='p-1 hover:text-white'
|
||||||
|
onClick={() => setIsDelete(true)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatHistoryList;
|
39
src/components/Menu/Menu.tsx
Normal file
39
src/components/Menu/Menu.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import NewChat from './NewChat';
|
||||||
|
import ChatHistoryList from './ChatHistoryList';
|
||||||
|
import MenuOptions from './MenuOptions';
|
||||||
|
import CrossIcon2 from '@icon/CrossIcon2';
|
||||||
|
|
||||||
|
const Menu = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id='menu'
|
||||||
|
className='dark bg-gray-900 md:fixed md:inset-y-0 md:flex md:w-[260px] md:flex-col max-md:translate-x-[-100%] max-md:fixed max-md:transition-transform max-md:z-[999] max-md:top-0 max-md:left-0 max-md:h-full'
|
||||||
|
>
|
||||||
|
<div className='flex h-full min-h-0 flex-col '>
|
||||||
|
<div className='scrollbar-trigger flex h-full w-full flex-1 items-start border-white/20'>
|
||||||
|
<nav className='flex h-full flex-1 flex-col space-y-1 p-2'>
|
||||||
|
<NewChat />
|
||||||
|
<ChatHistoryList />
|
||||||
|
<MenuOptions />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id='menu-close'
|
||||||
|
className='hidden md:hidden absolute z-[999] right-0 translate-x-full top-10 bg-gray-900 p-2 cursor-pointer hover:bg-black text-white'
|
||||||
|
onClick={() => {
|
||||||
|
document
|
||||||
|
.getElementById('menu')
|
||||||
|
?.classList.remove('max-md:translate-x-[0%]');
|
||||||
|
document.getElementById('menu-close')?.classList.add('hidden');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CrossIcon2 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Menu;
|
13
src/components/Menu/MenuOptions/Account.tsx
Normal file
13
src/components/Menu/MenuOptions/Account.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PersonIcon from '@icon/PersonIcon';
|
||||||
|
|
||||||
|
const Account = () => {
|
||||||
|
return (
|
||||||
|
<a className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'>
|
||||||
|
<PersonIcon />
|
||||||
|
My account
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
14
src/components/Menu/MenuOptions/ClearConversation.tsx
Normal file
14
src/components/Menu/MenuOptions/ClearConversation.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import DeleteIcon from '@icon/DeleteIcon';
|
||||||
|
|
||||||
|
const ClearConversation = () => {
|
||||||
|
return (
|
||||||
|
<a className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'>
|
||||||
|
<DeleteIcon />
|
||||||
|
Clear conversations
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClearConversation;
|
23
src/components/Menu/MenuOptions/Config.tsx
Normal file
23
src/components/Menu/MenuOptions/Config.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import PersonIcon from '@icon/PersonIcon';
|
||||||
|
|
||||||
|
const Config = () => {
|
||||||
|
const [apiFree, setOpenConfig] = useStore((state) => [
|
||||||
|
state.apiFree,
|
||||||
|
state.setOpenConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
|
||||||
|
onClick={() => setOpenConfig(true)}
|
||||||
|
>
|
||||||
|
<PersonIcon />
|
||||||
|
API: {apiFree ? 'Free' : 'Personal'}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Config;
|
13
src/components/Menu/MenuOptions/Logout.tsx
Normal file
13
src/components/Menu/MenuOptions/Logout.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import LogoutIcon from '@icon/LogoutIcon';
|
||||||
|
|
||||||
|
const Logout = () => {
|
||||||
|
return (
|
||||||
|
<a className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'>
|
||||||
|
<LogoutIcon />
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logout;
|
18
src/components/Menu/MenuOptions/Me.tsx
Normal file
18
src/components/Menu/MenuOptions/Me.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HeartIcon from '@icon/HeartIcon';
|
||||||
|
|
||||||
|
const Me = () => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
|
||||||
|
href='https://github.com/ztjhz'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<HeartIcon />
|
||||||
|
Made by Jing Hua
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Me;
|
25
src/components/Menu/MenuOptions/MenuOptions.tsx
Normal file
25
src/components/Menu/MenuOptions/MenuOptions.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Account from './Account';
|
||||||
|
import ClearConversation from './ClearConversation';
|
||||||
|
import Config from './Config';
|
||||||
|
import Logout from './Logout';
|
||||||
|
import Me from './Me';
|
||||||
|
import ThemeSwitcher from './ThemeSwitcher';
|
||||||
|
import Updates from './Updates';
|
||||||
|
|
||||||
|
const MenuOptions = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* <ClearConversation /> */}
|
||||||
|
<Config />
|
||||||
|
<ThemeSwitcher />
|
||||||
|
{/* <Account /> */}
|
||||||
|
<Updates />
|
||||||
|
<Me />
|
||||||
|
{/* <Logout /> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MenuOptions;
|
38
src/components/Menu/MenuOptions/ThemeSwitcher.tsx
Normal file
38
src/components/Menu/MenuOptions/ThemeSwitcher.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import SunIcon from '@icon/SunIcon';
|
||||||
|
import MoonIcon from '@icon/MoonIcon';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const getOppositeTheme = (theme: Theme): Theme => {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
return 'light';
|
||||||
|
} else {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ThemeSwitcher = () => {
|
||||||
|
const [theme, setTheme] = useState<Theme>('dark');
|
||||||
|
|
||||||
|
const switchTheme = () => {
|
||||||
|
setTheme(getOppositeTheme(theme));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.className = theme;
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
|
||||||
|
onClick={switchTheme}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
||||||
|
{getOppositeTheme(theme).charAt(0).toUpperCase() +
|
||||||
|
getOppositeTheme(theme).slice(1)}{' '}
|
||||||
|
mode
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeSwitcher;
|
17
src/components/Menu/MenuOptions/Updates.tsx
Normal file
17
src/components/Menu/MenuOptions/Updates.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
import LinkIcon from '@icon/LinkIcon';
|
||||||
|
|
||||||
|
const Updates = () => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href='https://github.com/ztjhz/chatgpt-free-app'
|
||||||
|
target='_blank'
|
||||||
|
className='flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm'
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Updates;
|
1
src/components/Menu/MenuOptions/index.ts
Normal file
1
src/components/Menu/MenuOptions/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './MenuOptions';
|
43
src/components/Menu/NewChat.tsx
Normal file
43
src/components/Menu/NewChat.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import PlusIcon from '@icon/PlusIcon';
|
||||||
|
|
||||||
|
import { ChatInterface } from '@type/chat';
|
||||||
|
import { defaultSystemMessage } from '@constants/chat';
|
||||||
|
|
||||||
|
const NewChat = () => {
|
||||||
|
const [chats, setChats, setCurrentChatIndex, setMessages] = useStore(
|
||||||
|
(state) => [
|
||||||
|
state.chats,
|
||||||
|
state.setChats,
|
||||||
|
state.setCurrentChatIndex,
|
||||||
|
state.setMessages,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addChat = () => {
|
||||||
|
if (chats) {
|
||||||
|
const updatedChats: ChatInterface[] = JSON.parse(JSON.stringify(chats));
|
||||||
|
updatedChats.unshift({
|
||||||
|
title: `Chat ${Math.random()}`,
|
||||||
|
messages: [{ role: 'system', content: defaultSystemMessage }],
|
||||||
|
});
|
||||||
|
setChats(updatedChats);
|
||||||
|
setMessages(updatedChats[0].messages);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className='max-md:hidden flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm md:mb-2 flex-shrink-0 md:border md:border-white/20'
|
||||||
|
onClick={addChat}
|
||||||
|
>
|
||||||
|
<PlusIcon />{' '}
|
||||||
|
<span className='hidden md:inline-flex text-white text-sm'>New chat</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewChat;
|
1
src/components/Menu/index.ts
Normal file
1
src/components/Menu/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './Menu';
|
53
src/components/MobileBar/MobileBar.tsx
Normal file
53
src/components/MobileBar/MobileBar.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
import NewChat from '../Menu/NewChat';
|
||||||
|
|
||||||
|
const MobileBar = () => {
|
||||||
|
const [chats, currentChatIndex] = useStore((state) => [
|
||||||
|
state.chats,
|
||||||
|
state.currentChatIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='sticky top-0 left-0 w-full z-50 flex items-center border-b border-white/20 bg-gray-800 pl-1 pt-1 text-gray-200 sm:pl-3 md:hidden'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='-ml-0.5 -mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-md hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white dark:hover:text-white'
|
||||||
|
onClick={() => {
|
||||||
|
document
|
||||||
|
.getElementById('menu')
|
||||||
|
?.classList.add('max-md:translate-x-[0%]');
|
||||||
|
document.getElementById('menu-close')?.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Open sidebar</span>
|
||||||
|
<svg
|
||||||
|
stroke='currentColor'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='1.5'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className='h-6 w-6'
|
||||||
|
height='1em'
|
||||||
|
width='1em'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<line x1='3' y1='12' x2='21' y2='12'></line>
|
||||||
|
<line x1='3' y1='6' x2='21' y2='6'></line>
|
||||||
|
<line x1='3' y1='18' x2='21' y2='18'></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className='flex-1 text-center text-base font-normal'>
|
||||||
|
{chats && chats.length > 0
|
||||||
|
? chats[currentChatIndex]?.title
|
||||||
|
: 'New Chat'}
|
||||||
|
</h1>
|
||||||
|
<NewChat />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileBar;
|
1
src/components/MobileBar/index.ts
Normal file
1
src/components/MobileBar/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './MobileBar';
|
12
src/constants/chat.ts
Normal file
12
src/constants/chat.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
const date = new Date();
|
||||||
|
const dateString =
|
||||||
|
date.getFullYear() +
|
||||||
|
'-' +
|
||||||
|
('0' + (date.getMonth() + 1)).slice(-2) +
|
||||||
|
'-' +
|
||||||
|
('0' + date.getDate()).slice(-2);
|
||||||
|
|
||||||
|
// default system message obtained using the following method: https://twitter.com/DeminDimin/status/1619935545144279040
|
||||||
|
export const defaultSystemMessage = `You are ChatGPT, a large language model trained by OpenAI.
|
||||||
|
Knowledge cutoff: 2021-09
|
||||||
|
Current date: ${dateString}`;
|
12
src/hooks/useSaveToLocalStorage.ts
Normal file
12
src/hooks/useSaveToLocalStorage.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
|
||||||
|
const useSaveToLocalStorage = () => {
|
||||||
|
const chats = useStore((state) => state.chats);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chats) localStorage.setItem('chats', JSON.stringify(chats));
|
||||||
|
}, [chats]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSaveToLocalStorage;
|
25
src/hooks/useUpdateChats.ts
Normal file
25
src/hooks/useUpdateChats.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import useStore from '@store/store';
|
||||||
|
import { ChatInterface, MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
const useUpdateCharts = () => {
|
||||||
|
const [chats, messages, setChats, currentChatIndex] = useStore((state) => [
|
||||||
|
state.chats,
|
||||||
|
state.messages,
|
||||||
|
state.setChats,
|
||||||
|
state.currentChatIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentChatIndex !== -1 && chats && chats.length > 0) {
|
||||||
|
const updatedChats: ChatInterface[] = JSON.parse(JSON.stringify(chats));
|
||||||
|
const updatedMessages: MessageInterface[] = JSON.parse(
|
||||||
|
JSON.stringify(messages)
|
||||||
|
);
|
||||||
|
updatedChats[currentChatIndex].messages = updatedMessages;
|
||||||
|
setChats(updatedChats);
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUpdateCharts;
|
|
@ -1,3 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
207
src/main.css
Normal file
207
src/main.css
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: inherit;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body,
|
||||||
|
.dark html {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(52, 53, 65, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 1rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.scrollbar-trigger ::-webkit-scrollbar-thumb {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark::-webkit-scrollbar-thumb {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(86, 88, 105, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
background-color: rgba(217, 217, 227, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||||
|
border-radius: 9999px;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
align-items: center;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-width: 1px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neutral {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
background-color: rgba(255, 255, 255, var(--tw-bg-opacity));
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
border-width: 1px;
|
||||||
|
color: rgba(64, 65, 79, var(--tw-text-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neutral:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(236, 236, 241, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .btn-neutral {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
background-color: rgba(52, 53, 65, var(--tw-bg-opacity));
|
||||||
|
border-color: rgba(86, 88, 105, var(--tw-border-opacity));
|
||||||
|
color: rgba(217, 217, 227, var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .btn-neutral:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(64, 65, 79, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
background-color: rgba(52, 53, 65, var(--tw-bg-opacity));
|
||||||
|
border-color: rgba(86, 88, 105, var(--tw-border-opacity));
|
||||||
|
border-width: 1px;
|
||||||
|
color: rgba(255, 255, 255, var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
background-color: rgba(16, 163, 127, var(--tw-bg-opacity));
|
||||||
|
color: rgba(255, 255, 255, var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(26, 127, 100, var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.scroll-convo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown ol,
|
||||||
|
.markdown ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown ol li,
|
||||||
|
.markdown ol li > p,
|
||||||
|
.markdown ol ol,
|
||||||
|
.markdown ol ul,
|
||||||
|
.markdown ul li,
|
||||||
|
.markdown ul li > p,
|
||||||
|
.markdown ul ol,
|
||||||
|
.markdown ul ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown ul li:before {
|
||||||
|
content: '•';
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code.hljs,
|
||||||
|
:not(pre) > code[class*='language-'] {
|
||||||
|
border-radius: 0.3em;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.hljs-comment {
|
||||||
|
color: hsla(0, 0%, 100%, 0.5);
|
||||||
|
}
|
||||||
|
.hljs-meta {
|
||||||
|
color: hsla(0, 0%, 100%, 0.6);
|
||||||
|
}
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-class .hljs-title {
|
||||||
|
color: #e9950c;
|
||||||
|
}
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-formula,
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-literal {
|
||||||
|
color: #2e95d3;
|
||||||
|
}
|
||||||
|
.hljs-addition,
|
||||||
|
.hljs-attribute,
|
||||||
|
.hljs-meta-string,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-string {
|
||||||
|
color: #00a67d;
|
||||||
|
}
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-selector-attr,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-selector-pseudo,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-variable {
|
||||||
|
color: #df3079;
|
||||||
|
}
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-link,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-title {
|
||||||
|
color: #f22c3d;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './main.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
24
src/store/auth-slice.ts
Normal file
24
src/store/auth-slice.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { StoreSlice } from './store';
|
||||||
|
|
||||||
|
export interface AuthSlice {
|
||||||
|
apiKey?: string;
|
||||||
|
apiFree: boolean;
|
||||||
|
setApiKey: (apiKey: string) => void;
|
||||||
|
setApiFree: (apiFree: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAuthSlice: StoreSlice<AuthSlice> = (set, get) => ({
|
||||||
|
apiFree: true,
|
||||||
|
setApiKey: (apiKey: string) => {
|
||||||
|
set((prev: AuthSlice) => ({
|
||||||
|
...prev,
|
||||||
|
apiKey: apiKey,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setApiFree: (apiFree: boolean) => {
|
||||||
|
set((prev: AuthSlice) => ({
|
||||||
|
...prev,
|
||||||
|
apiFree: apiFree,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
34
src/store/chat-slice.ts
Normal file
34
src/store/chat-slice.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { StoreSlice } from './store';
|
||||||
|
import { ChatInterface, MessageInterface } from '@type/chat';
|
||||||
|
|
||||||
|
export interface ChatSlice {
|
||||||
|
messages: MessageInterface[];
|
||||||
|
chats?: ChatInterface[];
|
||||||
|
currentChatIndex: number;
|
||||||
|
setMessages: (messages: MessageInterface[]) => void;
|
||||||
|
setChats: (chats: ChatInterface[]) => void;
|
||||||
|
setCurrentChatIndex: (currentChatIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createChatSlice: StoreSlice<ChatSlice> = (set, get) => ({
|
||||||
|
messages: [],
|
||||||
|
currentChatIndex: -1,
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
16
src/store/config-slice.ts
Normal file
16
src/store/config-slice.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { StoreSlice } from './store';
|
||||||
|
|
||||||
|
export interface ConfigSlice {
|
||||||
|
openConfig: boolean;
|
||||||
|
setOpenConfig: (openConfig: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createConfigSlice: StoreSlice<ConfigSlice> = (set, get) => ({
|
||||||
|
openConfig: false,
|
||||||
|
setOpenConfig: (openConfig: boolean) => {
|
||||||
|
set((prev: ConfigSlice) => ({
|
||||||
|
...prev,
|
||||||
|
openConfig: openConfig,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
17
src/store/input-slice.ts
Normal file
17
src/store/input-slice.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { StoreSlice } from './store';
|
||||||
|
import { Role } from '@type/chat';
|
||||||
|
|
||||||
|
export interface InputSlice {
|
||||||
|
inputRole: Role;
|
||||||
|
setInputRole: (inputRole: Role) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInputSlice: StoreSlice<InputSlice> = (set, get) => ({
|
||||||
|
inputRole: 'user',
|
||||||
|
setInputRole: (inputRole: Role) => {
|
||||||
|
set((prev: InputSlice) => ({
|
||||||
|
...prev,
|
||||||
|
inputRole: inputRole,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
21
src/store/store.ts
Normal file
21
src/store/store.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import create, { SetState, GetState } from 'zustand';
|
||||||
|
import { ChatSlice, createChatSlice } from './chat-slice';
|
||||||
|
import { InputSlice, createInputSlice } from './input-slice';
|
||||||
|
import { AuthSlice, createAuthSlice } from './auth-slice';
|
||||||
|
import { ConfigSlice, createConfigSlice } from './config-slice';
|
||||||
|
|
||||||
|
export type StoreState = ChatSlice & InputSlice & AuthSlice & ConfigSlice;
|
||||||
|
|
||||||
|
export type StoreSlice<T> = (
|
||||||
|
set: SetState<StoreState>,
|
||||||
|
get: GetState<StoreState>
|
||||||
|
) => T;
|
||||||
|
|
||||||
|
const useStore = create<StoreState>((set, get) => ({
|
||||||
|
...createChatSlice(set, get),
|
||||||
|
...createInputSlice(set, get),
|
||||||
|
...createAuthSlice(set, get),
|
||||||
|
...createConfigSlice(set, get),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useStore;
|
18
src/types/api.ts
Normal file
18
src/types/api.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export interface EventSourceDataInterface {
|
||||||
|
choices: EventSourceDataChoices[];
|
||||||
|
created: number;
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSourceData = EventSourceDataInterface | '[DONE]';
|
||||||
|
|
||||||
|
export interface EventSourceDataChoices {
|
||||||
|
delta: {
|
||||||
|
content?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
finish_reason?: string;
|
||||||
|
index: number;
|
||||||
|
}
|
12
src/types/chat.ts
Normal file
12
src/types/chat.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export type Role = 'user' | 'assistant' | 'system';
|
||||||
|
export const roles: Role[] = ['user', 'assistant', 'system'];
|
||||||
|
|
||||||
|
export interface MessageInterface {
|
||||||
|
role: Role;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInterface {
|
||||||
|
title: string;
|
||||||
|
messages: MessageInterface[];
|
||||||
|
}
|
|
@ -2,7 +2,40 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'Söhne',
|
||||||
|
'ui-sans-serif',
|
||||||
|
'system-ui',
|
||||||
|
'-apple-system',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'Ubuntu',
|
||||||
|
'Cantarell',
|
||||||
|
'Noto Sans',
|
||||||
|
'sans-serif',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Arial',
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji',
|
||||||
|
],
|
||||||
|
mono: ['Söhne Mono', 'Monaco', 'Andale Mono', 'Ubuntu Mono', 'monospace'],
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
typography: {
|
||||||
|
DEFAULT: {
|
||||||
|
css: {
|
||||||
|
pre: { padding: 0, margin: 0 },
|
||||||
|
ul: {
|
||||||
|
'list-style-type': 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require('@tailwindcss/typography')],
|
||||||
|
darkMode: 'class',
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,7 +14,16 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@icon/*": ["./src/assets/icons/*"],
|
||||||
|
"@type/*": ["./src/types/*"],
|
||||||
|
"@store/*": ["./src/store/*"],
|
||||||
|
"@hooks/*": ["./src/hooks/*"],
|
||||||
|
"@constants/*": ["./src/constants/*"],
|
||||||
|
"@api/*": ["./src/api/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|
|
@ -1,7 +1,17 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@icon/': new URL('./src/assets/icons/', import.meta.url).pathname,
|
||||||
|
'@type/': new URL('./src/types/', import.meta.url).pathname,
|
||||||
|
'@store/': new URL('./src/store/', import.meta.url).pathname,
|
||||||
|
'@hooks/': new URL('./src/hooks/', import.meta.url).pathname,
|
||||||
|
'@constants/': new URL('./src/constants/', import.meta.url).pathname,
|
||||||
|
'@api/': new URL('./src/api/', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue