diff --git a/api/routers/chat.py b/api/routers/chat.py index 42fcaa9..1e00415 100644 --- a/api/routers/chat.py +++ b/api/routers/chat.py @@ -1,5 +1,7 @@ -from fastapi import APIRouter +from typing import Optional +from fastapi import APIRouter, HTTPException, Body from fastapi.responses import StreamingResponse +from pydantic import BaseModel from api import schemas from api.dependencies import app, honcho @@ -80,3 +82,47 @@ async def get_thought(conversation_id: str, message_id: str, user_id: str): ) # In practice, there should only be one thought per message return {"thought": thought.items[0].content if thought.items else None} + +class ReactionBody(BaseModel): + reaction: Optional[str] = None + +@router.post("/reaction/{message_id}") +async def add_or_remove_reaction( + conversation_id: str, + message_id: str, + user_id: str, + body: ReactionBody +): + reaction = body.reaction + + if reaction is not None and reaction not in ["thumbs_up", "thumbs_down"]: + raise HTTPException(status_code=400, detail="Invalid reaction type") + + user = honcho.apps.users.get_or_create(app_id=app.id, name=user_id) + + message = honcho.apps.users.sessions.messages.get( + app_id=app.id, + session_id=conversation_id, + user_id=user.id, + message_id=message_id + ) + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + metadata = message.metadata or {} + + if reaction is None: + metadata.pop('reaction', None) + else: + metadata['reaction'] = reaction + + honcho.apps.users.sessions.messages.update( + app_id=app.id, + session_id=conversation_id, + user_id=user.id, + message_id=message_id, + metadata=metadata + ) + + return {"status": "Reaction updated successfully"} 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..519962d 100644 --- a/www/app/page.tsx +++ b/www/app/page.tsx @@ -4,14 +4,11 @@ import useSWR from 'swr'; 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'; +import banner from "@/public/bloom2x1.svg"; +import darkBanner from "@/public/bloom2x1dark.svg"; +import { DarkModeSwitch } from "react-toggle-dark-mode"; +import { FaLightbulb, FaPaperPlane, FaBars } from "react-icons/fa"; +import Swal from "sweetalert2"; import { useRef, useEffect, useState, ElementRef } from 'react'; import { redirect } from 'next/navigation'; @@ -19,10 +16,19 @@ import { usePostHog } from 'posthog-js/react'; import { getSubscription } from '@/utils/supabase/queries'; -import { API } from '@/utils/api'; -import { createClient } from '@/utils/supabase/client'; +import { API } from "@/utils/api"; +import { createClient } from "@/utils/supabase/client"; +import { Reaction } from "@/components/messagebox"; -const Thoughts = dynamic(() => import('@/components/thoughts')); +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; @@ -133,6 +139,35 @@ export default function Home() { 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; + }); + }, true); + } catch (error) { + console.error("Failed to update reaction:", error); + } + }; + async function chat() { if (!isSubscribed) { Swal.fire({ @@ -202,7 +237,6 @@ export default function Home() { isThinking = false; continue; } - console.log(value); setThought((prev) => prev + value); } else { if (value.includes('❀')) { @@ -297,20 +331,29 @@ export default function Home() { isUser={message.isUser} userId={userId} URL={URL} - messageId={message.id} - text={message.text} + message={message} loading={messagesLoading} conversationId={conversationId} setThought={setThought} setIsThoughtsOpen={setIsThoughtsOpen} + onReactionAdded={handleReactionAdded} /> )) || ( )} diff --git a/www/components/messagebox.tsx b/www/components/messagebox.tsx index 9ab9799..fd02c82 100644 --- a/www/components/messagebox.tsx +++ b/www/components/messagebox.tsx @@ -1,39 +1,64 @@ -import { useState } from 'react'; -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 { useState } from "react"; +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, 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; 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, setIsThoughtsOpen, conversationId, + onReactionAdded, setThought, }: MessageBoxProps) { const [isThoughtLoading, setIsThoughtLoading] = useState(false); + const [pendingReaction, setPendingReaction] = useState(null); const [error, setError] = useState(null); - const shouldShowButtons = messageId !== ''; + const { id: messageId, text, metadata } = message; + const reaction = metadata?.reaction || null; + const shouldShowButtons = messageId !== ""; + + 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 Exclude, + ); + } catch (err) { + console.error(err); + setError("Failed to update reaction."); + } finally { + setPendingReaction(null); + } + }; const handleFetchThought = async () => { if (!messageId || !conversationId || !userId || !URL) return; @@ -82,23 +107,50 @@ export default function MessageBox({
{text}
)} {!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..280c9d1 --- /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/utils/api.ts b/www/utils/api.ts index 2c29e45..50b9a78 100644 --- a/www/utils/api.ts +++ b/www/utils/api.ts @@ -1,5 +1,7 @@ +import { type Reaction } from "@/components/messagebox"; + 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 +10,7 @@ export interface Message { text: string; isUser: boolean; id: string; + metadata?: { reaction?: Reaction }; } export class Conversation { @@ -154,6 +157,7 @@ export class API { text: rawMessage.content, isUser: rawMessage.isUser, id: rawMessage.id, + metadata: rawMessage.metadata, }; }); @@ -186,4 +190,31 @@ export class API { return null; } } + + async addOrRemoveReaction( + conversationId: string, + messageId: string, + reaction: Reaction, + ): Promise<{ status: string }> { + try { + const response = await fetch( + `${this.url}/api/reaction/${messageId}?user_id=${this.userId}&conversation_id=${conversationId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ reaction: reaction || undefined }), + }, + ); + if (!response.ok) { + throw new Error("Failed to update reaction"); + } + + return await response.json(); + } catch (error) { + console.error("Error updating reaction:", error); + throw error; + } + } }