diff --git a/.gitignore b/.gitignore index 3ffdc5c..0129f94 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ *.pyc *.pyo .python-version +api/api.egg-info/* # Visual Studio Code .vscode/ diff --git a/api/pyproject.toml b/api/pyproject.toml index 5ee7bf3..6800810 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -2,9 +2,7 @@ name = "api" version = "0.6.0" description = "The REST API Implementation of Tutor-GPT" -authors = [ - {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, -] +authors = [{ name = "Plastic Labs", email = "hello@plasticlabs.ai" }] requires-python = ">=3.11" dependencies = [ "fastapi[standard]>=0.112.2", diff --git a/api/routers/messages.py b/api/routers/messages.py index 970b99f..3045418 100644 --- a/api/routers/messages.py +++ b/api/routers/messages.py @@ -21,6 +21,7 @@ async def get_messages(user_id: str, conversation_id: uuid.UUID): "id": message.id, "content": message.content, "isUser": message.is_user, + "metadata": message.metadata, } for message in honcho.apps.users.sessions.messages.list( app_id=app.id, user_id=user.id, session_id=str(conversation_id) diff --git a/www/app/page.tsx b/www/app/page.tsx index 8703344..9555194 100644 --- a/www/app/page.tsx +++ b/www/app/page.tsx @@ -6,9 +6,6 @@ import dynamic from 'next/dynamic'; import banner from '@/public/bloom2x1.svg'; import darkBanner from '@/public/bloom2x1dark.svg'; -import MessageBox from '@/components/messagebox'; -import Sidebar from '@/components/sidebar'; -import MarkdownWrapper from '@/components/markdownWrapper'; import { DarkModeSwitch } from 'react-toggle-dark-mode'; import { FaLightbulb, FaPaperPlane, FaBars } from 'react-icons/fa'; import Swal from 'sweetalert2'; @@ -21,15 +18,28 @@ import { getSubscription } from '@/utils/supabase/queries'; import { API } from '@/utils/api'; import { createClient } from '@/utils/supabase/client'; - -const Thoughts = dynamic(() => import('@/components/thoughts')); +import { Reaction } from '@/components/messagebox'; + +const Thoughts = dynamic(() => import('@/components/thoughts'), { + ssr: false, +}); +const MessageBox = dynamic(() => import('@/components/messagebox'), { + ssr: false, +}); +const Sidebar = dynamic(() => import('@/components/sidebar'), { + ssr: false, +}); const URL = process.env.NEXT_PUBLIC_API_URL; export default function Home() { const [userId, setUserId] = useState(); - const [isThoughtsOpen, setIsThoughtsOpen] = useState(false); + const [isThoughtsOpenState, setIsThoughtsOpenState] = + useState(false); + const [openThoughtMessageId, setOpenThoughtMessageId] = useState< + string | null + >(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [thought, setThought] = useState(''); @@ -51,6 +61,14 @@ export default function Home() { const [isSubscribed, setIsSubscribed] = useState(false); + const setIsThoughtsOpen = ( + isOpen: boolean, + messageId: string | null = null + ) => { + setIsThoughtsOpenState(isOpen); + setOpenThoughtMessageId(isOpen ? messageId : null); + }; + useEffect(() => { (async () => { const { @@ -106,17 +124,17 @@ export default function Home() { return api.getConversations(); }; - const { - data: conversations, - mutate: mutateConversations, - error, - } = useSWR(userId, conversationsFetcher, { - onSuccess: (conversations) => { - setConversationId(conversations[0].conversationId); - setCanSend(true); - }, - revalidateOnFocus: false, - }); + const { data: conversations, mutate: mutateConversations } = useSWR( + userId, + conversationsFetcher, + { + onSuccess: (conversations) => { + setConversationId(conversations[0].conversationId); + setCanSend(true); + }, + revalidateOnFocus: false, + } + ); const messagesFetcher = async (conversationId: string) => { if (!userId) return Promise.resolve([]); @@ -130,9 +148,40 @@ export default function Home() { data: messages, mutate: mutateMessages, isLoading: messagesLoading, - error: _, } = useSWR(conversationId, messagesFetcher, { revalidateOnFocus: false }); + const handleReactionAdded = async (messageId: string, reaction: Reaction) => { + if (!userId || !conversationId) return; + + const api = new API({ url: URL!, userId }); + + try { + await api.addOrRemoveReaction(conversationId, messageId, reaction); + + // Optimistically update the local data + mutateMessages( + (currentMessages) => { + if (!currentMessages) return currentMessages; + return currentMessages.map((msg) => { + if (msg.id === messageId) { + return { + ...msg, + metadata: { + ...msg.metadata, + reaction, + }, + }; + } + return msg; + }); + }, + { revalidate: false } + ); + } catch (error) { + console.error('Failed to update reaction:', error); + } + }; + async function chat() { if (!isSubscribed) { Swal.fire({ @@ -202,7 +251,6 @@ export default function Home() { isThinking = false; continue; } - console.log(value); setThought((prev) => prev + value); } else { if (value.includes('❀')) { @@ -214,7 +262,7 @@ export default function Home() { mutateMessages( [ - ...newMessages?.slice(0, -1)!, + ...(newMessages?.slice(0, -1) || []), { text: currentModelOutput, isUser: false, @@ -238,8 +286,9 @@ export default function Home() { return (
+ setIsThoughtsOpen(isOpen, message.id) + } + onReactionAdded={handleReactionAdded} /> )) || ( - - )} + + )}
{ @@ -358,8 +420,8 @@ export default function Home() { setIsThoughtsOpen(isOpen, null)} + isThoughtsOpen={isThoughtsOpenState} />
); diff --git a/www/components/messagebox.tsx b/www/components/messagebox.tsx index 9ab9799..da6b00e 100644 --- a/www/components/messagebox.tsx +++ b/www/components/messagebox.tsx @@ -3,41 +3,69 @@ import Image from 'next/image'; import icon from '@/public/bloomicon.jpg'; import usericon from '@/public/usericon.svg'; import Skeleton from 'react-loading-skeleton'; -import { FaLightbulb } from 'react-icons/fa'; -import { API } from '@/utils/api'; +import MarkdownWrapper from './markdownWrapper'; +import { FaLightbulb, FaThumbsDown, FaThumbsUp } from 'react-icons/fa'; +import { API, type Message } from '@/utils/api'; +import Spinner from './spinner'; + +export type Reaction = 'thumbs_up' | 'thumbs_down' | null; interface MessageBoxProps { isUser?: boolean; userId?: string; URL?: string; - messageId?: string; conversationId?: string; - text: string; + message: Message; loading?: boolean; - isThoughtsOpen?: boolean; + isThoughtOpen?: boolean; setIsThoughtsOpen: (isOpen: boolean) => void; setThought: (thought: string) => void; + onReactionAdded: (messageId: string, reaction: Reaction) => Promise; } export default function MessageBox({ isUser, userId, URL, - messageId, - text, + message, loading = false, + isThoughtOpen, setIsThoughtsOpen, conversationId, + onReactionAdded, setThought, }: MessageBoxProps) { const [isThoughtLoading, setIsThoughtLoading] = useState(false); + const [pendingReaction, setPendingReaction] = useState(null); const [error, setError] = useState(null); + const { id: messageId, text, metadata } = message; + const reaction = metadata?.reaction || null; const shouldShowButtons = messageId !== ''; - const handleFetchThought = async () => { + const handleReaction = async (newReaction: Exclude) => { if (!messageId || !conversationId || !userId || !URL) return; + setPendingReaction(newReaction); + + try { + const reactionToSend = reaction === newReaction ? null : newReaction; + await onReactionAdded(messageId, reactionToSend as Reaction); + } catch (err) { + console.error(err); + setError('Failed to update reaction.'); + } finally { + setPendingReaction(null); + } + }; + + const handleFetchThought = async () => { + if (!messageId || !conversationId || !userId || !URL) return; + if (isThoughtOpen) { + // If thought is already open, close it + setIsThoughtsOpen(false); + return; + } setIsThoughtLoading(true); setError(null); @@ -76,29 +104,58 @@ export default function MessageBox({ /> )}
- {loading ? ( - - ) : ( -
{text}
- )} + {loading ? : } {!loading && !isUser && shouldShowButtons && ( -
- {/* + - */}
)} - {isThoughtLoading &&

Loading thought...

} {error &&

Error: {error}

}
diff --git a/www/components/spinner.tsx b/www/components/spinner.tsx new file mode 100644 index 0000000..f1719a2 --- /dev/null +++ b/www/components/spinner.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { FaCircleNotch } from 'react-icons/fa'; + +const Spinner = ({ size = 24, color = '#000000' }) => { + const spinnerStyle = { + animation: 'spin 1s linear infinite', + color: color, + fontSize: `${size}px`, + }; + + return ( +
+ + +
+ ); +}; + +export default Spinner; diff --git a/www/package.json b/www/package.json index cc23247..52b26d4 100644 --- a/www/package.json +++ b/www/package.json @@ -26,6 +26,7 @@ "react-toggle-dark-mode": "^1.1.1", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", + "retry": "^0.13.1", "sharp": "^0.32.6", "stripe": "^16.12.0", "sweetalert2": "^11.14.2", @@ -38,6 +39,7 @@ "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "@types/react-syntax-highlighter": "^15.5.13", + "@types/retry": "^0.12.5", "@types/uuid": "^9.0.8", "autoprefixer": "10.4.15", "encoding": "^0.1.13", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 10fad57..5148a55 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 + retry: + specifier: ^0.13.1 + version: 0.13.1 sharp: specifier: ^0.32.6 version: 0.32.6 @@ -86,7 +89,10 @@ importers: "@types/react-syntax-highlighter": specifier: ^15.5.13 version: 15.5.13 - "@types/uuid": + '@types/retry': + specifier: ^0.12.5 + version: 0.12.5 + '@types/uuid': specifier: ^9.0.8 version: 9.0.8 autoprefixer: @@ -2631,11 +2637,11 @@ packages: integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==, } - "@types/scheduler@0.23.0": - resolution: - { - integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==, - } + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + + '@types/scheduler@0.23.0': + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} "@types/shimmer@1.2.0": resolution: @@ -7708,11 +7714,12 @@ packages: hasBin: true restore-cursor@3.1.0: - resolution: - { - integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} reusify@1.0.4: resolution: @@ -11276,7 +11283,9 @@ snapshots: "@types/scheduler": 0.23.0 csstype: 3.1.3 - "@types/scheduler@0.23.0": {} + '@types/retry@0.12.5': {} + + '@types/scheduler@0.23.0': {} "@types/shimmer@1.2.0": {} @@ -14820,6 +14829,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.13.1: {} + reusify@1.0.4: {} rimraf@2.6.3: diff --git a/www/utils/api.ts b/www/utils/api.ts index 2c29e45..88fb306 100644 --- a/www/utils/api.ts +++ b/www/utils/api.ts @@ -1,5 +1,8 @@ +import { type Reaction } from '@/components/messagebox'; +import { retryDBOperation, retryOpenAIOperation } from './retryUtils'; + const defaultMessage: Message = { - text: `I'm your Aristotelian learning companion — here to help you follow your curiosity in whatever direction you like. My engineering makes me extremely receptive to your needs and interests. You can reply normally, and I’ll always respond!\n\nIf I'm off track, just say so!\n\nNeed to leave or just done chatting? Let me know! I’m conversational by design so I’ll say goodbye 😊.`, + text: `I'm your Aristotelian learning companion — here to help you follow your curiosity in whatever direction you like. My engineering makes me extremely receptive to your needs and interests. You can reply normally, and I’ll always respond!\n\nIf I'm off track, just say so!\n\nNeed to leave or just done chatting? Let me know! I’m conversational by design so I’ll say goodbye 😊.`, isUser: false, id: '', }; @@ -8,6 +11,7 @@ export interface Message { text: string; isUser: boolean; id: string; + metadata?: { reaction?: Reaction }; } export class Conversation { @@ -30,65 +34,71 @@ export class Conversation { } async getMessages() { - const req = await fetch( - `${this.api.url}/api/messages?` + - new URLSearchParams({ - conversation_id: this.conversationId, - user_id: this.api.userId, - }) - ); - const { messages: rawMessages } = await req.json(); - // console.log(rawMessages); - if (!rawMessages) return []; - const messages = rawMessages.map((rawMessage: any) => { - return { - text: rawMessage.data.content, - isUser: rawMessage.type === 'human', - id: rawMessage.id, - }; - }); + return retryDBOperation(async () => { + const req = await fetch( + `${this.api.url}/api/messages?` + + new URLSearchParams({ + conversation_id: this.conversationId, + user_id: this.api.userId, + }) + ); + const { messages: rawMessages } = await req.json(); + if (!rawMessages) return []; + const messages = rawMessages.map((rawMessage: any) => { + return { + text: rawMessage.data.content, + isUser: rawMessage.type === 'human', + id: rawMessage.id, + }; + }); - return messages; + return messages; + }); } async setName(name: string) { if (!name || name === this.name) return; - await fetch(`${this.api.url}/api/conversations/update`, { - method: 'POST', - body: JSON.stringify({ - conversation_id: this.conversationId, - user_id: this.api.userId, - name, - }), - headers: { - 'Content-Type': 'application/json', - }, + await retryDBOperation(async () => { + await fetch(`${this.api.url}/api/conversations/update`, { + method: 'POST', + body: JSON.stringify({ + conversation_id: this.conversationId, + user_id: this.api.userId, + name, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + this.name = name; }); - this.name = name; } async delete() { - await fetch( - `${this.api.url}/api/conversations/delete?user_id=${this.api.userId}&conversation_id=${this.conversationId}` - ).then((res) => res.json()); + await retryDBOperation(async () => { + await fetch( + `${this.api.url}/api/conversations/delete?user_id=${this.api.userId}&conversation_id=${this.conversationId}` + ).then((res) => res.json()); + }); } async chat(message: string) { - const req = await fetch(`${this.api.url}/api/stream`, { - method: 'POST', - body: JSON.stringify({ - conversation_id: this.conversationId, - user_id: this.api.userId, - message, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); + return retryOpenAIOperation(async () => { + const req = await fetch(`${this.api.url}/api/stream`, { + method: 'POST', + body: JSON.stringify({ + conversation_id: this.conversationId, + user_id: this.api.userId, + message, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); - const reader = req.body?.pipeThrough(new TextDecoderStream()).getReader()!; - return reader; + return req.body?.pipeThrough(new TextDecoderStream()).getReader()!; + }); } } @@ -107,83 +117,186 @@ export class API { } async new() { - const req = await fetch( - `${this.url}/api/conversations/insert?user_id=${this.userId}` - ); - const { conversation_id } = await req.json(); - return new Conversation({ - api: this, - name: '', - conversationId: conversation_id, + return retryDBOperation(async () => { + const req = await fetch( + `${this.url}/api/conversations/insert?user_id=${this.userId}` + ); + const { conversation_id } = await req.json(); + return new Conversation({ + api: this, + name: '', + conversationId: conversation_id, + }); }); } async getConversations() { - const req = await fetch( - `${this.url}/api/conversations/get?user_id=${this.userId}` - ); - const { conversations }: { conversations: RawConversation[] } = - await req.json(); - - if (conversations.length === 0) { - return [await this.new()]; - } - return conversations.map( - (conversation) => - new Conversation({ - api: this, - name: conversation.name, - conversationId: conversation.conversation_id, - }) - ); + return retryDBOperation(async () => { + const req = await fetch( + `${this.url}/api/conversations/get?user_id=${this.userId}` + ); + const { conversations }: { conversations: RawConversation[] } = + await req.json(); + + if (conversations.length === 0) { + return [await this.new()]; + } + return conversations.map( + (conversation) => + new Conversation({ + api: this, + name: conversation.name, + conversationId: conversation.conversation_id, + }) + ); + }); } async getMessagesByConversation(conversationId: string) { - const req = await fetch( - `${this.url}/api/messages?` + - new URLSearchParams({ - conversation_id: conversationId, - user_id: this.userId, - }) - ); - const { messages: rawMessages } = await req.json(); - // console.log(rawMessages); - if (!rawMessages) return []; - const messages: Message[] = rawMessages.map((rawMessage: any) => { - return { - text: rawMessage.content, - isUser: rawMessage.isUser, - id: rawMessage.id, - }; - }); + return retryDBOperation(async () => { + const req = await fetch( + `${this.url}/api/messages?` + + new URLSearchParams({ + conversation_id: conversationId, + user_id: this.userId, + }) + ); + const { messages: rawMessages } = await req.json(); + if (!rawMessages) return []; + const messages: Message[] = rawMessages.map((rawMessage: any) => { + return { + ...rawMessage, + text: rawMessage.content, + isUser: rawMessage.isUser, + id: rawMessage.id, + metadata: rawMessage.metadata, + }; + }); - return [defaultMessage, ...messages]; + return [defaultMessage, ...messages]; + }); } async getThoughtById( conversationId: string, messageId: string ): Promise { + return retryDBOperation(async () => { + try { + const response = await fetch( + `${this.url}/api/thought/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch thought'); + } + + const data = await response.json(); + return data.thought; + } catch (error) { + console.error('Error fetching thought:', error); + return null; + } + }); + } + + async addReaction( + conversationId: string, + messageId: string, + reaction: Exclude + ): Promise<{ status: string }> { + return retryDBOperation(async () => { + try { + const response = await fetch( + `${this.url}/api/reaction/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}&reaction=${reaction}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to add reaction'); + } + + return await response.json(); + } catch (error) { + console.error('Error adding reaction:', error); + throw error; + } + }); + } + + async getReaction( + conversationId: string, + messageId: string + ): Promise<{ reaction: Reaction }> { + return retryDBOperation(async () => { + try { + const response = await fetch( + `${this.url}/api/reaction/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to get reaction'); + } + + const data = await response.json(); + + // Validate the reaction + if ( + data.reaction !== null && + !['thumbs_up', 'thumbs_down'].includes(data.reaction) + ) { + throw new Error('Invalid reaction received from server'); + } + + return data as { reaction: Reaction }; + } catch (error) { + console.error('Error getting reaction:', error); + throw error; + } + }); + } + + async addOrRemoveReaction( + conversationId: string, + messageId: string, + reaction: Reaction + ): Promise<{ status: string }> { try { const response = await fetch( - `${this.url}/api/thought/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}`, + `${this.url}/api/reaction/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}`, { - method: 'GET', + method: 'POST', headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ reaction: reaction || undefined }), } ); - if (!response.ok) { - throw new Error('Failed to fetch thought'); + throw new Error('Failed to update reaction'); } - const data = await response.json(); - return data.thought; + return await response.json(); } catch (error) { - console.error('Error fetching thought:', error); - return null; + console.error('Error updating reaction:', error); + throw error; } } } diff --git a/www/utils/retryUtils.ts b/www/utils/retryUtils.ts new file mode 100644 index 0000000..9328bfa --- /dev/null +++ b/www/utils/retryUtils.ts @@ -0,0 +1,72 @@ +import retry from 'retry'; +import { captureException, captureMessage } from '@sentry/nextjs'; + +interface RetryOptions { + retries: number; + factor: number; + minTimeout: number; + maxTimeout: number; +} + +const dbOptions: RetryOptions = { + retries: 3, + factor: 1.5, + minTimeout: 1000, + maxTimeout: 10000, +}; + +const openAIOptions: RetryOptions = { + retries: 5, + factor: 2, + minTimeout: 4000, + maxTimeout: 60000, +}; + +function isRateLimitError(error: any): boolean { + return error?.response?.data?.error === 'rate_limit_exceeded'; +} + +function retryOperation( + operation: () => Promise, + options: RetryOptions, + isOpenAI: boolean +): Promise { + return new Promise((resolve, reject) => { + const retryOperation = retry.operation(options); + + retryOperation.attempt(async (currentAttempt) => { + try { + const result = await operation(); + resolve(result); + } catch (error: any) { + if (isOpenAI && isRateLimitError(error)) { + captureMessage('OpenAI Rate Limit Hit', { + level: 'warning', + extra: { + attempt: currentAttempt, + error: error.message, + }, + }); + } else { + captureException(error); + } + + if (retryOperation.retry(error)) { + return; + } + + reject(retryOperation.mainError()); + } + }); + }); +} + +export function retryDBOperation(operation: () => Promise): Promise { + return retryOperation(operation, dbOptions, false); +} + +export function retryOpenAIOperation( + operation: () => Promise +): Promise { + return retryOperation(operation, openAIOptions, true); +}