diff --git a/www/app/Chat.tsx b/www/app/Chat.tsx index 9c34b7fe..00794fe3 100644 --- a/www/app/Chat.tsx +++ b/www/app/Chat.tsx @@ -22,6 +22,8 @@ import { type Message } from '@/utils/types'; import { localStorageProvider } from '@/utils/swrCache'; import useAutoScroll from '@/hooks/autoscroll'; +import MessageList from '@/components/MessageList'; +import { MessageListRef } from '@/components/MessageList'; const Thoughts = dynamic(() => import('@/components/thoughts'), { ssr: false, @@ -56,6 +58,17 @@ async function fetchStream( }); if (!response.ok) { + if (response.status === 402) { + Swal.fire({ + title: 'Subscription Required', + text: 'You have no active subscription. Subscribe to continue using Bloom!', + icon: 'warning', + confirmButtonColor: '#3085d6', + confirmButtonText: 'Subscribe', + showCancelButton: false, + }); + throw new Error(`Subscription is required to chat: ${response.status}`); + } const errorText = await response.text(); console.error(`Stream error for ${type}:`, { status: response.status, @@ -66,10 +79,6 @@ async function fetchStream( throw new Error(`Failed to fetch ${type} stream: ${response.status}`); } - if (!response.body) { - throw new Error(`No response body for ${type} stream`); - } - return response.body; } catch (error) { console.error(`Error in fetchStream (${type}):`, error); @@ -80,9 +89,12 @@ async function fetchStream( interface ChatProps { initialUserId: string; initialEmail: string | undefined; - initialIsSubscribed: boolean; - initialFreeMessages: number; initialConversations: any[]; + initialChatAccess: { + isSubscribed: boolean; + freeMessages: number; + canChat: boolean; + }; initialMessages: any[]; initialConversationId: string | null | undefined; } @@ -91,31 +103,21 @@ interface HonchoResponse { content: string; } -const defaultMessage: Message = { - content: `I’m Bloom, your Aristotelian learning companion, here to guide your intellectual journey. - - -The more we chat, the more I learn about you as a person. That helps me adapt to your interests and needs. - - -What’s on your mind? Let’s dive in. 🌱`, - isUser: false, - id: '', - metadata: {}, -}; - export default function Chat({ initialUserId, initialEmail, - initialIsSubscribed, - initialFreeMessages, initialConversations, initialMessages, initialConversationId, + initialChatAccess, }: ChatProps) { const [userId] = useState(initialUserId); - const [isSubscribed, setIsSubscribed] = useState(initialIsSubscribed); - const [freeMessages, setFreeMessages] = useState(initialFreeMessages); + const [isSubscribed, setIsSubscribed] = useState( + initialChatAccess.isSubscribed + ); + const [freeMessages, setFreeMessages] = useState( + initialChatAccess.freeMessages + ); const [conversationId, setConversationId] = useState( initialConversationId || undefined ); @@ -139,6 +141,33 @@ export default function Chat({ const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; const turnstile = useTurnstile(); + const messageListRef = useRef(null); + const firstChat = useMemo(() => { + return ( + !initialConversations?.length || + (initialConversations.length === 1 && !initialMessages?.length) || + initialChatAccess.freeMessages === 50 + ); + }, [ + initialConversations?.length, + initialMessages?.length, + initialChatAccess.freeMessages, + ]); + + // Since this message is just rendered in the UI, this naive check may result in edge cases where the incorrect message is shown. + // (Ex. will show on all chats after creating a new session or messaging Bloom, even the first chat). + // Also, clearing chats will revert the message to the initial description. + const defaultMessage: Message = { + content: `${firstChat ? "I'm Bloom, your Aristotelian learning companion," : "Welcome back! I'm"} here to guide your intellectual journey. + +The more we chat, the more I learn about you as a person. That helps me adapt to your interests and needs. + +What\'s on your mind? Let\'s dive in. 🌱`, + isUser: false, + id: '', + metadata: {}, + }; + useEffect(() => { if (typeof window !== 'undefined') { posthog?.identify(initialUserId, { email: initialEmail }); @@ -267,22 +296,6 @@ export default function Chat({ if (input.current) input.current.value = ''; - // Check free message allotment upfront if not subscribed - if (!isSubscribed) { - const currentCount = await getFreeMessageCount(userId); - if (currentCount <= 0) { - Swal.fire({ - title: 'Free Messages Depleted', - text: 'You have used all your free messages. Subscribe to continue using Bloom!', - icon: 'warning', - confirmButtonColor: '#3085d6', - confirmButtonText: 'Subscribe', - showCancelButton: true, - }); - return; - } - } - setCanSend(false); const newMessages = [ @@ -301,7 +314,7 @@ export default function Chat({ }, ]; await mutateMessages(newMessages, { revalidate: false }); - scrollToBottom(); + messageListRef.current?.scrollToBottom(); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -315,7 +328,10 @@ export default function Chat({ messageToSend, conversationId! ); - if (!thoughtStream) throw new Error('Failed to get thought stream'); + + if (!thoughtStream) { + throw new Error('Failed to get thought stream'); + } thoughtReader = thoughtStream.getReader(); let thoughtText = ''; @@ -340,6 +356,7 @@ export default function Chat({ conversationId!, thoughtText ); + const honchoContent = (await new Response( honchoResponse ).json()) as HonchoResponse; @@ -394,20 +411,20 @@ export default function Chat({ { revalidate: false } ); - scrollToBottom(); + messageListRef.current?.scrollToBottom(); } responseReader.releaseLock(); responseReader = null; await mutateMessages(); - scrollToBottom(); + messageListRef.current?.scrollToBottom(); setCanSend(true); } catch (error) { console.error('Chat error:', error); setCanSend(true); await mutateMessages(); - scrollToBottom(); + messageListRef.current?.scrollToBottom(); } finally { // Cleanup if (thoughtReader) { @@ -449,19 +466,19 @@ export default function Chat({ setConversationId={setConversationId} isSidebarOpen={isSidebarOpen} toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} - isSubscribed={canUseApp} + canUseApp={canUseApp} />
{!isSidebarOpen && ( )} {!isSubscribed && ( -
+

{freeMessages === 0 ? "You've used all your free messages" @@ -478,46 +495,19 @@ export default function Chat({

)}
-
- {messages ? ( - [defaultMessage, ...messages].map((message, i) => ( - - setIsThoughtsOpen(isOpen, message.id) - } - onReactionAdded={handleReactionAdded} - /> - )) - ) : ( - - )} -
-
+ +
(.*?)<\/honcho>/s); + return match ? match[1].trim() : str; + } catch (error) { + return str; + } +} + export async function POST(req: NextRequest) { - const { message, conversationId } = await req.json(); + const { message, thought, conversationId } = await req.json(); const userData = await getUserData(); @@ -17,12 +26,17 @@ export async function POST(req: NextRequest) { const { appId, userId } = userData; + const query = `Given the following user message: ${message} I had the following message: ${parseHonchoContent(thought)}`; + const dialecticQuery = await honcho.apps.users.sessions.chat( appId, userId, conversationId, - { queries: message } + { queries: query } ); + console.log('dialecticQuery:', query); + console.log('dialecticQuery Response:', dialecticQuery); + return NextResponse.json({ content: dialecticQuery.content }); } diff --git a/www/app/api/chat/response/route.ts b/www/app/api/chat/response/route.ts index e9a8756f..550a384c 100644 --- a/www/app/api/chat/response/route.ts +++ b/www/app/api/chat/response/route.ts @@ -1,4 +1,10 @@ -import { createStream, getUserData, Message } from '@/utils/ai'; +import { + assistant, + createStream, + getUserData, + Message, + user, +} from '@/utils/ai'; import { honcho } from '@/utils/honcho'; import { responsePrompt } from '@/utils/prompts/response'; import { NextRequest, NextResponse } from 'next/server'; @@ -8,7 +14,9 @@ export const maxDuration = 100; export const dynamic = 'force-dynamic'; // always run dynamically export async function POST(req: NextRequest) { - const { message, conversationId, honchoThought } = await req.json(); + const { message, conversationId, thought, honchoThought } = await req.json(); + + console.log('honchoThought', honchoThought); const userData = await getUserData(); if (!userData) { @@ -17,44 +25,110 @@ export async function POST(req: NextRequest) { const { appId, userId } = userData; - const history: Message[] = []; - const responseIter = await honcho.apps.users.sessions.metamessages.list( + const responseIter = await honcho.apps.users.sessions.messages.list( + appId, + userId, + conversationId, + {} + ); + + const responseHistory = Array.from(responseIter.items); + + const honchoIter = await honcho.apps.users.sessions.metamessages.list( appId, userId, conversationId, { - metamessage_type: 'response', + metamessage_type: 'honcho', } ); - for await (const metamessage of responseIter) { - const associatedMessage = await honcho.apps.users.sessions.messages.get( - appId, - userId, - conversationId, - metamessage.message_id - ); + const honchoHistory = Array.from(honchoIter.items); - history.push({ role: 'user', content: metamessage.content }); - history.push({ role: 'assistant', content: associatedMessage.content }); - } + console.log('honchoHistory', honchoHistory); + console.log('responseHistory', responseHistory); + + const getHonchoMessage = (id: string) => + honchoHistory.find((m) => m.message_id === id)?.content || + 'No Honcho Message'; + + const history = responseHistory.map((message, i) => { + if (message.is_user) { + return user`${getHonchoMessage(message.id)} + ${message.content}`; + } else { + return assistant`${message.content}`; + } + }); - const messages = [ - ...responsePrompt, - ...history, + const finalMessage = user`${honchoThought} + ${message}`; + + const prompt = [...responsePrompt, ...history, finalMessage]; + + console.log('responsePrompt', prompt); + + // Create logs directory if it doesn't exist + + const stream = await createStream( + prompt, { - role: 'user', - content: `${honchoThought}\n${message}`, + sessionId: conversationId, + userId, + type: 'response', }, - ] as Message[]; + async (response) => { + const newUserMessage = await honcho.apps.users.sessions.messages.create( + appId, + userId, + conversationId, + { + is_user: true, + content: message, + } + ); - const stream = await createStream('response', messages, { - appId, - userId, - sessionId: conversationId, - userInput: message, - honchoContent: honchoThought, - }); + // Execute all requests in parallel + await Promise.all([ + // Save the thought metamessage + honcho.apps.users.sessions.metamessages.create( + appId, + userId, + conversationId, + { + message_id: newUserMessage.id, + metamessage_type: 'thought', + content: thought || '', + metadata: { type: 'assistant' }, + } + ), + + // Save honcho metamessage + honcho.apps.users.sessions.metamessages.create( + appId, + userId, + conversationId, + { + message_id: newUserMessage.id, + metamessage_type: 'honcho', + content: honchoThought || '', + metadata: { type: 'assistant' }, + } + ), + + // Save assistant message + honcho.apps.users.sessions.messages.create( + appId, + userId, + conversationId, + { + is_user: false, + content: response.text, + } + ), + ]); + } + ); if (!stream) { throw new Error('Failed to get stream'); diff --git a/www/app/api/chat/thought/route.ts b/www/app/api/chat/thought/route.ts index 6e406360..fe7e438b 100644 --- a/www/app/api/chat/thought/route.ts +++ b/www/app/api/chat/thought/route.ts @@ -1,34 +1,54 @@ import { createStream, getUserData, - HistoryWithoutResponse, - Message, user, assistant, // parsePrompt, } from '@/utils/ai'; import { honcho } from '@/utils/honcho'; import { thoughtPrompt } from '@/utils/prompts/thought'; +import { createClient } from '@/utils/supabase/server'; +import { getChatAccessWithUser } from '@/utils/supabase/actions'; import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'nodejs'; export const maxDuration = 100; export const dynamic = 'force-dynamic'; // always run dynamically -const CONTEXT_LIMIT = 10; - -interface Metadata { - summary?: string; +interface ThoughtCallProps { + message: string; + conversationId: string; } export async function POST(req: NextRequest) { - const { message, conversationId } = await req.json(); + const supabase = createClient(); + const honchoUserData = await getUserData(); + const { message, conversationId } = (await req.json()) as ThoughtCallProps; + + const { + data: { user: supabaseUser }, + } = await supabase.auth.getUser(); - const userData = await getUserData(); - if (!userData) { + if (!honchoUserData || !supabaseUser) { return new NextResponse('Unauthorized', { status: 401 }); } - const { appId, userId } = userData; + + const { canChat } = await getChatAccessWithUser(supabaseUser.id); + + if (!canChat) { + return new NextResponse('Subscription required', { status: 402 }); + } + + const { appId, userId } = honchoUserData; + + const messageIter = await honcho.apps.users.sessions.messages.list( + appId, + userId, + conversationId, + {} + ); + + const messageHistory = Array.from(messageIter.items); const thoughtIter = await honcho.apps.users.sessions.metamessages.list( appId, @@ -36,55 +56,60 @@ export async function POST(req: NextRequest) { conversationId, { metamessage_type: 'thought', - filter: { type: 'user' }, } ); - const thoughtHistory = thoughtIter.items.map( - (metamessage) => user`${metamessage.content}` - ); + const thoughtHistory = Array.from(thoughtIter.items); - const recentResponseMeta = await honcho.apps.users.sessions.metamessages.list( + const honchoIter = await honcho.apps.users.sessions.metamessages.list( appId, userId, conversationId, { - metamessage_type: 'response', - filter: { type: 'user' }, - reverse: true, - size: 1, + metamessage_type: 'honcho', } ); - const [recentResponse] = recentResponseMeta.items; - const content = recentResponse?.content ?? ''; - - const honchoResponse = - content.match(/([^]*?)<\/honcho>/)?.[1]?.trim() ?? 'None'; - const bloomResponse = - content.match(/([^]*?)<\/tutor>/)?.[1]?.trim() ?? 'None'; + const honchoHistory = Array.from(honchoIter.items); + + const history = messageHistory.map((message, i) => { + if (message.is_user) { + if (i == 0) { + return user`${message.content}`; + } + const lastUserMessage = messageHistory[i - 2]; + const honchoResponse = honchoHistory.find( + (h) => h.message_id === lastUserMessage.id + ); + const tutorResponse = messageHistory[i - 1]; + + return user`${honchoResponse?.content || 'None'} + ${tutorResponse?.content || 'None'} + ${message.content}`; + } else { + const lastUserMessage = messageHistory[i - 1]; + const thoughtResponse = thoughtHistory.find( + (t) => t.message_id === lastUserMessage.id + ); + return assistant`${thoughtResponse?.content || 'None'}`; + } + }); - const prompt: Message[] = [ - ...thoughtPrompt, - ...thoughtHistory, - { - role: 'user', - content: `${honchoResponse}\n${bloomResponse}\n${message}`, - }, - ]; + const finalMessage = user`${honchoHistory[honchoHistory.length - 1]?.content || 'None'} + ${messageHistory[messageHistory.length - 1]?.content || 'None'} + ${message}`; - const honchoPayload: HistoryWithoutResponse = { - appId, - userId, - sessionId: conversationId, - userInput: message, - }; + const prompt = [...thoughtPrompt, ...history, finalMessage]; console.log('Messages:\n'); console.log(prompt); console.log('\n\n\n'); - const stream = await createStream('thought', prompt, honchoPayload); + const stream = await createStream(prompt, { + sessionId: conversationId, + userId, + type: 'thought', + }); if (!stream) { throw new Error('Failed to get stream'); diff --git a/www/app/layout.tsx b/www/app/layout.tsx index ff662aa1..62e00472 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -7,6 +7,7 @@ import { Suspense } from 'react'; import { Header } from '@/components/header'; import { ThemeProvider } from 'next-themes'; import { SpeedInsights } from '@vercel/speed-insights/next'; +import { departureMono } from '@/utils/fonts'; const roboto = Roboto_Mono({ weight: '400', subsets: ['latin'] }); diff --git a/www/app/page.tsx b/www/app/page.tsx index 32512583..1de9ae02 100644 --- a/www/app/page.tsx +++ b/www/app/page.tsx @@ -1,8 +1,7 @@ -import { getSubscription } from '@/utils/supabase/queries'; +import { getChatAccessWithUser } from '@/utils/supabase/actions'; import { createClient } from '@/utils/supabase/server'; import { redirect } from 'next/navigation'; import Chat from './Chat'; -import { getFreeMessageCount } from '@/utils/supabase/actions'; import { getConversations } from './actions/conversations'; import { getMessages } from './actions/messages'; import { type Message } from '@/utils/types'; @@ -22,20 +21,16 @@ export default async function Home() { } // Get initial subscription state - let isSubscribed = false; - let freeMessages = 0; + const realChatAccess = await getChatAccessWithUser(user.id); + const isDevMode = process.env.NEXT_PUBLIC_STRIPE_ENABLED === 'false'; - if (process.env.NEXT_PUBLIC_STRIPE_ENABLED === 'false') { - isSubscribed = true; - } else { - const sub = await getSubscription(supabase); - // Only consider active paid subscriptions, not trials - isSubscribed = !!(sub && sub.status === 'active' && !sub.trial_end); - - if (!isSubscribed) { - freeMessages = await getFreeMessageCount(user.id); - } - } + const chatAccess = { + isSubscribed: isDevMode ? true : realChatAccess.isSubscribed, + freeMessages: realChatAccess.freeMessages, + canChat: isDevMode + ? true + : realChatAccess.isSubscribed || realChatAccess.freeMessages > 0, + }; // Get initial conversations const conversations = await getConversations(); @@ -45,7 +40,7 @@ export default async function Home() { let initialConversationId: string | undefined = undefined; if (conversations.length > 0) { initialConversationId = conversations[0].conversationId; - initialMessages = await getMessages(initialConversationId); + initialMessages = await getMessages(initialConversationId!); } return ( @@ -54,11 +49,10 @@ export default async function Home() { ); diff --git a/www/components/MessageList.tsx b/www/components/MessageList.tsx new file mode 100644 index 00000000..72567345 --- /dev/null +++ b/www/components/MessageList.tsx @@ -0,0 +1,158 @@ +'use client'; +import { useRef, useState, forwardRef, useImperativeHandle } from 'react'; +import { Message } from '@/utils/types'; +import MessageBox from '@/components/messagebox'; +import { getThought } from '@/app/actions/messages'; +import useAutoScroll from '@/hooks/autoscroll'; + +interface MessageListProps { + messages: Message[] | undefined; + defaultMessage: Message; + userId: string; + conversationId?: string; + messagesLoading: boolean; + handleReactionAdded: (messageId: string, reaction: any) => Promise; + setThoughtParent: (thought: string) => void; + setIsThoughtsOpen: ( + isOpen: boolean, + messageId?: string | null | undefined + ) => void; + openThoughtMessageId: string | null; +} + +export interface MessageListRef { + scrollToBottom: () => void; +} + +const MessageList = forwardRef( + ( + { + messages, + defaultMessage, + userId, + conversationId, + messagesLoading, + handleReactionAdded, + setThoughtParent, + setIsThoughtsOpen, + openThoughtMessageId, + }, + ref + ) => { + const [isThoughtLoading, setIsThoughtLoading] = useState(false); + const [thoughtError, setThoughtError] = useState<{ + messageId: string; + error: string; + } | null>(null); + + const messageContainerRef = useRef(null); + const [, scrollToBottom] = useAutoScroll(messageContainerRef); + + // Expose scrollToBottom method to parent + useImperativeHandle(ref, () => ({ + scrollToBottom: () => { + scrollToBottom(); + }, + })); + + const handleGetThought = async (messageId: string) => { + if (!conversationId || !userId) return; + + // Find the last user message before this AI message + const allMessages = [defaultMessage, ...(messages || [])]; + const messageIndex = allMessages.findIndex((msg) => msg.id === messageId); + + // Look backwards for the last user message + let lastUserMessageId = null; + for (let i = messageIndex; i >= 0; i--) { + if (allMessages[i].isUser) { + lastUserMessageId = allMessages[i].id; + break; + } + } + + try { + // Try with last user message first + if (lastUserMessageId) { + const thought = await getThought(conversationId, lastUserMessageId); + if (thought) { + setThoughtParent(thought); + setIsThoughtsOpen(true, messageId); + return; + } + } + + // If that didn't work, try with current AI message + const thought = await getThought(conversationId, messageId); + if (thought) { + setThoughtParent(thought); + setIsThoughtsOpen(true, messageId); + return; + } + + // If neither worked + setThoughtError({ messageId, error: 'No thought data available.' }); + console.log(messageId, 'No thought data available.'); + } catch (error) { + console.error('Failed to fetch thought:', error); + setThoughtError({ messageId, error: 'Failed to fetch thought.' }); + } finally { + setIsThoughtLoading(false); + } + }; + + return ( +
+ {messages ? ( + [defaultMessage, ...messages].map((message, i) => ( + + setIsThoughtsOpen(isOpen, message.id) + } + onReactionAdded={handleReactionAdded} + onGetThought={handleGetThought} + isThoughtLoading={ + isThoughtLoading && openThoughtMessageId === message.id + } + thoughtError={ + thoughtError?.messageId === message.id + ? thoughtError.error + : null + } + /> + )) + ) : ( + + )} +
+ ); + } +); + +MessageList.displayName = 'MessageList'; + +export default MessageList; diff --git a/www/components/auth/forgot.tsx b/www/components/auth/forgot.tsx index a210a4e4..8d3a8c75 100644 --- a/www/components/auth/forgot.tsx +++ b/www/components/auth/forgot.tsx @@ -19,6 +19,7 @@ export default function Forgot(props: any) { title: 'Error!', text: 'Something went wrong', icon: 'error', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); return; @@ -27,6 +28,7 @@ export default function Forgot(props: any) { title: 'Success!', text: 'Please check your email for a password reset link', icon: 'success', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); }; @@ -50,7 +52,7 @@ export default function Forgot(props: any) {
- - +
+
+ + + +
+ {thoughtError && ( +
{thoughtError}
+ )}
)} {error &&

Error: {error}

} diff --git a/www/components/settings/SettingsForm.tsx b/www/components/settings/SettingsForm.tsx index 7732b1b4..5be327cf 100644 --- a/www/components/settings/SettingsForm.tsx +++ b/www/components/settings/SettingsForm.tsx @@ -53,6 +53,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Success!', text: 'Please check your new email for a confirmation link', icon: 'success', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); } catch (error) { @@ -61,6 +62,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Error!', text: 'Something went wrong while updating your email', icon: 'error', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); } @@ -77,6 +79,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Success!', text: 'Your profile has been updated', icon: 'success', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); } catch (error) { @@ -85,6 +88,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Error!', text: 'Something went wrong while updating your profile', icon: 'error', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); } @@ -97,6 +101,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Error!', text: 'Passwords do not match', icon: 'error', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); return; @@ -110,6 +115,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Success!', text: 'Your password has been updated', icon: 'success', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); setNewPassword(''); @@ -120,6 +126,7 @@ export function SettingsForm({ user, type }: SettingsFormProps) { title: 'Error!', text: 'Something went wrong while updating your password', icon: 'error', + confirmButtonColor: '#3085d6', confirmButtonText: 'Close', }); } diff --git a/www/components/sidebar.tsx b/www/components/sidebar.tsx index 0702e64e..7a05f32f 100644 --- a/www/components/sidebar.tsx +++ b/www/components/sidebar.tsx @@ -1,7 +1,7 @@ +import React from 'react'; import { GrClose } from 'react-icons/gr'; import { createClient } from '@/utils/supabase/client'; import { useRouter } from 'next/navigation'; -import localFont from 'next/font/local'; import { usePostHog } from 'posthog-js/react'; import Swal from 'sweetalert2'; @@ -16,10 +16,9 @@ import { } from '@/app/actions/conversations'; import { type Conversation, type Message } from '@/utils/types'; import { clearSWRCache } from '@/utils/swrCache'; +import { departureMono } from '@/utils/fonts'; + -const departureMono = localFont({ - src: '../fonts/DepartureMono-Regular.woff2', -}); export default function Sidebar({ conversations, @@ -28,7 +27,7 @@ export default function Sidebar({ setConversationId, isSidebarOpen, toggleSidebar, - isSubscribed, + canUseApp, }: { conversations: Conversation[]; mutateConversations: KeyedMutator; @@ -36,7 +35,7 @@ export default function Sidebar({ setConversationId: (id: typeof conversationId) => void; isSidebarOpen: boolean; toggleSidebar: () => void; - isSubscribed: boolean; + canUseApp: boolean; }) { const postHog = usePostHog(); const supabase = createClient(); @@ -178,7 +177,7 @@ export default function Sidebar({ diff --git a/www/next.config.mjs b/www/next.config.mjs index bdd7e3d2..a2bf93e8 100644 --- a/www/next.config.mjs +++ b/www/next.config.mjs @@ -1,5 +1,113 @@ import { withSentryConfig } from "@sentry/nextjs"; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +// Generate CSP directives based on environment +const getCSPDirectives = () => { + const directives = [ + // Base policies + "default-src 'self'", + // Script handling + isDevelopment + ? "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:54321 https://va.vercel-scripts.com https://*.posthog.com https://vercel.live https://js.stripe.com https://checkout.stripe.com" + : "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.supabase.co https://va.vercel-scripts.com https://*.posthog.com https://vercel.live https://js.stripe.com https://checkout.stripe.com", + // Style handling + "style-src 'self' 'unsafe-inline'", + // Images and media + "img-src 'self' data: blob: https:", + // Fonts + "font-src 'self' data:", + // Worker handling + "worker-src 'self' blob:", + // Frame sources + "frame-src 'self' https://vercel.live https://js.stripe.com https://hooks.stripe.com https://checkout.stripe.com https://connect.stripe.com", + // Supabase connectivity + isDevelopment + ? "connect-src 'self' http://127.0.0.1:54321 https://vitals.vercel-insights.com https://*.posthog.com https://vercel.live https://js.stripe.com https://checkout.stripe.com" + : "connect-src 'self' https://*.supabase.co https://*.supabase.net https://vitals.vercel-insights.com https://*.posthog.com https://vercel.live https://js.stripe.com https://checkout.stripe.com", + // Frame security + "frame-ancestors 'none'", + // Form submissions + "form-action 'self'", + // Base URI restriction + "base-uri 'self'", + // Only include upgrade-insecure-requests in production + ...(isDevelopment ? [] : ["upgrade-insecure-requests"]), + // Block mixed content + "block-all-mixed-content" + ]; + + return directives.join('; '); +}; + const nextConfig = { + // Enables strict mode for enhanced security + reactStrictMode: true, + + // Disable x-powered-by header to prevent information disclosure + poweredByHeader: false, + + headers: () => { + return [ + { + source: "/:path*", + headers: [ + { + key: 'Content-Security-Policy', + value: getCSPDirectives() + }, + // Strict Transport Security + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload' + }, + // Prevent clickjacking + { + key: 'X-Frame-Options', + value: 'DENY' + }, + // Prevent MIME type sniffing + { + key: 'X-Content-Type-Options', + value: 'nosniff' + }, + // XSS Protection as fallback + { + key: 'X-XSS-Protection', + value: '1; mode=block' + }, + // Referrer Policy + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin' + }, + // Permissions Policy (formerly Feature-Policy) + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' + } + + ], + }, + // Additional headers for API routes + { + source: '/api/:path*', + headers: [ + // Prevent caching of API responses + { + key: 'Cache-Control', + value: 'no-store, max-age=0, must-revalidate' + }, + // Ensure API responses aren't cached + { + key: 'Pragma', + value: 'no-cache' + }, + ] + } + ]; + }, + output: "standalone", experimental: { instrumentationHook: true, diff --git a/www/public/bloom_icon_large.jpg b/www/public/bloom_icon_large.jpg new file mode 100644 index 00000000..4b997aee Binary files /dev/null and b/www/public/bloom_icon_large.jpg differ diff --git a/www/utils/ai.ts b/www/utils/ai.ts index 5fd5a7d8..2092896e 100644 --- a/www/utils/ai.ts +++ b/www/utils/ai.ts @@ -32,95 +32,95 @@ const openrouter = createOpenRouter({ }, }); -export interface HistoryWithoutResponse { - appId: string; - userId: string; - sessionId: string; - userInput: string; - thought?: string; - honchoContent?: string; -} - -type History = HistoryWithoutResponse & { - aiResponse: string; -}; - -async function saveHistory({ - appId, - userId, - sessionId, - userInput, - thought, - honchoContent, - aiResponse, -}: History) { - try { - // Create user message - const newUserMessage = await honcho.apps.users.sessions.messages.create( - appId, - userId, - sessionId, - { - is_user: true, - content: userInput, - } - ); - - // Save thought metamessage for user message - const thoughtMetamessage = `${honchoContent}\n${aiResponse}\n${userInput}`; - await honcho.apps.users.sessions.metamessages.create( - appId, - userId, - sessionId, - { - message_id: newUserMessage.id, - metamessage_type: 'thought', - content: thoughtMetamessage, - metadata: { type: 'user' }, - } - ); - - // Create AI message - const newAiMessage = await honcho.apps.users.sessions.messages.create( - appId, - userId, - sessionId, - { - is_user: false, - content: aiResponse, - } - ); - - // Save thought metamessage for AI message - await honcho.apps.users.sessions.metamessages.create( - appId, - userId, - sessionId, - { - content: thought || '', - message_id: newAiMessage.id, - metamessage_type: 'thought', - metadata: { type: 'assistant' }, - } - ); - - // Save response metamessage - const responseMetamessage = `${honchoContent}\n${userInput}`; - await honcho.apps.users.sessions.metamessages.create( - appId, - userId, - sessionId, - { - message_id: newAiMessage.id, - metamessage_type: 'response', - content: responseMetamessage, - } - ); - } catch (error) { - Sentry.captureException(error); - throw error; // Re-throw to be handled by caller - } -} +// export interface HistoryWithoutResponse { +// appId: string; +// userId: string; +// sessionId: string; +// userInput: string; +// thought?: string; +// honchoContent?: string; +// } + +// type History = HistoryWithoutResponse & { +// aiResponse: string; +// }; + +// async function saveHistory({ +// appId, +// userId, +// sessionId, +// userInput, +// thought, +// honchoContent, +// aiResponse, +// }: History) { +// try { +// // Create user message +// const newUserMessage = await honcho.apps.users.sessions.messages.create( +// appId, +// userId, +// sessionId, +// { +// is_user: true, +// content: userInput, +// } +// ); + +// // Save thought metamessage for user message +// const thoughtMetamessage = `${honchoContent}\n${aiResponse}\n${userInput}`; +// await honcho.apps.users.sessions.metamessages.create( +// appId, +// userId, +// sessionId, +// { +// message_id: newUserMessage.id, +// metamessage_type: 'thought', +// content: thoughtMetamessage, +// metadata: { type: 'user' }, +// } +// ); + +// // Create AI message +// const newAiMessage = await honcho.apps.users.sessions.messages.create( +// appId, +// userId, +// sessionId, +// { +// is_user: false, +// content: aiResponse, +// } +// ); + +// // Save thought metamessage for AI message +// await honcho.apps.users.sessions.metamessages.create( +// appId, +// userId, +// sessionId, +// { +// content: thought || '', +// message_id: newAiMessage.id, +// metamessage_type: 'thought', +// metadata: { type: 'assistant' }, +// } +// ); + +// // Save response metamessage +// const responseMetamessage = `${honchoContent}\n${userInput}`; +// await honcho.apps.users.sessions.metamessages.create( +// appId, +// userId, +// sessionId, +// { +// message_id: newAiMessage.id, +// metamessage_type: 'response', +// content: responseMetamessage, +// } +// ); +// } catch (error) { +// Sentry.captureException(error); +// throw error; // Re-throw to be handled by caller +// } +// } export async function getUserData() { const supabase = createClient(); @@ -165,31 +165,28 @@ export const assistant = ( // role: 'system', // content: d(strings, ...values), // }); - export async function createStream( - type: string, messages: Message[], - payload: HistoryWithoutResponse + metadata: { + sessionId: string; + userId: string; + type: string; + }, + onFinish?: (response: { text: string }) => Promise ) { try { const result = streamText({ model: openrouter(MODEL), messages, - onFinish: async (response) => { - if (type === 'response') { - const aiResponse = response.text; - const finalPayload = { ...payload, aiResponse }; - await saveHistory(finalPayload); - } - }, + ...(onFinish && { onFinish }), experimental_telemetry: { isEnabled: true, metadata: { - sessionId: payload.sessionId, - userId: payload.userId, + sessionId: metadata.sessionId, + userId: metadata.userId, release: SENTRY_RELEASE, environment: SENTRY_ENVIRONMENT, - tags: [type], + tags: [metadata.type], }, }, }); diff --git a/www/utils/fonts.ts b/www/utils/fonts.ts new file mode 100644 index 00000000..6c6b4057 --- /dev/null +++ b/www/utils/fonts.ts @@ -0,0 +1,5 @@ +import localFont from 'next/font/local'; + +export const departureMono = localFont({ + src: '../fonts/DepartureMono-Regular.woff2', +}); \ No newline at end of file diff --git a/www/utils/supabase/actions.ts b/www/utils/supabase/actions.ts index 6f88e8ff..dc0c0484 100644 --- a/www/utils/supabase/actions.ts +++ b/www/utils/supabase/actions.ts @@ -1,13 +1,28 @@ 'use server'; // This directive marks all exports as server actions -import { - createOrRetrieveFreeTrialSubscription, - decrementFreeMessages, -} from '@/utils/supabase/admin'; +import { createFreeTrialSubscription, decrementFreeMessages } from './admin'; +import { createClient } from './server'; +import { getChatAccess, getSubscription } from './queries'; + +export async function getChatAccessWithUser(userId: string) { + const subscription = await createOrRetrieveFreeTrialSubscription(userId); + return getChatAccess(subscription); +} + +export async function createOrRetrieveFreeTrialSubscription(userId: string) { + const supabase = createClient(); + const existingSub = await getSubscription(supabase); + if (existingSub) return existingSub; + + // If no subscription exists, create one with admin privileges + return createFreeTrialSubscription(userId); +} export async function getFreeMessageCount(userId: string) { const subscription = await createOrRetrieveFreeTrialSubscription(userId); - return (subscription.metadata as { freeMessages: number })?.freeMessages ?? 0; + return ( + (subscription?.metadata as { freeMessages: number })?.freeMessages ?? 0 + ); } export async function useFreeTrial(userId: string) { diff --git a/www/utils/supabase/admin.ts b/www/utils/supabase/admin.ts index 5dbe77b6..7ac7b093 100644 --- a/www/utils/supabase/admin.ts +++ b/www/utils/supabase/admin.ts @@ -22,29 +22,13 @@ const supabaseAdmin = createClient( process.env.SUPABASE_SERVICE_ROLE_KEY || '' ); -// Trial membership -const createOrRetrieveFreeTrialSubscription = async (userId: string) => { - // Check for existing trial subscription - const { data: existingSub, error: subError } = await supabaseAdmin - .from('subscriptions') - .select('*') - .eq('user_id', userId) - .eq('status', 'trialing') - .maybeSingle(); - - if (subError) - throw new Error(`Subscription lookup failed: ${subError.message}`); - - // If trial subscription exists, return it - if (existingSub) return existingSub; - - // Create a new trial subscription +const createFreeTrialSubscription = async (userId: string) => { const subscriptionData: TablesInsert<'subscriptions'> = { id: `free_trial_${userId}`, user_id: userId, status: 'trialing', metadata: { freeMessages: FREE_MESSAGE_LIMIT }, - price_id: null, // or your free tier price ID if you have one + price_id: null, quantity: 1, cancel_at_period_end: false, created: new Date().toISOString(), @@ -63,15 +47,15 @@ const createOrRetrieveFreeTrialSubscription = async (userId: string) => { .from('subscriptions') .insert([subscriptionData]); - if (insertError) + if (insertError) { throw new Error( `Trial subscription creation failed: ${insertError.message}` ); + } return subscriptionData; }; -// Add this function to decrement free messages const decrementFreeMessages = async (userId: string) => { const { data: subscription, error: subError } = await supabaseAdmin .from('subscriptions') @@ -384,7 +368,7 @@ export { deletePriceRecord, createOrRetrieveCustomer, manageSubscriptionStatusChange, - createOrRetrieveFreeTrialSubscription, + createFreeTrialSubscription, decrementFreeMessages, FREE_MESSAGE_LIMIT, }; diff --git a/www/utils/supabase/queries.ts b/www/utils/supabase/queries.ts index 588b5b31..342edea3 100644 --- a/www/utils/supabase/queries.ts +++ b/www/utils/supabase/queries.ts @@ -1,11 +1,37 @@ import { cache } from 'react'; import { SupabaseClient } from '@supabase/supabase-js'; +import { unstable_cache } from '../unstableCache'; + +export const getChatAccess = unstable_cache( + async (subscription) => { + // Rest of the function remains the same + const isSubscribed = + subscription?.status === 'active' && !subscription.cancel_at_period_end; + + const isTrialing = subscription?.status === 'trialing'; + const trialEnded = subscription?.trial_end + ? new Date(subscription.trial_end) < new Date() + : false; + + const freeMessages = + isTrialing && !trialEnded + ? ((subscription?.metadata as { freeMessages: number })?.freeMessages ?? + 0) + : 0; + return { + isSubscribed, + freeMessages, + canChat: isSubscribed || freeMessages > 0, + }; + }, + ['chat-access'], + { revalidate: 60 } +); export const getSubscription = cache(async (supabase: SupabaseClient) => { const { data: subscription, error } = await supabase .from('subscriptions') .select('*, prices(*, products(*))') - .in('status', ['trialing', 'active']) .maybeSingle(); return subscription; diff --git a/www/utils/unstableCache.ts b/www/utils/unstableCache.ts new file mode 100644 index 00000000..3509d797 --- /dev/null +++ b/www/utils/unstableCache.ts @@ -0,0 +1,16 @@ +import { cache } from 'react'; +import { unstable_cache as next_unstable_cache } from 'next/cache'; + +export const unstable_cache = ( + callback: (...args: Args) => Promise, + key: string[], + options: { revalidate: number } +) => { + return cache( + next_unstable_cache( + callback as unknown as (...args: any[]) => Promise, + key, + options + ) + ); +};