├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .stylelintrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── api │ ├── auth │ │ └── callback │ │ │ └── route.ts │ └── sitemap │ │ └── route.ts ├── apple-icon.png ├── auth │ └── auth-code-error │ │ └── page.tsx ├── conversations │ └── [conversation_id] │ │ ├── bonus-score-pane.tsx │ │ ├── chat.tsx │ │ ├── goal-pane.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── microphone.tsx │ │ ├── page.tsx │ │ ├── scenario-pane.tsx │ │ ├── scenario-provider.tsx │ │ ├── services │ │ ├── fetch-completed-goals.ts │ │ ├── fetch-conversation.ts │ │ ├── fetch-goals.ts │ │ ├── fetch-llm-role.ts │ │ ├── fetch-scenario.ts │ │ ├── fetch-target-words.ts │ │ ├── gemini │ │ │ ├── check-goal-completions.ts │ │ │ ├── get-initial-history.ts │ │ │ └── send-messages-to-llm.ts │ │ ├── openai │ │ │ ├── check-goal-completions.ts │ │ │ ├── get-evaluation-from-ai.ts │ │ │ ├── get-initial-history.ts │ │ │ └── send-messages-to-llm.ts │ │ ├── save-completed-goal.ts │ │ ├── save-completed-target-word.ts │ │ ├── save-conversation-dialog.ts │ │ ├── save-evaluation.ts │ │ └── update-conversation.ts │ │ ├── target-words-pane.tsx │ │ ├── turns-left-pane.tsx │ │ ├── use-microphone-permission.ts │ │ └── utils │ │ ├── get-initial-llm-prompt.ts │ │ └── get-matched-targets.ts ├── error.tsx ├── evaluations │ └── [evaluation_id] │ │ ├── copy-link-icon.tsx │ │ ├── copy-link.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── scenario-background-loader.tsx │ │ └── services │ │ └── fetch-evaluation.ts ├── favicon.ico ├── globals.css ├── header.tsx ├── home-link.tsx ├── icon1.png ├── icon2.png ├── icon3.png ├── icon4.png ├── layout.tsx ├── loading.tsx ├── opengraph-image.alt.txt ├── opengraph-image.jpg ├── page.tsx ├── profile │ ├── conversation-link.tsx │ ├── conversations-card.tsx │ ├── danger-zone.tsx │ ├── evaluation-link.tsx │ ├── evaluations-card.tsx │ ├── page.tsx │ ├── services │ │ ├── delete-user.ts │ │ ├── fetch-user-conversations.ts │ │ ├── fetch-user.ts │ │ ├── link-with-google.ts │ │ └── sign-out-user.ts │ └── signout.tsx ├── robots.ts ├── scenarios │ ├── category-pane.tsx │ ├── mobile-category-drawer.tsx │ ├── page.tsx │ ├── scenario-background-provider.tsx │ ├── scenario-background.tsx │ ├── scenario-grid.tsx │ └── services │ │ ├── create-conversation.ts │ │ └── fetch-scenarios.ts ├── shared-metadata.ts ├── signin │ ├── anonymous-signin-button.tsx │ ├── google-oauth-button.tsx │ ├── page.tsx │ └── services │ │ ├── signin-anonymously.ts │ │ └── signin-google.ts ├── sitemap.ts ├── theme-provider.tsx ├── theme-switch-holder.tsx ├── twitter-image.alt.txt ├── twitter-image.jpg └── user-signin-portal-holder.tsx ├── components.json ├── components ├── animated-button-with-transition.tsx ├── animated-text.tsx ├── chat-bubble │ ├── chat-bubble.tsx │ └── services │ │ └── text-to-speech.ts ├── fetch-auth-user.ts ├── hover-perspective-container.tsx ├── label-with-padded-digits.tsx ├── pane-group-drawer.tsx ├── theme-switch.tsx ├── ui │ ├── alert-dialog.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── sonner.tsx │ └── tooltip.tsx └── user-signin-portal.tsx ├── lib ├── ai │ ├── gemini.ts │ └── openai.ts ├── auth-error.ts ├── convo-error.ts ├── convo-loader.tsx ├── copy-to-clipboard.tsx ├── global.d.ts ├── supabase │ ├── client.ts │ ├── middleware.ts │ └── server.ts ├── use-media-query.tsx └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── manifest.json ├── og │ └── title-dark.jpg └── twitter-image.jpg ├── supabase ├── .temp │ └── cli-latest └── database.types.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "plugins": ["react", "simple-import-sort", "unused-imports"], 4 | "rules": { 5 | // increase the severity of rules so they are auto-fixable 6 | "simple-import-sort/imports": "error", 7 | "simple-import-sort/exports": "error", 8 | "unused-imports/no-unused-imports": "error", 9 | "unused-imports/no-unused-vars": [ 10 | "warn", 11 | { 12 | "vars": "all", 13 | "varsIgnorePattern": "^_", 14 | "args": "after-used", 15 | "argsIgnorePattern": "^_" 16 | } 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | # Auto Generated PWA files 3 | **/public/sw.js 4 | **/public/workbox-*.js 5 | **/public/worker-*.js 6 | **/public/sw.js.map 7 | **/public/workbox-*.js.map 8 | **/public/worker-*.js.map 9 | 10 | # dependencies 11 | /node_modules 12 | /.pnp 13 | .pnp.js 14 | 15 | # testing 16 | /coverage 17 | 18 | # next.js 19 | /.next/ 20 | /out/ 21 | 22 | # production 23 | /build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # local env files 35 | .env*.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | 44 | .env 45 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/build 2 | **/coverage 3 | 4 | *.html 5 | **/.git 6 | **/node_modules 7 | 8 | **/package-lock.json 9 | 10 | **/.husky/** 11 | **/.prettierignore 12 | 13 | **/playwright-report/** 14 | **/public/** 15 | **/test-results/** 16 | 17 | **/.gitignore 18 | **/.github/** 19 | **/.vscode/** 20 | **/.swc/** 21 | **/.next/** 22 | **/.vercel/** 23 | 24 | **/LICENSE 25 | **/*.png 26 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "plugins": ["prettier-plugin-tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | "ignoreAtRules": [ 8 | "tailwind", 9 | "apply", 10 | "variants", 11 | "responsive", 12 | "screen" 13 | ] 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": "always" 5 | } 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Li Yuxuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convo AI 2 | 3 | [Convo AI](https://www.convo-ai.cc) helps you practice English conversation with real-world scenarios. It is a gamified experience where you will role play with AI counter-part to steer the conversation towards a set of end goals. 4 | 5 | [![convo youtube cover](https://i.imgur.com/ASSQyPj.jpeg)](https://youtu.be/TqE_mjJPd08?si=Y0gVr7Caj7og_Ncp) 6 | 7 | # Introduction 8 | 9 | Welcome to **Convo AI**, where **language learning** becomes an immersive experience through **real-world conversations**. Chat with an **intelligent AI** to hit targets, score points and get constructive feedback. All in the name of having fun while learning! We offer a dynamic series of practical conversational scenarios that vary in settings, styles and purposes. Whether you are a student preparing for a presentation, a professional heading for an interview, or you simply want to go about the daily norms, there is something for you. 10 | 11 | Our innovative platform uses state of the art technology to ensure the best of experiences. The user interface is versatile, optimised for both mobile and desktop viewing, and supports dark/light modes. Join us today and get chatting about anything, anywhere, anytime! 12 | 13 | # Key features 14 | 15 | ## Immersive Scenario-Based Learning 16 | Explore a wide range of real-world scenarios. From the classroom to the restaurent, casual to formal, presentation-based to dialogue-based. 17 | 18 | ## Intelligent AI Role-Play 19 | Have lifelike conversations with our smart AI. It responds just like a real person, making your learning experience natural and engaging. 20 | 21 | ## Advanced Speech Recognition and Synthesis 22 | Speak to Convo's AI and hear its responses instantly. It's like having a real conversation, but with technology doing the heavy lifting. 23 | 24 | ## Personalised Feedback and Evaluation 25 | Get helpful tips from Convo's AI on how to improve your language skills. It gives feedback on areas like grammar and word choice, so you can become a better speaker. 26 | 27 | ## Anonymous Sign-in and Conversation Saving 28 | Sign in without sharing personal details and save your conversations for later. You can always pick up right where you left off. 29 | 30 | ## Convenient Google OAuth Sign-in 31 | Sign in easily with your Google account. No need to remember another password – just use your existing Google credentials. 32 | 33 | ## Light and Dark Mode Support 34 | Choose between light and dark mode to suit your preferences. Whether you like a bright interface or something easier on the eyes, Convo has you covered. 35 | 36 | ## Responsive UI 37 | Our user interface works seamlessly on both mobile and desktop, so we can keep the conversation on the go wherever you are. 38 | 39 | ## Classic Minimalistic Elegant UI Design 40 | Enjoy Convo's simple and stylish design. It's easy to use and looks great, making your learning experience enjoyable and distraction-free. 41 | 42 | # Tech stacks used 43 | 44 | - **Supabase**: A powerful open-source alternative to Firebase for database management. 45 | - **Next.js**: Utilized the Next.js 14 app router for server-side rendering and optimized routing. 46 | - **TailwindCSS**: Leveraged TailwindCSS for rapid UI development with utility-first CSS classes. 47 | - **Shadcn UI**: Integrated Shadcn UI for sleek and customizable user interface components. 48 | - **Vercel**: Deployed the application on Vercel for seamless hosting and continuous deployment. 49 | - **OpenAI**: Utilized OpenAI for completion and text-to-speech functionalities, enhancing user interaction. 50 | - **react-speech-recognition**: Integrated react-speech-recognition for accurate and responsive speech-to-text capabilities. 51 | - **Framer Motion**: Incorporated Framer Motion for smooth and engaging animations, enhancing the user experience. 52 | 53 | # Supabase features used 54 | 55 | - **Database**: Use supabase database with RLS policies to manage users, conversations, etc. data, and set up triggers and cron job. 56 | - **Auth**: Use OAuth google as a sign-in method. Also use the new anonymous sign-in feature announced. 57 | 58 | # Video demo on YouTube 59 | 60 | https://youtu.be/FYme8S0DnWw 61 | -------------------------------------------------------------------------------- /app/api/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { type CookieOptions, createServerClient } from '@supabase/ssr'; 2 | import { cookies } from 'next/headers'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams, origin } = new URL(request.url); 7 | const code = searchParams.get('code'); 8 | // if "next" is in param, use it as the redirect URL 9 | const next = searchParams.get('next') ?? '/'; 10 | 11 | if (code) { 12 | const cookieStore = cookies(); 13 | const supabase = createServerClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 16 | { 17 | cookies: { 18 | get(name: string) { 19 | return cookieStore.get(name)?.value; 20 | }, 21 | set(name: string, value: string, options: CookieOptions) { 22 | cookieStore.set({ name, value, ...options }); 23 | }, 24 | remove(name: string, options: CookieOptions) { 25 | cookieStore.delete({ name, ...options }); 26 | }, 27 | }, 28 | } 29 | ); 30 | const { error } = await supabase.auth.exchangeCodeForSession(code); 31 | if (!error) { 32 | return NextResponse.redirect(`${origin}${next}`); 33 | } 34 | } 35 | 36 | // return the user to an error page with instructions 37 | return NextResponse.redirect(`${origin}/auth/auth-code-error`); 38 | } 39 | -------------------------------------------------------------------------------- /app/api/sitemap/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from 'next/server'; 2 | 3 | import { fetchScenarios } from '@/app/scenarios/services/fetch-scenarios'; 4 | 5 | const generateTxtSitemap = async (): Promise => { 6 | let sitemap = ''; 7 | 8 | // Static routes 9 | ['', 'scenarios', 'profile', 'signin'].forEach((route) => { 10 | sitemap += `https://www.convo-ai.cc/${route}\n`; 11 | }); 12 | 13 | // Dynamic routes 14 | const { scenarios } = await fetchScenarios(); 15 | const categories = scenarios.reduce((acc, scenario) => { 16 | scenario.categories.forEach((category) => { 17 | if (!acc.includes(category)) { 18 | acc.push(category); 19 | } 20 | }); 21 | return acc; 22 | }, []); 23 | categories.forEach( 24 | (category) => 25 | // URL encoded category 26 | (sitemap += `https://www.convo-ai.cc/scenarios?category=${encodeURIComponent(category)}\n`) 27 | ); 28 | return sitemap; 29 | }; 30 | 31 | export async function GET(request: NextRequest) { 32 | if (request.method !== 'GET') { 33 | return new Response('Method Not Allowed', { status: 405 }); 34 | } 35 | const { searchParams } = new URL(request.url); 36 | const fileType = searchParams.get('type') ?? 'txt'; 37 | switch (fileType) { 38 | case 'txt': 39 | try { 40 | const content = await generateTxtSitemap(); 41 | return new Response(content, { 42 | headers: { 'Content-Type': 'text/plain' }, 43 | }); 44 | } catch (error) { 45 | console.error(error); 46 | return new Response('Internal Server Error', { status: 500 }); 47 | } 48 | default: 49 | break; 50 | } 51 | return new Response('sitemap file type not recognised', { status: 400 }); 52 | } 53 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/apple-icon.png -------------------------------------------------------------------------------- /app/auth/auth-code-error/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | 3 | import { openGraph } from '@/app/shared-metadata'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Convo AI | Auth Error', 7 | openGraph: { 8 | ...openGraph, 9 | url: '/auth/auth-code-error', 10 | title: 'Convo AI | Error', 11 | }, 12 | alternates: { 13 | canonical: 'https://www.convo-ai.cc/auth/auth-code-error', 14 | }, 15 | }; 16 | 17 | export default function Page() { 18 | return ( 19 |
20 |
21 |

Authentication failed

22 |

Failed to exchange code.

23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/bonus-score-pane.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | 6 | import { Card, CardContent } from '@/components/ui/card'; 7 | import { cn } from '@/lib/utils'; 8 | 9 | import { useScenario } from './scenario-provider'; 10 | 11 | export function BonusScorePane() { 12 | const { goals, targetWords, completedGoalIds, completedWords } = 13 | useScenario(); 14 | const [animate, setAnimate] = useState(false); 15 | const currentScore = 16 | goals 17 | .filter((goal) => completedGoalIds.includes(goal.id)) 18 | .reduce((acc, goal) => acc + goal.points, 0) + 19 | targetWords.words 20 | .filter((word) => completedWords.includes(word)) 21 | .reduce((acc, _) => acc + 1, 0); 22 | const previousScore = useRef(currentScore); 23 | 24 | useEffect(() => { 25 | if (previousScore.current !== currentScore) { 26 | setAnimate((prev) => !prev); 27 | previousScore.current = currentScore; 28 | } 29 | }, [currentScore]); 30 | 31 | if (!goals || !targetWords) return null; 32 | 33 | return ( 34 | 40 | 41 |
42 |
Your bonus:
43 | 44 |
span]:flex [&>span]:items-center [&>span]:justify-center [&>span]:text-center', 48 | 'rounded-sm bg-card/20 shadow-[inset_0_0px_5px_1px_#00000020]' 49 | )} 50 | > 51 | {animate ? ( 52 | 59 | {currentScore.toString().padStart(3, '0')} 60 | 61 | ) : ( 62 | 69 | {currentScore.toString().padStart(3, '0')} 70 | 71 | )} 72 |
73 |
74 |
75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/layout.tsx: -------------------------------------------------------------------------------- 1 | type LayoutProps = { 2 | children: React.ReactNode; 3 | }; 4 | 5 | export default async function Layout(props: LayoutProps) { 6 | return <>{props.children}; 7 | } 8 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { ConvoLoader } from '@/lib/convo-loader'; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/microphone.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MicrophoneSlash, MicrophoneStage } from '@phosphor-icons/react'; 4 | import { motion, Variants } from 'framer-motion'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | type MicrophoneProps = { 9 | disabled?: boolean; 10 | isRecording: boolean; 11 | onStartRecording: () => void; 12 | onStopRecording: () => void; 13 | }; 14 | 15 | /** 16 | * Controllable microphone button. 17 | */ 18 | export function Microphone(props: MicrophoneProps) { 19 | const microphoneVariants: Variants = { 20 | initial: { 21 | rotate: 0, 22 | }, 23 | hover: { 24 | rotate: [0, -15, 15, 0], 25 | transition: { 26 | duration: 0.5, 27 | repeat: Infinity, 28 | }, 29 | }, 30 | }; 31 | return ( 32 | { 36 | if (props.isRecording) { 37 | props.onStopRecording(); 38 | } else { 39 | props.onStartRecording(); 40 | } 41 | }} 42 | className={cn( 43 | 'absolute bottom-1 right-1 top-1 z-20 grid size-8 place-items-center rounded-sm p-2 transition-[background-color_color] duration-200', 44 | props.isRecording ? 'bg-destructive/80 !text-white' : 'bg-card/80' 45 | )} 46 | initial='initial' 47 | whileHover='hover' 48 | animate={props.isRecording ? 'recording' : 'visible'} 49 | exit={{ 50 | opacity: 0, 51 | y: 10, 52 | }} 53 | variants={{ 54 | initial: { 55 | opacity: 0, 56 | y: 10, 57 | }, 58 | visible: { 59 | opacity: 1, 60 | scale: 1, 61 | rotate: 0, 62 | y: 0, 63 | }, 64 | hover: { 65 | opacity: 1, 66 | }, 67 | recording: { 68 | y: 0, 69 | opacity: [1, 0.5, 1], 70 | scale: [1, 0.7, 1], 71 | transition: { 72 | duration: 1, 73 | repeat: Infinity, 74 | }, 75 | }, 76 | }} 77 | > 78 | 82 | {props.isRecording ? : } 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { fetchUser } from '@/app/profile/services/fetch-user'; 5 | import { ScenarioBackground } from '@/app/scenarios/scenario-background'; 6 | import { ScenarioBackgroundProvider } from '@/app/scenarios/scenario-background-provider'; 7 | import { openGraph } from '@/app/shared-metadata'; 8 | 9 | import { BonusScorePane } from './bonus-score-pane'; 10 | import { Chat } from './chat'; 11 | import { GoalPane } from './goal-pane'; 12 | import { ScenarioPane } from './scenario-pane'; 13 | import { Chat as ChatType, ScenarioProvider } from './scenario-provider'; 14 | import { fetchConversation } from './services/fetch-conversation'; 15 | import { getInitialHistory } from './services/openai/get-initial-history'; 16 | import { saveConversationDialog } from './services/save-conversation-dialog'; 17 | import { TargetWordsPane } from './target-words-pane'; 18 | import { TurnsLeftPane } from './turns-left-pane'; 19 | import { getInitialLlmPrompt } from './utils/get-initial-llm-prompt'; 20 | 21 | export const maxDuration = 60; // Max duration is 1 minute. 22 | 23 | export async function generateMetadata(props: { 24 | params: { conversation_id: string }; 25 | }): Promise { 26 | const { conversation } = await fetchConversation({ 27 | conversationId: props.params.conversation_id, 28 | }); 29 | 30 | return { 31 | title: `Convo AI | ${conversation.scenario.name}`, 32 | openGraph: { 33 | ...openGraph, 34 | url: `/conversations/${props.params.conversation_id}`, 35 | title: `Convo AI | ${conversation.scenario.name}`, 36 | }, 37 | alternates: { 38 | canonical: `https://www.convo-ai.cc/conversations/${props.params.conversation_id}`, 39 | }, 40 | }; 41 | } 42 | 43 | type PageProps = { 44 | params: { 45 | conversation_id: string; 46 | }; 47 | }; 48 | 49 | export default async function Page(props: PageProps) { 50 | const { user } = await fetchUser(); 51 | const { conversation } = await fetchConversation({ 52 | conversationId: props.params.conversation_id, 53 | }); 54 | if (!conversation) return redirect('/scenarios'); 55 | 56 | const scenario = conversation.scenario; 57 | const llmRole = scenario.llm_role; 58 | const targetWords = scenario.target_words; 59 | const goals = scenario.goals; 60 | 61 | if (targetWords === null) 62 | throw new Error(`Scenraio ${conversation.scenario.id} has no target words`); 63 | 64 | const hasConversationDialogHistory = 65 | conversation.conversation_dialogs.length > 0; 66 | 67 | const history: ChatType[] = []; 68 | if (!hasConversationDialogHistory) { 69 | const initialHistory = await getInitialHistory({ 70 | llmRole, 71 | scenario, 72 | }); 73 | 74 | const firstModelMessage = initialHistory.at(-1); 75 | if (firstModelMessage) { 76 | saveConversationDialog({ 77 | conversationId: conversation.id, 78 | chat: firstModelMessage, 79 | }); 80 | } 81 | 82 | history.push(...initialHistory); 83 | } else { 84 | const initialLlmPrompt = getInitialLlmPrompt({ 85 | llmRole, 86 | scenario, 87 | }); 88 | history.push({ 89 | role: 'user', 90 | message: initialLlmPrompt, 91 | createdAt: new Date().toISOString(), 92 | }); 93 | conversation.conversation_dialogs.forEach((dialog) => { 94 | if (dialog.message) { 95 | history.push({ 96 | role: dialog.role, 97 | message: dialog.message, 98 | createdAt: dialog.timestamp, 99 | }); 100 | } else { 101 | history.push({ 102 | role: 'error', 103 | message: 'Sorry, we could not find the message.', 104 | createdAt: dialog.timestamp, 105 | }); 106 | } 107 | }); 108 | } 109 | 110 | return ( 111 | 112 | ({ ...goal, completed: false }))} 114 | llmRole={llmRole} 115 | scenario={scenario} 116 | targetWords={targetWords} 117 | history={history} 118 | > 119 |
120 |
121 |
122 |
123 | 124 | 127 |
128 |
129 |
130 | 135 |
136 |
137 |
138 | 139 | 140 | 141 |
142 |
143 |
144 |
145 | 146 | 147 |
148 |
149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/scenario-pane.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 4 | 5 | import { useScenario } from './scenario-provider'; 6 | 7 | export function ScenarioPane() { 8 | const { scenario } = useScenario(); 9 | if (!scenario) return null; 10 | 11 | return ( 12 | 13 | 14 | {scenario.name} 15 | 16 | 17 | {scenario.description} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/scenario-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | createContext, 5 | Dispatch, 6 | SetStateAction, 7 | useContext, 8 | useState, 9 | } from 'react'; 10 | 11 | import { Chat } from './chat'; 12 | 13 | export type Chat = { 14 | role: 'user' | 'model' | 'error' | 'recording'; 15 | message: string; 16 | /** 17 | * ISO 8601 string 18 | */ 19 | createdAt: string; 20 | }; 21 | 22 | type ScenarioProviderContextValue = { 23 | llmRole: LlmRole | undefined; 24 | scenario: Scenario | undefined; 25 | goals: Goal[]; 26 | targetWords: { 27 | id: string; 28 | words: string[]; 29 | }; 30 | completedGoalIds: string[]; 31 | completedWords: string[]; 32 | history: Chat[]; 33 | isGameOver: boolean; 34 | setGoals: Dispatch>; 35 | setTargetWords: Dispatch< 36 | SetStateAction<{ 37 | id: string; 38 | words: string[]; 39 | }> 40 | >; 41 | setHistory: Dispatch>; 42 | setCompletedGoalIds: Dispatch>; 43 | setCompletedWords: Dispatch>; 44 | }; 45 | 46 | const ScenarioProviderContext = createContext({ 47 | llmRole: undefined, 48 | scenario: undefined, 49 | goals: [], 50 | history: [], 51 | targetWords: { 52 | id: '', 53 | words: [], 54 | }, 55 | isGameOver: false, 56 | completedGoalIds: [], 57 | completedWords: [], 58 | setGoals: () => {}, 59 | setTargetWords: () => {}, 60 | setHistory: () => {}, 61 | setCompletedGoalIds: () => {}, 62 | setCompletedWords: () => {}, 63 | }); 64 | 65 | type ScenarioProviderProps = { 66 | llmRole: LlmRole; 67 | scenario: Scenario; 68 | goals: Goal[]; 69 | targetWords: { 70 | id: string; 71 | words: string[]; 72 | }; 73 | history: Chat[]; 74 | children: React.ReactNode; 75 | }; 76 | 77 | export function ScenarioProvider(props: ScenarioProviderProps) { 78 | const [goals, setGoals] = useState(props.goals); 79 | const [targetWords, setTargetWords] = useState<{ 80 | id: string; 81 | words: string[]; 82 | }>(props.targetWords); 83 | const [history, setHistory] = useState(props.history); 84 | const [completedGoalIds, setCompletedGoalIds] = useState([]); 85 | const [completedWords, setCompletedWords] = useState([]); 86 | 87 | return ( 88 | 105 | {props.children} 106 | 107 | ); 108 | } 109 | 110 | export function useScenario() { 111 | return useContext(ScenarioProviderContext); 112 | } 113 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-completed-goals.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type FetchGoalsOptions = { conversationId: string }; 7 | 8 | /** 9 | * Fetches the completed goals associated with the conversation. 10 | */ 11 | export async function fetchCompletedGoalsAndTargetWords( 12 | options: FetchGoalsOptions 13 | ) { 14 | const supabase = createServerServiceRoleClient(); 15 | const selectCompletedGoalsResponse = await supabase 16 | .from('conversation_completed_goals') 17 | .select('*,goal:goals!inner(*)') 18 | .eq('conversation_id', options.conversationId); 19 | 20 | if (selectCompletedGoalsResponse.error) { 21 | throw new ConvoError( 22 | `Failed to fetch completed goals for conversation: ${options.conversationId}`, 23 | JSON.stringify({ 24 | code: selectCompletedGoalsResponse.error.code, 25 | details: selectCompletedGoalsResponse.error.details, 26 | hint: selectCompletedGoalsResponse.error.hint, 27 | message: selectCompletedGoalsResponse.error.message, 28 | }) 29 | ); 30 | } 31 | 32 | const selectCompletedTargetWordsResponse = await supabase 33 | .from('conversation_completed_target_words') 34 | .select() 35 | .eq('conversation_id', options.conversationId) 36 | .maybeSingle(); 37 | 38 | if (selectCompletedTargetWordsResponse.error) { 39 | throw new ConvoError( 40 | `Failed to fetch completed target words for conversation: ${options.conversationId}`, 41 | JSON.stringify({ 42 | code: selectCompletedTargetWordsResponse.error.code, 43 | details: selectCompletedTargetWordsResponse.error.details, 44 | hint: selectCompletedTargetWordsResponse.error.hint, 45 | message: selectCompletedTargetWordsResponse.error.message, 46 | }) 47 | ); 48 | } 49 | 50 | return { 51 | conversationId: options.conversationId, 52 | completedGoals: selectCompletedGoalsResponse.data.map((data) => data.goal), 53 | completedTargetWords: 54 | selectCompletedTargetWordsResponse.data?.completed_words ?? [], 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-conversation.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerAnonClient } from '@/lib/supabase/server'; 5 | 6 | type FetchConversationOptions = { conversationId: string }; 7 | 8 | export async function fetchConversation({ 9 | conversationId, 10 | }: FetchConversationOptions) { 11 | const supabase = createServerAnonClient(); 12 | const response = await supabase 13 | .from('conversations') 14 | .select( 15 | '*,scenario:scenarios!inner(*,llm_role:llm_roles!inner(*),goals(*),target_words!inner(*)),conversation_dialogs(*),evaluation:evaluations(*)' 16 | ) 17 | .eq('id', conversationId) 18 | .single(); 19 | 20 | if (response.error) { 21 | throw new ConvoError( 22 | 'Unable to fetch conversation with ID: ' + conversationId, 23 | JSON.stringify({ 24 | code: response.error.code, 25 | details: response.error.details, 26 | hint: response.error.hint, 27 | message: response.error.message, 28 | }) 29 | ); 30 | } 31 | 32 | return { conversation: response.data }; 33 | } 34 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-goals.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type FetchGoalsOptions = { 7 | scenarioId: string; 8 | }; 9 | 10 | /** 11 | * Fetches all goals associated with a scenario from the database. 12 | */ 13 | export async function fetchGoals(options: FetchGoalsOptions) { 14 | const supabase = createServerServiceRoleClient(); 15 | const response = await supabase 16 | .from('goals') 17 | .select('*') 18 | .eq('scenario_id', options.scenarioId); 19 | if (response.error) { 20 | throw new ConvoError( 21 | 'Failed to fetch goals for scenario: ' + options.scenarioId, 22 | JSON.stringify({ 23 | code: response.error.code, 24 | details: response.error.details, 25 | hint: response.error.hint, 26 | message: response.error.message, 27 | }) 28 | ); 29 | } 30 | return { 31 | goals: response.data, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-llm-role.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type FetchLlmRoleOptions = { 7 | llmId: string; 8 | }; 9 | 10 | /** 11 | * Fetches a single LLM role with the given ID from the database. 12 | */ 13 | export async function fetchLlmRole(options: FetchLlmRoleOptions) { 14 | const supabase = createServerServiceRoleClient(); 15 | const response = await supabase 16 | .from('llm_roles') 17 | .select('*') 18 | .eq('id', options.llmId) 19 | .single(); 20 | if (response.error) { 21 | throw new ConvoError( 22 | 'Failed to fetch LLM role: ' + options.llmId, 23 | JSON.stringify({ 24 | code: response.error.code, 25 | details: response.error.details, 26 | hint: response.error.hint, 27 | message: response.error.message, 28 | }) 29 | ); 30 | } 31 | return { 32 | llmRole: response.data, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-scenario.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type FetchScenarioOptions = { 7 | scenarioId: string; 8 | }; 9 | 10 | /** 11 | * Fetches all scenarios from the database. 12 | */ 13 | export async function fetchScenario(options: FetchScenarioOptions) { 14 | const supabase = createServerServiceRoleClient(); 15 | const response = await supabase 16 | .from('scenarios') 17 | .select('*') 18 | .eq('id', options.scenarioId) 19 | .single(); 20 | if (response.error) { 21 | throw new ConvoError( 22 | 'Failed to fetch scenario: ' + options.scenarioId, 23 | JSON.stringify({ 24 | code: response.error.code, 25 | details: response.error.details, 26 | hint: response.error.hint, 27 | message: response.error.message, 28 | }) 29 | ); 30 | } 31 | return { 32 | scenario: response.data, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/fetch-target-words.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type FetchTargetWordsOptions = { 7 | scenarioId: string; 8 | }; 9 | 10 | /** 11 | * Fetches all target words associated with a scenario from the database. 12 | */ 13 | export async function fetchTargetWords(options: FetchTargetWordsOptions) { 14 | const supabase = createServerServiceRoleClient(); 15 | const response = await supabase 16 | .from('target_words') 17 | .select('*') 18 | .eq('scenario_id', options.scenarioId) 19 | .single(); 20 | if (response.error) { 21 | throw new ConvoError( 22 | 'Failed to fetch target words for scenario: ' + options.scenarioId, 23 | JSON.stringify({ 24 | code: response.error.code, 25 | details: response.error.details, 26 | hint: response.error.hint, 27 | message: response.error.message, 28 | }) 29 | ); 30 | } 31 | return { 32 | targetWords: response.data, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/gemini/get-initial-history.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { HarmBlockThreshold, HarmCategory } from '@google/generative-ai'; 4 | 5 | import { getGeminiModel } from '@/lib/ai/gemini'; 6 | 7 | import { Chat } from '../../scenario-provider'; 8 | import { getInitialLlmPrompt } from '../../utils/get-initial-llm-prompt'; 9 | 10 | type GetInitialHistoryOptions = { 11 | llmRole: LlmRole; 12 | scenario: Scenario; 13 | }; 14 | 15 | export async function getInitialHistory( 16 | options: GetInitialHistoryOptions 17 | ): Promise { 18 | const systemPromptString = getInitialLlmPrompt({ 19 | llmRole: options.llmRole, 20 | scenario: options.scenario, 21 | }); 22 | const geminiModel = getGeminiModel({ modelName: 'gemini-pro' }); 23 | const result = await geminiModel.generateContent({ 24 | contents: [ 25 | { 26 | role: 'user', 27 | parts: [ 28 | { 29 | text: systemPromptString, 30 | }, 31 | ], 32 | }, 33 | ], 34 | safetySettings: [ 35 | { 36 | category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, 37 | threshold: HarmBlockThreshold.BLOCK_NONE, 38 | }, 39 | { 40 | category: HarmCategory.HARM_CATEGORY_HARASSMENT, 41 | threshold: HarmBlockThreshold.BLOCK_NONE, 42 | }, 43 | { 44 | category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, 45 | threshold: HarmBlockThreshold.BLOCK_NONE, 46 | }, 47 | { 48 | category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, 49 | threshold: HarmBlockThreshold.BLOCK_NONE, 50 | }, 51 | ], 52 | generationConfig: { 53 | temperature: 0, 54 | }, 55 | }); 56 | const message = result.response.text(); 57 | return [ 58 | { 59 | role: 'user', 60 | message: systemPromptString, 61 | createdAt: new Date().toISOString(), 62 | }, 63 | { 64 | role: 'model', 65 | message, 66 | createdAt: new Date().toISOString(), 67 | }, 68 | ]; 69 | } 70 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/gemini/send-messages-to-llm.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { HarmBlockThreshold, HarmCategory } from '@google/generative-ai'; 4 | 5 | import { getGeminiModel } from '@/lib/ai/gemini'; 6 | 7 | import { Chat } from '../../scenario-provider'; 8 | 9 | /** 10 | * Send messages to the LLM, messages include history. This returns the new history. 11 | */ 12 | export async function sendMessagesToLlm( 13 | history: Chat[], 14 | newUserMessage: string 15 | ): Promise { 16 | const geminiModel = getGeminiModel({ modelName: 'gemini-pro' }); 17 | 18 | const chat = geminiModel.startChat({ 19 | history: history.map((message) => ({ 20 | role: message.role, 21 | parts: [{ text: message.message }], 22 | })), 23 | generationConfig: { 24 | temperature: 0.2, 25 | }, 26 | safetySettings: [ 27 | { 28 | category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, 29 | threshold: HarmBlockThreshold.BLOCK_NONE, 30 | }, 31 | { 32 | category: HarmCategory.HARM_CATEGORY_HARASSMENT, 33 | threshold: HarmBlockThreshold.BLOCK_NONE, 34 | }, 35 | { 36 | category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, 37 | threshold: HarmBlockThreshold.BLOCK_NONE, 38 | }, 39 | { 40 | category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, 41 | threshold: HarmBlockThreshold.BLOCK_NONE, 42 | }, 43 | ], 44 | }); 45 | 46 | const result = await chat.sendMessage(newUserMessage); 47 | const response = result.response; 48 | const text = response.text(); 49 | 50 | // Return the new history 51 | return { 52 | role: 'model', 53 | message: text, 54 | createdAt: new Date().toISOString(), 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/openai/check-goal-completions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createCompletion, OpenAIMessage } from '@/lib/ai/openai'; 4 | 5 | import { Chat } from '../../scenario-provider'; 6 | 7 | type CheckGoalCompletionsOptions = { 8 | goals: Goal[]; 9 | completedGoalIds: string[]; 10 | history: Chat[]; 11 | scenario: Scenario; 12 | }; 13 | 14 | /** 15 | * Check if goals are completed based on the scenario and chat history. Returns the ids of the completed goals. 16 | */ 17 | export async function checkGoalCompletions( 18 | options: CheckGoalCompletionsOptions 19 | ): Promise { 20 | const uncompletedGoals = options.goals.filter( 21 | (goal) => !options.completedGoalIds.includes(goal.id) 22 | ); 23 | 24 | // Remove the first system prompt. 25 | const history = options.history.slice(1); 26 | const initialPrompts = getInitialPrompts( 27 | options.scenario, 28 | history, 29 | uncompletedGoals 30 | ); 31 | const content = await createCompletion({ 32 | messages: initialPrompts, 33 | temperature: 0, 34 | returnAsJson: true, 35 | }); 36 | 37 | const responseText = cleanupJSONString(content); 38 | const completedGoals = JSON.parse(responseText).response; 39 | if (!Array.isArray(completedGoals)) { 40 | throw new Error('Invalid response from the AI model'); 41 | } 42 | if (!completedGoals.every((goal) => goal.id && typeof goal.id === 'string')) { 43 | throw new Error('Invalid response from the AI model'); 44 | } 45 | if (!completedGoals.every((goal) => typeof goal.completed === 'boolean')) { 46 | throw new Error('Invalid response from the AI model'); 47 | } 48 | const newListOfCompletedGoalIds = new Set(); 49 | for (const goal of options.completedGoalIds) { 50 | newListOfCompletedGoalIds.add(goal); 51 | } 52 | for (const goal of completedGoals) { 53 | if (goal.completed) { 54 | newListOfCompletedGoalIds.add(goal.id); 55 | } 56 | } 57 | return Array.from(newListOfCompletedGoalIds); 58 | } 59 | 60 | function getInitialPrompts( 61 | scenario: Scenario, 62 | history: Chat[], 63 | goals: Goal[] 64 | ): OpenAIMessage[] { 65 | return [ 66 | { 67 | role: 'system', 68 | content: ` 69 | You are a linguistic expert who is participating in a simulated conversation to evaluate goal completion status of the situational chat. You are designed to output only JSON. In the role-play scenario, the "model" -- the LLM will play a role, and the "user" will play another role. The "model" is there to help the "user" practice, by role-playing the counterparty in the given scenario. You will look at the history of conversation, and determine if the given goals have been achieved by the conversation. Your evaluation will be striclty based on the context of the scenario that the conversation is taking place in. 70 | 71 | You will be provided with the following data: 72 | 73 | Scenario: 74 | { 75 | description: "The description of the scenario", 76 | name: "The name of the scenario", 77 | player_role: "The role of the user in the scenario" 78 | } 79 | 80 | Goals: 81 | [ 82 | { 83 | "id": "goal_id", 84 | "short_description": "The description of the goal", 85 | "long_description": "The detailed description of the goal" 86 | } 87 | ] 88 | 89 | Conversation history: 90 | [ 91 | { 92 | "role": "user" | "model", 93 | "message": "The message sent by the user or model" 94 | } 95 | ] 96 | 97 | A goal is considered completed if: 98 | The goal is mentioned or inferred in the conversation history provided 99 | AND 100 | The goal is achieved in the context of the conversation. 101 | 102 | Examples: 103 | 104 | Goal: Purchase toilet paper 105 | Thoughts: To consider this goal achieved, the conversation history must show signs that the user has successfully purchased toilet paper. If user only asks the price of the toilet paper but does not mention about purchasing it, the goal is not considered achieved. 106 | 107 | Return your response in parsable JSON format, with your reasoning, and citing the source from the conversation history. You are designed to only speak in parsable JSON. 108 | 109 | Example response: 110 | { 111 | "response": [{ 112 | "id": <>, 113 | "reasoning": <>, 114 | "source": <>, 115 | "completed": <> 116 | }] 117 | } 118 | 119 | Your response: 120 | { 121 | "response": <> 122 | }`, 123 | }, 124 | { 125 | role: 'user', 126 | content: ` 127 | Here is the scenario data: 128 | ${JSON.stringify(scenario)} 129 | 130 | Here are the goals: 131 | ${JSON.stringify(goals)} 132 | 133 | Here is the conversation history: 134 | ${JSON.stringify(history)} 135 | `, 136 | }, 137 | ]; 138 | } 139 | 140 | function cleanupJSONString(jsonString: string): string { 141 | return jsonString 142 | .replace(/\`\`\`/g, '') 143 | .replace(/json/g, '') 144 | .trim(); 145 | } 146 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/openai/get-evaluation-from-ai.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createCompletion } from '@/lib/ai/openai'; 4 | 5 | import type { Chat } from '../../scenario-provider'; 6 | 7 | type GetEvaluationsOptions = { 8 | scenario: Scenario; 9 | goals: Goal[]; 10 | history: Chat[]; 11 | }; 12 | 13 | export async function getEvaluationFromAI( 14 | options: GetEvaluationsOptions 15 | ): Promise<{ 16 | evaluation: string; 17 | score: number; 18 | suggestions: string[]; 19 | }> { 20 | const content = await createCompletion({ 21 | messages: [ 22 | { 23 | role: 'system', 24 | content: getSystemPrompt(), 25 | }, 26 | { 27 | role: 'user', 28 | content: ` 29 | Scenario: ${JSON.stringify(options.scenario)} 30 | Goals: ${JSON.stringify(options.goals)} 31 | History: ${JSON.stringify(options.history)} 32 | `, 33 | }, 34 | ], 35 | temperature: 0, 36 | returnAsJson: true, 37 | }); 38 | return JSON.parse(content); 39 | } 40 | 41 | function getSystemPrompt(): string { 42 | return ` 43 | You are a helpful English tutor. You are here to evaluate the performance of a role-play scenario that the student ("user") did with the counterpart ("model"). You will be provided with the following information: 44 | 45 | - Scenario. This is the context of the role-play scenario. The role of the student determines the learning objectives. 46 | - Goals. These are the learning objectives that the student should achieve during the role-play scenario. 47 | - History. This is the conversation that took place during the role-play scenario. In this history, the student is referred as "user" and the counterpart is referred as "model". Your evaluation will focus on the student's sentences. 48 | 49 | Your task is to evaluate the English proficiency of the student from different aspects: 50 | - The student's clever use of English vocabulary. 51 | - The student's ability to achieve the learning objectives with good sentence structure and grammar. 52 | - Point out any mistakes made by the student and suggest improvements. 53 | - Evaluate the student's overall performance in the role-play scenario context and provide constructive feedback. 54 | 55 | Your evaluation should be detailed and helpful, at the same time, easy to read and clear to understand. Highlight keywords or phrases that are important to the student. Use "you" to address the student directly. 56 | 57 | Your evaluation is in markdown format and is comprehensive and detailed. It should contain the following sections: 58 | # Summary (A brief summary of the student's performance and goal completion) 59 | # Vocabulary (Evaluation of the student's vocabulary and word choice) 60 | # Grammar (Evaluation of the student's grammar) 61 | 62 | In your response, you should provide a list of suggestions with examples to help the student improve. 63 | 64 | Your response format is designed to be a parsable JSON object. 65 | 66 | JSON format as follows: 67 | { 68 | "evaluation": <>, 69 | "score": <>, 70 | "suggestions": [ 71 | <>, 72 | <>, 73 | ... 74 | ] 75 | } 76 | 77 | Example: 78 | { 79 | "evaluation": "Your evaluation in markdown format here...", 80 | "score": 90, 81 | "suggestions": [ 82 | "your suggestion here...", 83 | "your suggestion here...", 84 | ... 85 | ] 86 | } 87 | 88 | Your response: 89 | `; 90 | } 91 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/openai/get-initial-history.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { createCompletion, OpenAIMessage } from '@/lib/ai/openai'; 4 | 5 | import { Chat } from '../../scenario-provider'; 6 | import { getInitialLlmPrompt } from '../../utils/get-initial-llm-prompt'; 7 | 8 | type GetInitialHistoryOptions = { 9 | llmRole: LlmRole; 10 | scenario: Scenario; 11 | }; 12 | 13 | export async function getInitialHistory( 14 | options: GetInitialHistoryOptions 15 | ): Promise { 16 | const systemPromptString = getInitialLlmPrompt({ 17 | llmRole: options.llmRole, 18 | scenario: options.scenario, 19 | }); 20 | const systemPrompt: OpenAIMessage = { 21 | role: 'system', 22 | content: systemPromptString, 23 | }; 24 | const content = await createCompletion({ 25 | messages: [systemPrompt], 26 | temperature: 0.5, 27 | maxTokens: 100, 28 | }); 29 | return [ 30 | { 31 | role: 'user', 32 | message: systemPromptString, 33 | createdAt: new Date().toISOString(), 34 | }, 35 | { 36 | role: 'model', 37 | message: content, 38 | createdAt: new Date().toISOString(), 39 | }, 40 | ]; 41 | } 42 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/openai/send-messages-to-llm.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createCompletion, OpenAIMessage } from '@/lib/ai/openai'; 4 | 5 | import { Chat } from '../../scenario-provider'; 6 | 7 | /** 8 | * Send messages to the LLM, messages include history. This returns the new history. 9 | */ 10 | export async function sendMessagesToLlm( 11 | history: Chat[], 12 | newUserMessage: string 13 | ): Promise { 14 | const cleanupHistory: OpenAIMessage[] = history 15 | // Filters out error messages 16 | .filter((message) => message.role !== 'error') 17 | .map((message) => ({ 18 | role: message.role === 'user' ? 'user' : 'assistant', 19 | content: message.message, 20 | })); 21 | const content = await createCompletion({ 22 | messages: [...cleanupHistory, { role: 'user', content: newUserMessage }], 23 | temperature: 0.5, 24 | maxTokens: 100, 25 | }); 26 | // Return the new history 27 | return { 28 | role: 'model', 29 | message: content, 30 | createdAt: new Date().toISOString(), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/save-completed-goal.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type SaveCompletedGoalOptions = { 7 | conversationId: string; 8 | goalId: string; 9 | }; 10 | 11 | export async function saveCompletedGoal(options: SaveCompletedGoalOptions) { 12 | const supabase = createServerServiceRoleClient(); 13 | const response = await supabase.from('conversation_completed_goals').insert({ 14 | conversation_id: options.conversationId, 15 | goal_id: options.goalId, 16 | }); 17 | if (response.error) { 18 | throw new ConvoError( 19 | `Failed to save completed goal for conversation: ${options.conversationId}`, 20 | JSON.stringify({ 21 | code: response.error.code, 22 | details: response.error.details, 23 | hint: response.error.hint, 24 | message: response.error.message, 25 | }) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/save-completed-target-word.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | type SaveCompletedTargetWordOptions = { 7 | conversationId: string; 8 | targetWordId: string; 9 | word: string; 10 | }; 11 | 12 | export async function saveCompletedTargetWord( 13 | options: SaveCompletedTargetWordOptions 14 | ) { 15 | const supabase = createServerServiceRoleClient(); 16 | 17 | const selectCompletedTargetWordsResponse = await supabase 18 | .from('conversation_completed_target_words') 19 | .select() 20 | .eq('conversation_id', options.conversationId) 21 | .maybeSingle(); 22 | if (selectCompletedTargetWordsResponse.error) { 23 | throw new ConvoError( 24 | `Failed to fetch completed target words for conversation: ${options.conversationId}`, 25 | JSON.stringify({ 26 | code: selectCompletedTargetWordsResponse.error.code, 27 | details: selectCompletedTargetWordsResponse.error.details, 28 | hint: selectCompletedTargetWordsResponse.error.hint, 29 | message: selectCompletedTargetWordsResponse.error.message, 30 | }) 31 | ); 32 | } 33 | 34 | if (selectCompletedTargetWordsResponse.data === null) { 35 | // No existing record, insert new record 36 | const insertResponse = await supabase 37 | .from('conversation_completed_target_words') 38 | .insert({ 39 | conversation_id: options.conversationId, 40 | target_word_id: options.targetWordId, 41 | completed_words: [options.word], 42 | }); 43 | if (insertResponse.error) { 44 | throw new ConvoError( 45 | `Failed to save completed target word for conversation: ${options.conversationId}`, 46 | JSON.stringify({ 47 | code: insertResponse.error.code, 48 | details: insertResponse.error.details, 49 | hint: insertResponse.error.hint, 50 | message: insertResponse.error.message, 51 | }) 52 | ); 53 | } 54 | } else { 55 | // Existing record, update record 56 | const existingWords = new Set( 57 | selectCompletedTargetWordsResponse.data.completed_words ?? [] 58 | ); 59 | if (existingWords.has(options.word)) { 60 | // Word already exists in the completed words 61 | return; 62 | } 63 | existingWords.add(options.word); 64 | const updateResponse = await supabase 65 | .from('conversation_completed_target_words') 66 | .update({ completed_words: Array.from(existingWords) }) 67 | .eq('conversation_id', options.conversationId); 68 | if (updateResponse.error) { 69 | throw new ConvoError( 70 | `Failed to save completed target word for conversation: ${options.conversationId}`, 71 | JSON.stringify({ 72 | code: updateResponse.error.code, 73 | details: updateResponse.error.details, 74 | hint: updateResponse.error.hint, 75 | message: updateResponse.error.message, 76 | }) 77 | ); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/save-conversation-dialog.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { createServerAnonClient } from '@/lib/supabase/server'; 6 | 7 | import { Chat } from '../scenario-provider'; 8 | 9 | type SaveConversationDialogOptions = { 10 | conversationId: string; 11 | chat: Chat; 12 | }; 13 | 14 | export async function saveConversationDialog( 15 | options: SaveConversationDialogOptions 16 | ) { 17 | // We don't save the dialog recording in progress. 18 | if (options.chat.role === 'recording') return; 19 | const supabase = createServerAnonClient(); 20 | const { data, error } = await supabase.auth.getUser(); 21 | if (error || !data.user) { 22 | redirect('/signin'); 23 | } 24 | const insertDialogsResponse = await supabase 25 | .from('conversation_dialogs') 26 | .insert({ 27 | conversation_id: options.conversationId, 28 | role: options.chat.role, 29 | message: options.chat.message, 30 | timestamp: options.chat.createdAt, 31 | created_by: data.user.id, 32 | }) 33 | .select(); 34 | if (insertDialogsResponse.error) throw insertDialogsResponse.error; 35 | return { 36 | conversationDialog: insertDialogsResponse.data, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/save-evaluation.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 4 | 5 | type SaveEvaluationOptions = { 6 | conversationId: string; 7 | evaluation: string; 8 | score: number; 9 | suggestions: string[]; 10 | }; 11 | 12 | export async function saveEvaluation(options: SaveEvaluationOptions) { 13 | const supabase = createServerServiceRoleClient(); 14 | const response = await supabase 15 | .from('evaluations') 16 | .insert({ 17 | conversation_id: options.conversationId, 18 | ai_evaluation: options.evaluation, 19 | ai_score: options.score, 20 | suggestions: options.suggestions, 21 | }) 22 | .select() 23 | .single(); 24 | if (response.error) throw response.error; 25 | return { 26 | evaluation: response.data, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/services/update-conversation.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createServerAnonClient } from '@/lib/supabase/server'; 4 | 5 | type UpdateConversationOptions = { 6 | conversationId: string; 7 | bonusScore: number; 8 | }; 9 | 10 | export async function updateConversation(options: UpdateConversationOptions) { 11 | const supabase = createServerAnonClient(); 12 | const response = await supabase 13 | .from('conversations') 14 | .update({ 15 | bonus_score: options.bonusScore, 16 | }) 17 | .eq('id', options.conversationId) 18 | .select() 19 | .single(); 20 | if (response.error) throw response.error; 21 | return { 22 | conversation: response.data, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/turns-left-pane.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | 6 | import { Card, CardContent } from '@/components/ui/card'; 7 | import { cn } from '@/lib/utils'; 8 | 9 | import { useScenario } from './scenario-provider'; 10 | 11 | export const MAX_TURNS = 20; 12 | 13 | export function TurnsLeftPane() { 14 | const { history } = useScenario(); 15 | const [animate, setAnimate] = useState(false); 16 | const currentTurnCountLeft = 17 | MAX_TURNS - history.slice(1).filter((chat) => chat.role === 'user').length; 18 | const previousTurnCountLeft = useRef(currentTurnCountLeft); 19 | 20 | useEffect(() => { 21 | if (previousTurnCountLeft.current !== currentTurnCountLeft) { 22 | setAnimate((prev) => !prev); 23 | previousTurnCountLeft.current = currentTurnCountLeft; 24 | } 25 | }, [currentTurnCountLeft]); 26 | 27 | if (!history) return null; 28 | 29 | return ( 30 | 36 | 37 |
38 |
Turns left:
39 | 40 |
span]:flex [&>span]:items-center [&>span]:justify-center [&>span]:text-center', 44 | 'rounded-sm bg-card/20 shadow-[inset_0_0px_5px_1px_#00000020]' 45 | )} 46 | > 47 | {animate ? ( 48 | 0 && 53 | currentTurnCountLeft <= 5 && 54 | 'text-orange-400', 55 | currentTurnCountLeft === 0 && 'text-red-600' 56 | )} 57 | initial={{ y: 30 }} 58 | animate={{ y: 0 }} 59 | exit={{ y: -30 }} 60 | > 61 | {currentTurnCountLeft.toString().padStart(3, '0')} 62 | 63 | ) : ( 64 | 0 && 69 | currentTurnCountLeft <= 5 && 70 | 'text-orange-400', 71 | currentTurnCountLeft === 0 && 'text-red-600' 72 | )} 73 | initial={{ y: 30 }} 74 | animate={{ y: 0 }} 75 | exit={{ y: -30 }} 76 | > 77 | {currentTurnCountLeft.toString().padStart(3, '0')} 78 | 79 | )} 80 |
81 |
82 |
83 | {currentTurnCountLeft === 0 && ( 84 | 90 |

You have run out of your turns ö

91 |
92 | )} 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/use-microphone-permission.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useMicrophonePermission() { 4 | const [isMicrophoneAvailable, setIsMicrophoneAvailable] = useState(false); 5 | const [isMicrophoneBlockedByExtensions, setIsMicrophoneBlockedByExtensions] = 6 | useState(false); 7 | 8 | useEffect(() => { 9 | if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 10 | navigator.mediaDevices 11 | .getUserMedia( 12 | // constraints - only audio needed for this app 13 | { 14 | audio: true, 15 | } 16 | ) 17 | .then(() => { 18 | setIsMicrophoneAvailable(true); 19 | setIsMicrophoneBlockedByExtensions(false); 20 | }) 21 | .catch((error) => { 22 | console.error(error); 23 | setIsMicrophoneAvailable(false); 24 | if (error.name === 'NotAllowedError') { 25 | setIsMicrophoneBlockedByExtensions(true); 26 | } else { 27 | setIsMicrophoneBlockedByExtensions(false); 28 | } 29 | }); 30 | } else { 31 | console.warn('getUserMedia not supported on your browser!'); 32 | } 33 | }, []); 34 | 35 | return { 36 | isMicrophoneAvailable, 37 | isMicrophoneBlockedByExtensions, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/utils/get-initial-llm-prompt.ts: -------------------------------------------------------------------------------- 1 | type GetInitialLlmPromptOptions = { 2 | llmRole: LlmRole; 3 | scenario: Scenario; 4 | }; 5 | 6 | export function getInitialLlmPrompt( 7 | options: GetInitialLlmPromptOptions 8 | ): string { 9 | return `In this role-play scenario: ${options.scenario.name}, you are a ${options.llmRole.gender} ${options.llmRole.role}. ${options.llmRole.code_of_conduct} 10 | 11 | You should speak like a real ${options.llmRole.role} with a real name. When you encounter arbitrary values, you replace them with reasonable values to make the conversation more realistic and complete. 12 | 13 | 1. Introduce yourself with a real name and role. 14 | Good example: "Hi! My name is Sarah. I am a customer service representative. I am here to help you. How can I help you today?" 15 | 16 | 2. For arbitrary values, replace them with reasonable values. 17 | Good example: "For a fee of $50, you can upgrade to a suite which offers a jacuzzi. Would you like to upgrade your room?" 18 | Good example: "Your room number is 123. The Wi-Fi password is 123456." 19 | 20 | 3. You only speak human languages. Do not display your logics or thought process. 21 | Good example: "OK, I have you down for a breakfast reservation tomorrow at 8am." 22 | 23 | 4. You only speak human languages. Do not use code or any other format. 24 | Good example: "The room rate is $100." 25 | 26 | 5. You speak one sentence at a time. Do not combine multiple sentences into one. 27 | Good example: "Good morning! How can I help you today?" 28 | 29 | ${options.llmRole.starting_prompt} 30 | `; 31 | } 32 | -------------------------------------------------------------------------------- /app/conversations/[conversation_id]/utils/get-matched-targets.ts: -------------------------------------------------------------------------------- 1 | import { lowerCase } from 'lodash'; 2 | 3 | const getRegexPattern = (target: string): RegExp => { 4 | const magicSeparator = '[\\W_]*'; 5 | const magicMatchString = target 6 | .replace(/\W/g, '') 7 | .split('') 8 | .join(magicSeparator); 9 | const groupRegexString = 10 | target.length === 1 11 | ? `^(${magicMatchString})[\\W_]+|[\\W_]+(${magicMatchString})[\\W_]+|[\\W_]+(${magicMatchString})$|^(${magicMatchString})$` 12 | : `(${magicMatchString})`; 13 | return new RegExp(groupRegexString, 'gi'); 14 | }; 15 | 16 | export const getMatchedWordsInString = ( 17 | stringToMatch: string, 18 | wordsToMatch: string[] 19 | ): string[] => { 20 | const matchedTaboos: string[] = []; 21 | for (const matcher of wordsToMatch) { 22 | if (!matcher) continue; 23 | const regex = getRegexPattern(lowerCase(matcher)); 24 | let result; 25 | while ((result = regex.exec(stringToMatch)) !== null) { 26 | if (!matchedTaboos.includes(matcher)) { 27 | matchedTaboos.push(matcher); 28 | } 29 | // This is necessary to avoid infinite loops with zero-width matches 30 | if (result.index === regex.lastIndex) { 31 | regex.lastIndex++; 32 | } 33 | } 34 | } 35 | return matchedTaboos; 36 | }; 37 | -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { useRouter } from 'next/navigation'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | 8 | export default function Error({ 9 | error, 10 | reset, 11 | }: { 12 | error: Error; 13 | reset: () => void; 14 | }) { 15 | const router = useRouter(); 16 | 17 | return ( 18 | 19 |
20 |
21 |
22 | 28 |

Something went wrong!

29 |

{error.message}

30 |
31 | {error.stack && isObject(error.stack) && ( 32 | 37 | Here is something more technical: 38 |
39 |                   
40 |                     {JSON.stringify(JSON.parse(error.stack), null, 2)}
41 |                   
42 |                 
43 |
44 | )} 45 | 51 | 58 | 61 | 62 |
63 |
64 |
77 |
78 |
79 | ); 80 | } 81 | 82 | function isObject(value: string): boolean { 83 | try { 84 | JSON.parse(value); 85 | return true; 86 | } catch (e) { 87 | return false; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/copy-link-icon.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | 5 | import { CopyToClipboard } from '@/lib/copy-to-clipboard'; 6 | 7 | export function CopyLinkIcon() { 8 | const pathname = usePathname(); 9 | const evaluationLink = `Hi! Take a peek at my Convo session results for some awesome situational English conversation practice. Check them out here 🚀: 10 | 11 | ${window.location.origin}${pathname}`; 12 | return ( 13 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/copy-link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | 5 | import { CopyToClipboard } from '@/lib/copy-to-clipboard'; 6 | 7 | export function CopyLink() { 8 | const pathname = usePathname(); 9 | const evaluationLink = `Hi! Take a peek at my Convo session results for some awesome situational English conversation practice. Check them out here 🚀: 10 | 11 | ${window.location.origin}${pathname}`; 12 | return ( 13 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ScenarioBackground } from '@/app/scenarios/scenario-background'; 2 | import { ScenarioBackgroundProvider } from '@/app/scenarios/scenario-background-provider'; 3 | 4 | type LayoutProps = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | export default function Layout(props: LayoutProps) { 9 | return ( 10 | 11 | {props.children} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import Link from 'next/link'; 3 | import Markdown from 'react-markdown'; 4 | 5 | import { ScenarioProvider } from '@/app/conversations/[conversation_id]/scenario-provider'; 6 | import { openGraph } from '@/app/shared-metadata'; 7 | import { LabelWithPaddedDigits } from '@/components/label-with-padded-digits'; 8 | import { ScrollArea } from '@/components/ui/scroll-area'; 9 | 10 | import { CopyLink } from './copy-link'; 11 | import { ScenarioBackgroundLoader } from './scenario-background-loader'; 12 | import { fetchEvaluation } from './services/fetch-evaluation'; 13 | 14 | export async function generateMetadata(props: { 15 | params: { 16 | evaluation_id: string; 17 | }; 18 | }): Promise { 19 | const { evaluation } = await fetchEvaluation(props.params.evaluation_id); 20 | 21 | return { 22 | title: 'Convo AI | Evaluation' + ` - ${evaluation.conversation.scenario.name}`, 23 | openGraph: { 24 | ...openGraph, 25 | url: `/evaluations/${evaluation.id}`, 26 | title: 27 | 'Convo AI | Evaluation' + ` - ${evaluation.conversation.scenario.name}`, 28 | }, 29 | alternates: { 30 | canonical: `https://www.convo-ai.cc/evaluations/${evaluation.id}`, 31 | }, 32 | }; 33 | } 34 | 35 | type PageProps = { 36 | params: { 37 | evaluation_id: string; 38 | }; 39 | }; 40 | 41 | export default async function Page(props: PageProps) { 42 | const { evaluation: scenarioEvaluation } = await fetchEvaluation( 43 | props.params.evaluation_id 44 | ); 45 | const scenario = scenarioEvaluation.conversation.scenario; 46 | const conversation = scenarioEvaluation.conversation; 47 | const bonusScore = conversation.bonus_score; 48 | const evaluationScore = scenarioEvaluation.ai_score; 49 | const turnsUsed = conversation.conversation_dialogs 50 | .slice(1) 51 | .filter((dialog) => dialog.role === 'user').length; 52 | 53 | if (scenario.target_words === null) 54 | throw new Error(`Scenario ${scenario.id} has no target words`); 55 | 56 | return ( 57 | ({ 59 | ...goal, 60 | completed: false, 61 | }))} 62 | llmRole={scenario.llm_role} 63 | scenario={scenario} 64 | targetWords={scenario.target_words} 65 | history={ 66 | conversation.conversation_dialogs 67 | ?.filter<{ 68 | conversation_id: string; 69 | role: 'user' | 'model'; 70 | message: string; 71 | timestamp: string; 72 | created_by: string; 73 | }>( 74 | ( 75 | dialog 76 | ): dialog is { 77 | conversation_id: string; 78 | role: 'user' | 'model'; 79 | message: string; 80 | timestamp: string; 81 | created_by: string; 82 | } => dialog.role !== 'error' && dialog.message !== null 83 | ) 84 | .map((dialog) => ({ 85 | role: dialog.role, 86 | message: dialog.message, 87 | createdAt: new Date(dialog.timestamp).toISOString(), 88 | })) ?? [] 89 | } 90 | > 91 |
92 | 93 |
94 |
95 |

96 | {scenario.name} 97 |

98 |

99 | {scenario.description} 100 |

101 |
102 | 107 | 112 | 117 | 123 |
124 | 125 | {scenarioEvaluation.ai_evaluation} 126 | 127 | {scenarioEvaluation.suggestions.length > 0 && ( 128 |
129 |

Suggestions

130 |
131 | {scenarioEvaluation.suggestions.map((suggestion, index) => ( 132 |
136 | 137 | {index + 1} 138 | 139 | {suggestion} 140 |
141 | ))} 142 |
143 |
144 | )} 145 |
146 |
147 | 148 |
149 |
150 | Results generated by{' '} 151 | 156 | gpt-4o-mini 157 | {' '} 158 | might not be accurate. Please interepret with your own judgement. 159 |
160 |
161 |
162 |
163 | 164 |
165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/scenario-background-loader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import { useScenarioBackground } from '@/app/scenarios/scenario-background-provider'; 6 | 7 | export function ScenarioBackgroundLoader({ scenario }: { scenario: Scenario }) { 8 | const { setShowBackgroundImage, setBackgroundImageUrl } = 9 | useScenarioBackground(); 10 | 11 | useEffect(() => { 12 | if (scenario.image_url) { 13 | setShowBackgroundImage(true); 14 | setBackgroundImageUrl(scenario.image_url); 15 | } else { 16 | setShowBackgroundImage(false); 17 | } 18 | }, [scenario, setBackgroundImageUrl, setShowBackgroundImage]); 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /app/evaluations/[evaluation_id]/services/fetch-evaluation.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ConvoError } from '@/lib/convo-error'; 4 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 5 | 6 | export async function fetchEvaluation(evaluationId: string) { 7 | const supabase = createServerServiceRoleClient(); 8 | const response = await supabase 9 | .from('evaluations') 10 | .select( 11 | '*,conversation:conversations!inner(*,conversation_dialogs(*),scenario:scenarios!inner(*,llm_role:llm_roles!inner(*),target_words!inner(*),goals(*)))' 12 | ) 13 | .eq('id', evaluationId) 14 | .single(); 15 | if (response.error) 16 | throw new ConvoError( 17 | 'Failed to fetch evaluation with ID: ' + evaluationId, 18 | JSON.stringify({ 19 | code: response.error.code, 20 | details: response.error.details, 21 | hint: response.error.hint, 22 | message: response.error.message, 23 | }) 24 | ); 25 | return { 26 | evaluation: response.data, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/favicon.ico -------------------------------------------------------------------------------- /app/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | useSelectedLayoutSegment, 5 | useSelectedLayoutSegments, 6 | } from 'next/navigation'; 7 | 8 | import { cn } from '@/lib/utils'; 9 | 10 | import { DEFAULT_HOMELINK, HomeLink } from './home-link'; 11 | 12 | const LAYOUT_SEGMENT_TO_LINK: Record = { 13 | '/': { 14 | href: '/scenarios', 15 | label: 'Start', 16 | }, 17 | scenarios: { 18 | href: '/', 19 | label: 'Home', 20 | }, 21 | 'conversations/*': { 22 | href: '/scenarios', 23 | label: 'Scenarios', 24 | }, 25 | 'evaluations/*': { 26 | href: '/scenarios', 27 | label: 'Scenarios', 28 | }, 29 | profile: { 30 | href: '/scenarios', 31 | label: 'Scenarios', 32 | }, 33 | signin: { 34 | href: '/scenarios', 35 | label: 'Scenarios', 36 | }, 37 | }; 38 | 39 | export function Header() { 40 | const layoutSegment = useSelectedLayoutSegment(); 41 | const segments = useSelectedLayoutSegments(); 42 | const isRootLayout = 43 | layoutSegment === null || 44 | segments.length === 1 || 45 | (segments.length > 1 && 46 | segments.indexOf(layoutSegment) === segments.length - 1); 47 | 48 | const segmentKey = 49 | layoutSegment === null 50 | ? '/' 51 | : isRootLayout 52 | ? layoutSegment 53 | : `${layoutSegment}/*`; 54 | 55 | return ( 56 |
68 |
69 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/home-link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion, Variants } from 'framer-motion'; 4 | import { usePathname, useRouter } from 'next/navigation'; 5 | import { useCallback, useEffect, useState } from 'react'; 6 | 7 | import { AnimatedText } from '@/components/animated-text'; 8 | 9 | export type HomeLink = { 10 | /** 11 | * The path to navigate to when the user clicks on this component. 12 | */ 13 | href: string; 14 | /** 15 | * The label to show on the home link. 16 | */ 17 | label: string; 18 | }; 19 | 20 | export const DEFAULT_HOMELINK: HomeLink = { 21 | href: '/', 22 | label: 'Home', 23 | }; 24 | 25 | type HomeLinkProps = { 26 | /** 27 | * The {@link HomeLink} object that contains the path and label for the home link. 28 | */ 29 | homeLink: HomeLink; 30 | /** 31 | * The font size of the heading text as string. 32 | */ 33 | fontSize?: string; 34 | /** 35 | * If true, we show the home link on home page. 36 | */ 37 | showWhenOnHomePage?: boolean; 38 | /** 39 | * Maintenance mode 40 | */ 41 | maintenanceMode?: boolean; 42 | }; 43 | 44 | export function HomeLink(props: HomeLinkProps) { 45 | const router = useRouter(); 46 | const pathname = usePathname(); 47 | 48 | const [headingHeight, setHeadingHeight] = useState(0); 49 | const [isBeforeRouting, setIsBeforeRouting] = useState(false); 50 | 51 | useEffect(() => { 52 | // Listen to viewport change 53 | function handleViewportChange() { 54 | const headingElement = document.getElementById('home-link-heading'); 55 | if (headingElement) { 56 | setHeadingHeight(headingElement.clientHeight); 57 | } 58 | } 59 | window.addEventListener('resize', handleViewportChange); 60 | return () => { 61 | window.removeEventListener('resize', handleViewportChange); 62 | }; 63 | }, []); 64 | 65 | const containerVairants: Variants = { 66 | initial: { 67 | y: 0, 68 | }, 69 | onHover: (height) => ({ 70 | y: -height, 71 | }), 72 | }; 73 | 74 | const handleLinkClick = useCallback(() => { 75 | if (props.maintenanceMode) return; 76 | setIsBeforeRouting(true); 77 | setTimeout(() => { 78 | router.push(props.homeLink.href); 79 | }, 200); 80 | }, [props.homeLink.href, props.maintenanceMode, router]); 81 | 82 | useEffect(() => { 83 | setIsBeforeRouting(false); 84 | }, [pathname]); 85 | 86 | return ( 87 | 88 | {!props.showWhenOnHomePage && 89 | pathname === '/' ? null : isBeforeRouting ? null : ( 90 | 110 | {/* Container that holds two rows of text */} 111 | 117 |

{ 120 | if (el) { 121 | setHeadingHeight(el.clientHeight); 122 | } 123 | }} 124 | className='h-fit w-full text-center' 125 | style={{ 126 | fontSize: props.fontSize, 127 | }} 128 | > 129 | Convo 130 |

131 |
137 | 138 | {props.maintenanceMode 139 | ? '...will be back soon!' 140 | : props.homeLink.label} 141 | 142 |
143 |
144 |
145 | )} 146 |
147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /app/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/icon1.png -------------------------------------------------------------------------------- /app/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/icon2.png -------------------------------------------------------------------------------- /app/icon3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/icon3.png -------------------------------------------------------------------------------- /app/icon4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/icon4.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | 3 | import { GithubLogo } from '@phosphor-icons/react/dist/ssr'; 4 | import { Analytics } from '@vercel/analytics/react'; 5 | import { Metadata, Viewport } from 'next'; 6 | import { Playfair_Display } from 'next/font/google'; 7 | import Link from 'next/link'; 8 | 9 | import { Toaster } from '@/components/ui/sonner'; 10 | import { TooltipProvider } from '@/components/ui/tooltip'; 11 | import { UserSigninPortal } from '@/components/user-signin-portal'; 12 | 13 | import { Header } from './header'; 14 | import { HomeLink } from './home-link'; 15 | import { openGraph } from './shared-metadata'; 16 | import { NextThemeProvider } from './theme-provider'; 17 | import { ThemeSwitchHolder } from './theme-switch-holder'; 18 | import { UserSigninPortalHolder } from './user-signin-portal-holder'; 19 | 20 | // Metadata for SEO 21 | export const metadata: Metadata = { 22 | metadataBase: new URL('https://www.convo-ai.cc/'), 23 | title: 'Convo | Boundless Conversation Practice with AI', 24 | description: 25 | 'Convo is an innovative edtech web app designed to enhance language learning through immersive, situational conversations. Choose from a variety of scenarios covering diverse topics and interact with an AI role-playing partner. Practice speaking freely, set conversation goals, and target specific vocabulary, all while receiving personalized feedback on your linguistic performance. Start your journey to fluent communication today with Convo.', 26 | manifest: '/manifest.json', 27 | appleWebApp: { 28 | title: 'Convo | Boundless Conversation Practice with AI', 29 | statusBarStyle: 'black-translucent', 30 | }, 31 | icons: { 32 | icon: '/favicon.ico', 33 | apple: [ 34 | { 35 | url: '/android-chrome-192x192.png', 36 | sizes: '192x192', 37 | type: 'image/png', 38 | }, 39 | { 40 | url: '/android-chrome512x512.png', 41 | sizes: '512x512', 42 | type: 'image/png', 43 | }, 44 | ], 45 | other: { 46 | rel: 'apple-touch-icon', 47 | url: '/apple-touch-icon.png', 48 | }, 49 | }, 50 | applicationName: 'Convo | Boundless Conversation Practice with AI', 51 | keywords: [ 52 | 'convo', 53 | 'english', 54 | 'conversation', 55 | 'ai', 56 | 'practice', 57 | 'learn', 58 | 'situational', 59 | ], 60 | authors: [{ name: 'Li Yuxuan', url: 'https://liyuxuan.dev/' }], 61 | creator: 'Li Yuxuan', 62 | alternates: { canonical: 'https://www.convo-ai.cc/' }, 63 | category: 'education', 64 | openGraph, 65 | twitter: { 66 | card: 'summary_large_image', 67 | title: 'Convo | Boundless Conversation Practice with AI', 68 | description: 69 | 'Convo is an innovative edtech web app designed to enhance language learning through immersive, situational conversations. Choose from a variety of scenarios covering diverse topics and interact with an AI role-playing partner. Practice speaking freely, set conversation goals, and target specific vocabulary, all while receiving personalized feedback on your linguistic performance. Start your journey to fluent communication today with Convo.', 70 | siteId: '1704579643', 71 | creator: '@xmliszt', 72 | creatorId: '1704579643', 73 | images: [ 74 | { 75 | url: '/twitter-image.jpg', 76 | width: 1200, 77 | height: 630, 78 | alt: 'Convo | Boundless Conversation Practice with AI', 79 | }, 80 | ], 81 | }, 82 | robots: { 83 | follow: true, 84 | nocache: true, 85 | }, 86 | }; 87 | 88 | export const viewport: Viewport = { 89 | width: 'device-width', 90 | initialScale: 1, 91 | themeColor: [ 92 | { media: '(prefers-color-scheme: light)', color: 'white' }, 93 | { media: '(prefers-color-scheme: dark)', color: 'black' }, 94 | ], 95 | userScalable: false, 96 | }; 97 | 98 | const PlayfairDisplay = Playfair_Display({ 99 | weight: ['400', '500', '600', '700', '800', '900'], 100 | subsets: ['latin'], 101 | }); 102 | 103 | export default function RootLayout({ 104 | children, 105 | }: { 106 | children: React.ReactNode; 107 | }) { 108 | const isMaintenanceMode = process.env.MAINTENANCE_MODE === 'true'; 109 | 110 | return ( 111 | 116 | 117 | 118 | {isMaintenanceMode ? ( 119 |
120 | 121 | 127 |
128 | ) : ( 129 | 130 |
131 | 132 | 133 | 134 | {children} 135 | 136 | 137 | 138 | )} 139 |
147 | {/* beta tag, github link */} 148 |
149 | Beta ⋅ 150 | 151 | 152 | open source 153 | 154 | 155 |
156 | {/* trademark */} 157 |
158 | Convo © 2024. All rights reserved. 159 |
160 |
161 | 162 | 163 | 164 | 165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { ConvoLoader } from '@/lib/convo-loader'; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
12 | 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/opengraph-image.alt.txt: -------------------------------------------------------------------------------- 1 | Convo AI | Boundless Conversation Practice with AI -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { HomeLink } from './home-link'; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/profile/conversation-link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { format } from 'date-fns'; 4 | import Link from 'next/link'; 5 | import { isMobile } from 'react-device-detect'; 6 | 7 | import { useMediaQuery } from '@/lib/use-media-query'; 8 | 9 | import { useScenarioBackground } from '../scenarios/scenario-background-provider'; 10 | 11 | type ConversationLinkProps = { 12 | conversationId: string; 13 | conversationNumber: number; 14 | conversationCreatedAt: string; 15 | scenarioImageUrl: string; 16 | }; 17 | 18 | export function ConversationLink(props: ConversationLinkProps) { 19 | const { setBackgroundImageUrl, setShowBackgroundImage } = 20 | useScenarioBackground(); 21 | 22 | const isSmallerDevice = useMediaQuery('(max-width: 640px)'); 23 | 24 | if (props.conversationId === undefined) return null; 25 | 26 | return ( 27 | { 31 | if (isMobile) return; 32 | setBackgroundImageUrl(props.scenarioImageUrl); 33 | setShowBackgroundImage(true); 34 | }} 35 | onPointerOut={() => { 36 | if (isMobile) return; 37 | setShowBackgroundImage(false); 38 | }} 39 | > 40 | Conversation {props.conversationNumber.toString().padStart(2, '0')} 41 |
42 | {format( 43 | new Date(props.conversationCreatedAt), 44 | isSmallerDevice ? 'EEE, dd MMM yyyy HH:mm' : 'EEEE, dd MMM yyyy HH:mm' 45 | )} 46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/profile/conversations-card.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { isMobile } from 'react-device-detect'; 3 | 4 | import { AspectRatio } from '@/components/ui/aspect-ratio'; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from '@/components/ui/card'; 12 | import { cn } from '@/lib/utils'; 13 | 14 | import { ConversationLink } from './conversation-link'; 15 | 16 | const MAX_DESC_LENGTH = 180; 17 | 18 | type ConvversationsCardProps = { 19 | groupedConversations: Record< 20 | string, 21 | (Conversation & { 22 | scenario: Scenario; 23 | })[] 24 | >; 25 | scenarioIds: string[]; 26 | }; 27 | 28 | export function ConversationsCard(props: ConvversationsCardProps) { 29 | return ( 30 | 31 | 32 | Your conversations 33 | 34 | Here are all the conversations you have created. Click on any one to 35 | view or resume and resubmit for a new evaluation. 36 | 37 | 38 | 39 |
40 | {props.scenarioIds.map((scenarioId) => { 41 | const scenario = props.groupedConversations[scenarioId][0].scenario; 42 | return ( 43 |
44 |
45 |
46 | 47 | {scenario.name} 61 | 62 |
63 |
64 |

{scenario.name}

65 |

66 | {scenario.description.length > MAX_DESC_LENGTH 67 | ? scenario.description.slice(0, MAX_DESC_LENGTH) + '...' 68 | : scenario.description} 69 |

70 |
71 |
72 |
73 |
74 | {props.groupedConversations[scenarioId].length > 0 && 75 | props.groupedConversations[scenarioId].map( 76 | (conversation, idx) => 77 | conversation !== undefined ? ( 78 | 85 | ) : null 86 | )} 87 |
88 |
89 |
90 | ); 91 | })} 92 |
93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /app/profile/danger-zone.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import { useCallback, useState, useTransition } from 'react'; 5 | import { toast } from 'sonner'; 6 | 7 | import { 8 | AlertDialog, 9 | AlertDialogAction, 10 | AlertDialogCancel, 11 | AlertDialogContent, 12 | AlertDialogDescription, 13 | AlertDialogFooter, 14 | AlertDialogHeader, 15 | AlertDialogTitle, 16 | } from '@/components/ui/alert-dialog'; 17 | import { Button } from '@/components/ui/button'; 18 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 19 | 20 | import { deleteUser } from './services/delete-user'; 21 | 22 | type DangerZoneProps = { 23 | userId: string; 24 | }; 25 | 26 | export function DangerZone(props: DangerZoneProps) { 27 | const [openAlert, setOpenAlert] = useState(false); 28 | const [isPending, startTransition] = useTransition(); 29 | const router = useRouter(); 30 | 31 | const handleDeleteUser = useCallback(() => { 32 | startTransition(async () => { 33 | try { 34 | await deleteUser({ userId: props.userId }); 35 | toast.success('Account deleted'); 36 | router.push('/signin'); 37 | } catch (error) { 38 | toast.error('Failed to delete account'); 39 | } 40 | }); 41 | }, [props.userId, router]); 42 | 43 | return ( 44 | 45 | 46 | 47 | Danger zone 48 | 49 | 50 | 51 |

52 | You are about to delete your account. This is irreversible and all 53 | your data will be lost. 54 |

55 | 62 |
63 | 64 | 65 | 66 | 67 | Are you absolutely sure? 68 | 69 | 70 | This action cannot be undone. This will permanently delete your 71 | account and all your data. 72 | 73 | 74 | 75 | Cancel 76 | { 79 | setOpenAlert(false); 80 | handleDeleteUser(); 81 | }} 82 | > 83 | Continue 84 | 85 | 86 | 87 | 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/profile/evaluation-link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { format } from 'date-fns'; 4 | import Link from 'next/link'; 5 | import { isMobile } from 'react-device-detect'; 6 | 7 | import { useMediaQuery } from '@/lib/use-media-query'; 8 | 9 | import { useScenarioBackground } from '../scenarios/scenario-background-provider'; 10 | 11 | type EvaluationLinkProps = { 12 | evaluationId: string; 13 | evaluationNumber: number; 14 | evaluationCreatedAt: string; 15 | scenarioImageUrl: string; 16 | }; 17 | 18 | export function EvaluationLink(props: EvaluationLinkProps) { 19 | const { setBackgroundImageUrl, setShowBackgroundImage } = 20 | useScenarioBackground(); 21 | 22 | const isSmallerDevice = useMediaQuery('(max-width: 640px)'); 23 | 24 | if (props.evaluationId === undefined) return null; 25 | 26 | return ( 27 | { 31 | if (isMobile) return; 32 | setBackgroundImageUrl(props.scenarioImageUrl); 33 | setShowBackgroundImage(true); 34 | }} 35 | onPointerOut={() => { 36 | if (isMobile) return; 37 | setShowBackgroundImage(false); 38 | }} 39 | > 40 | Evaluation {props.evaluationNumber.toString().padStart(2, '0')} 41 |
42 | {format( 43 | new Date(props.evaluationCreatedAt), 44 | isSmallerDevice ? 'EEE, dd MMM yyyy HH:mm' : 'EEEE, dd MMM yyyy HH:mm' 45 | )} 46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/profile/evaluations-card.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { isMobile } from 'react-device-detect'; 3 | 4 | import { AspectRatio } from '@/components/ui/aspect-ratio'; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from '@/components/ui/card'; 12 | import { cn } from '@/lib/utils'; 13 | 14 | import { EvaluationLink } from './evaluation-link'; 15 | 16 | const MAX_DESC_LENGTH = 180; 17 | 18 | type EvaluationsCardProps = { 19 | scenarioIds: string[]; 20 | groupedConversations: Record< 21 | string, 22 | (Conversation & { 23 | scenario: Scenario; 24 | })[] 25 | >; 26 | groupedEvaluations: Record; 27 | }; 28 | 29 | export function EvaluationsCard(props: EvaluationsCardProps) { 30 | return ( 31 | 32 | 33 | 34 | Your evaluation results 35 | 36 | 37 | Here are all the result summary that you have received from your 38 | conversations. Click on any one to view the detailed evaluation and 39 | share with others. 40 | 41 | 42 | 43 |
44 | {props.scenarioIds.map((scenarioId) => { 45 | const scenario = props.groupedConversations[scenarioId][0].scenario; 46 | return ( 47 |
48 |
49 |
50 | 51 | {scenario.name} 65 | 66 |
67 |
68 |

{scenario.name}

69 |

70 | {scenario.description.length > MAX_DESC_LENGTH 71 | ? scenario.description.slice(0, MAX_DESC_LENGTH) + '...' 72 | : scenario.description} 73 |

74 |
75 |
76 |
77 |
78 | {props.groupedEvaluations[scenarioId]?.length > 0 ? ( 79 | props.groupedEvaluations[scenarioId].map( 80 | (evaluation, idx) => ( 81 | 88 | ) 89 | ) 90 | ) : ( 91 |

92 | No evaluation results available yet. 93 |

94 | )} 95 |
96 |
97 |
98 | ); 99 | })} 100 |
101 |
102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { groupBy } from 'lodash'; 2 | import { Metadata } from 'next'; 3 | import Link from 'next/link'; 4 | 5 | import { Button } from '@/components/ui/button'; 6 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 7 | import { ScrollArea } from '@/components/ui/scroll-area'; 8 | 9 | import { ScenarioBackground } from '../scenarios/scenario-background'; 10 | import { ScenarioBackgroundProvider } from '../scenarios/scenario-background-provider'; 11 | import { openGraph } from '../shared-metadata'; 12 | import { GoogleOAuthButton } from '../signin/google-oauth-button'; 13 | import { ConversationsCard } from './conversations-card'; 14 | import { DangerZone } from './danger-zone'; 15 | import { EvaluationsCard } from './evaluations-card'; 16 | import { fetchUserConversations } from './services/fetch-user-conversations'; 17 | import { Signout } from './signout'; 18 | 19 | export const metadata: Metadata = { 20 | title: 'Convo AI | Profile', 21 | openGraph: { 22 | ...openGraph, 23 | url: '/profile', 24 | title: 'Convo AI | Profile', 25 | }, 26 | alternates: { 27 | canonical: 'https://www.convo-ai.cc/profile', 28 | }, 29 | }; 30 | 31 | export default async function Page() { 32 | const { user } = await fetchUserConversations(); 33 | const conversations = user.conversations; 34 | const conversationsOrderedByLatestDate = [...conversations].sort( 35 | (a, b) => 36 | new Date(b.created_at).getTime() - new Date(a.created_at).getTime() 37 | ); 38 | // Group conversations by scenario 39 | const groupedConversations = groupBy( 40 | conversationsOrderedByLatestDate, 41 | (conversation) => conversation.scenario.id 42 | ); 43 | const scenarioIds = Object.keys(groupedConversations); 44 | 45 | // Group evaluations by scenario 46 | const allEvaluations = conversationsOrderedByLatestDate 47 | .map((conversation) => conversation.evaluation) 48 | .filter< 49 | NonNullable 50 | >((evaluation): evaluation is Evaluation => evaluation !== null); 51 | const groupedEvaluations = groupBy( 52 | allEvaluations, 53 | (evaluation) => 54 | conversations.find( 55 | (conversation) => conversation.id === evaluation.conversation_id 56 | )?.scenario_id 57 | ); 58 | 59 | const haveAnyEvaluations = allEvaluations.length > 0; 60 | const haveAnyCnoversations = conversationsOrderedByLatestDate.length > 0; 61 | 62 | return ( 63 | 64 |
65 | 66 |
67 |

Profile

68 | {/* Anonymous user link identity*/} 69 | {user.is_anonymous && } 70 | 71 | {/* Conversations card */} 72 | {haveAnyCnoversations && ( 73 | 77 | )} 78 | 79 | {/* Evaluations card */} 80 | {haveAnyEvaluations && ( 81 | 86 | )} 87 | 88 | {/* No conversation or evaluation */} 89 | {!haveAnyCnoversations && !haveAnyEvaluations && ( 90 | 91 | 92 | 93 | Your conversations 94 | 95 | 96 | 97 |
98 | You have no conversations or evaluations yet. You can head 99 | down to the scenarios page and choose one to start! 100 | 101 | 102 | 103 |
104 |
105 |
106 | )} 107 | 108 | {/* Danger zone */} 109 | 110 | 111 | {/* Logout */} 112 | 113 |
114 |
115 | 116 |
117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /app/profile/services/delete-user.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 4 | 5 | type DeleteUserProps = { 6 | userId: string; 7 | }; 8 | 9 | export async function deleteUser(props: DeleteUserProps) { 10 | const supabase = createServerServiceRoleClient(); 11 | await supabase.auth.admin.deleteUser(props.userId); 12 | await supabase.from('users').delete().eq('id', props.userId); 13 | } 14 | -------------------------------------------------------------------------------- /app/profile/services/fetch-user-conversations.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { ConvoError } from '@/lib/convo-error'; 6 | import { createServerAnonClient } from '@/lib/supabase/server'; 7 | 8 | export async function fetchUserConversations() { 9 | const supabase = createServerAnonClient(); 10 | const { data, error } = await supabase.auth.getUser(); 11 | if (!data.user || error) { 12 | redirect('/signin'); 13 | } 14 | const fetchUserConversationsResponse = await supabase 15 | .from('users') 16 | .select( 17 | '*,conversations(*,scenario:scenarios!inner(*,llm_role:llm_roles!inner(*),goals(*),target_words!inner(*)),evaluation:evaluations(*),conversation_dialogs(*))' 18 | ) 19 | .eq('id', data.user.id) 20 | .single(); 21 | if (fetchUserConversationsResponse.error) 22 | throw new ConvoError( 23 | 'Failed to fetch user data', 24 | JSON.stringify({ 25 | code: fetchUserConversationsResponse.error.code, 26 | details: fetchUserConversationsResponse.error.details, 27 | hint: fetchUserConversationsResponse.error.hint, 28 | message: fetchUserConversationsResponse.error.message, 29 | }) 30 | ); 31 | return { 32 | user: fetchUserConversationsResponse.data, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /app/profile/services/fetch-user.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { ConvoError } from '@/lib/convo-error'; 6 | import { createServerAnonClient } from '@/lib/supabase/server'; 7 | 8 | export async function fetchUser() { 9 | const supabase = createServerAnonClient(); 10 | const { data, error } = await supabase.auth.getUser(); 11 | if (!data.user || error) redirect('/signin'); 12 | const response = await supabase 13 | .from('users') 14 | .select() 15 | .eq('id', data.user.id) 16 | .single(); 17 | if (response.error) 18 | throw new ConvoError( 19 | `Failed to fetch user ${data.user.id}`, 20 | JSON.stringify({ 21 | code: response.error.code, 22 | details: response.error.details, 23 | hint: response.error.hint, 24 | message: response.error.message, 25 | }) 26 | ); 27 | return { user: response.data }; 28 | } 29 | -------------------------------------------------------------------------------- /app/profile/services/link-with-google.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createServerAnonClient } from '@/lib/supabase/server'; 4 | 5 | /** 6 | * {@deprecated} This function is deprecated. As it is not working as expected. 7 | */ 8 | export async function linkWithGoogle({ 9 | redirectOrigin, 10 | }: { 11 | redirectOrigin: string; 12 | }) { 13 | const supabase = createServerAnonClient(); 14 | const { error } = await supabase.auth.linkIdentity({ 15 | provider: 'google', 16 | options: { 17 | redirectTo: `${redirectOrigin}/api/auth/callback`, 18 | }, 19 | }); 20 | if (error) { 21 | console.error(error); 22 | throw new Error('Failed to link account with Google'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/profile/services/sign-out-user.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { createServerAnonClient } from '@/lib/supabase/server'; 6 | 7 | export async function signoutUser() { 8 | const supabase = createServerAnonClient(); 9 | await supabase.auth.signOut(); 10 | redirect('/scenarios'); 11 | } 12 | -------------------------------------------------------------------------------- /app/profile/signout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState, useTransition } from 'react'; 4 | import { toast } from 'sonner'; 5 | 6 | import { 7 | AlertDialog, 8 | AlertDialogAction, 9 | AlertDialogCancel, 10 | AlertDialogContent, 11 | AlertDialogDescription, 12 | AlertDialogFooter, 13 | AlertDialogHeader, 14 | AlertDialogTitle, 15 | } from '@/components/ui/alert-dialog'; 16 | import { Button } from '@/components/ui/button'; 17 | 18 | import { signoutUser } from './services/sign-out-user'; 19 | 20 | type SignoutProps = { 21 | isAnonymous: boolean; 22 | }; 23 | 24 | export function Signout(props: SignoutProps) { 25 | const [isPending, startTransition] = useTransition(); 26 | const [showAnonymousWarning, setShowAnonymousWarning] = useState(false); 27 | 28 | const handleSignout = useCallback(() => { 29 | startTransition(async () => { 30 | try { 31 | await signoutUser(); 32 | } catch (error) { 33 | toast.error('Failed to sign out'); 34 | } 35 | }); 36 | }, []); 37 | 38 | return ( 39 | <> 40 | 52 | 56 | 57 | 58 | Are you sure? 59 | 60 | You are signed in as an anonymous user. Once signed out, there is 61 | no way to get back to this account and all your data will be lost. 62 | 63 | 64 | 65 | Cancel 66 | { 68 | setShowAnonymousWarning(false); 69 | handleSignout(); 70 | }} 71 | > 72 | Continue 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: '*', 7 | allow: '/', 8 | disallow: ['/profile'], 9 | }, 10 | sitemap: 'https://www.convo-ai.cc/sitemap.xml', 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /app/scenarios/category-pane.tsx: -------------------------------------------------------------------------------- 1 | import { lowerCase, startCase } from 'lodash'; 2 | import Link from 'next/link'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | type CategoryPaneProps = { 7 | categories: string[]; 8 | selectedCategory?: string; 9 | }; 10 | 11 | export function CategoryPane(props: CategoryPaneProps) { 12 | return ( 13 |
14 |

Filter by categories

15 |
16 | {props.categories.map((category) => ( 17 | 27 | {startCase(category)} 28 | 29 | ))} 30 |
31 | {props.selectedCategory && ( 32 | 36 | Clear filter 37 | 38 | )} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/scenarios/mobile-category-drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@phosphor-icons/react/dist/ssr'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'; 5 | 6 | import { CategoryPane } from './category-pane'; 7 | 8 | type MobileCategoryDrawerProps = { 9 | categories: string[]; 10 | selectedCategory?: string; 11 | }; 12 | 13 | export function MobileCategoryDrawer(props: MobileCategoryDrawerProps) { 14 | return ( 15 | 16 | 17 | 24 | 25 | 26 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/scenarios/page.tsx: -------------------------------------------------------------------------------- 1 | import { lowerCase } from 'lodash'; 2 | import { Metadata } from 'next'; 3 | 4 | import { ScrollArea } from '@/components/ui/scroll-area'; 5 | 6 | import { openGraph } from '../shared-metadata'; 7 | import { CategoryPane } from './category-pane'; 8 | import { MobileCategoryDrawer } from './mobile-category-drawer'; 9 | import { ScenarioBackground } from './scenario-background'; 10 | import { ScenarioBackgroundProvider } from './scenario-background-provider'; 11 | import { ScenarioGrid } from './scenario-grid'; 12 | import { fetchScenarios } from './services/fetch-scenarios'; 13 | 14 | export async function generateMetadata(props: { 15 | searchParams?: { category: string }; 16 | }): Promise { 17 | const filterCategory = props.searchParams?.category; 18 | return { 19 | title: `Convo AI | Scenarios ${filterCategory !== undefined ? `- ${filterCategory}` : ''}`, 20 | openGraph: { 21 | ...openGraph, 22 | url: 23 | '/scenarios' + 24 | (filterCategory !== undefined ? `?category=${filterCategory}` : ''), 25 | title: `Convo AI | Scenarios ${filterCategory !== undefined ? `- ${filterCategory}` : ''}`, 26 | }, 27 | alternates: { 28 | canonical: 29 | 'https://www.convo-ai.cc/scenarios' + 30 | (filterCategory !== undefined ? `?category=${filterCategory}` : ''), 31 | }, 32 | }; 33 | } 34 | 35 | type PageProps = { 36 | searchParams?: { 37 | category: string; 38 | }; 39 | }; 40 | 41 | export default async function Page(props: PageProps) { 42 | const { scenarios } = await fetchScenarios(); 43 | const categories = [...scenarios] 44 | .reduce((acc, scenario) => { 45 | scenario.categories.forEach((category) => { 46 | if (!acc.includes(category)) { 47 | acc.push(category); 48 | } 49 | }); 50 | return acc; 51 | }, []) 52 | .sort((a, b) => a.localeCompare(b)); 53 | 54 | const filterCategory = props.searchParams?.category; 55 | let filteredScenarios: typeof scenarios = [...scenarios].sort( 56 | (a, b) => 57 | new Date(b.created_at).getTime() - new Date(a.created_at).getTime() 58 | ); 59 | if (filterCategory) { 60 | filteredScenarios = scenarios.filter((scenario) => 61 | scenario.categories.includes(lowerCase(filterCategory)) 62 | ); 63 | } 64 | 65 | return ( 66 | 67 |
68 | 69 |
70 |
71 | 75 |
76 | 77 |
78 |
79 | {/* Mobile category drawer */} 80 | 84 | 85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/scenarios/scenario-background-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | createContext, 5 | Dispatch, 6 | SetStateAction, 7 | useContext, 8 | useState, 9 | } from 'react'; 10 | 11 | const ScenarioBackgroundContext = createContext<{ 12 | backgroundImageUrl: string | undefined; 13 | setBackgroundImageUrl: Dispatch>; 14 | showBackgroundImage: boolean; 15 | setShowBackgroundImage: Dispatch>; 16 | }>({ 17 | backgroundImageUrl: undefined, 18 | setBackgroundImageUrl: () => {}, 19 | showBackgroundImage: false, 20 | setShowBackgroundImage: () => {}, 21 | }); 22 | 23 | export function ScenarioBackgroundProvider({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) { 28 | const [backgroundImageUrl, setBackgroundImageUrl] = useState< 29 | string | undefined 30 | >(undefined); 31 | const [showBackgroundImage, setShowBackgroundImage] = useState(false); 32 | return ( 33 | 41 | {children} 42 | 43 | ); 44 | } 45 | 46 | export function useScenarioBackground() { 47 | return useContext(ScenarioBackgroundContext); 48 | } 49 | -------------------------------------------------------------------------------- /app/scenarios/scenario-background.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'framer-motion'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | import { useScenarioBackground } from './scenario-background-provider'; 7 | 8 | export function ScenarioBackground() { 9 | const { resolvedTheme } = useTheme(); 10 | const { backgroundImageUrl, showBackgroundImage } = useScenarioBackground(); 11 | 12 | return ( 13 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/scenarios/scenario-grid.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Spinner } from '@phosphor-icons/react'; 4 | import { AnimatePresence, motion, Variants } from 'framer-motion'; 5 | import Image from 'next/image'; 6 | import { useRouter } from 'next/navigation'; 7 | import { useCallback, useTransition } from 'react'; 8 | import { isMobile } from 'react-device-detect'; 9 | import { toast } from 'sonner'; 10 | 11 | import { HoverPerspectiveContainer } from '@/components/hover-perspective-container'; 12 | import { AspectRatio } from '@/components/ui/aspect-ratio'; 13 | import { cn } from '@/lib/utils'; 14 | 15 | import { useScenarioBackground } from './scenario-background-provider'; 16 | import { createConversation } from './services/create-conversation'; 17 | 18 | type ScenarioGridProps = { 19 | scenarios: Scenario[]; 20 | }; 21 | 22 | export function ScenarioGrid(props: ScenarioGridProps) { 23 | const variants: Variants = { 24 | initial: { 25 | opacity: 0, 26 | }, 27 | animate: { 28 | opacity: 1, 29 | transition: { 30 | when: 'beforeChildren', 31 | staggerChildren: 0.3, 32 | }, 33 | }, 34 | }; 35 | 36 | return ( 37 | 43 | {props.scenarios.map((scenario, idx) => ( 44 | 45 | ))} 46 | 47 | ); 48 | } 49 | 50 | type ScenarioCardProps = { 51 | scenario: Scenario; 52 | }; 53 | 54 | function ScenarioCard(props: ScenarioCardProps) { 55 | const { backgroundImageUrl, setBackgroundImageUrl, setShowBackgroundImage } = 56 | useScenarioBackground(); 57 | const router = useRouter(); 58 | const [isPending, startTransition] = useTransition(); 59 | 60 | const variants: Variants = { 61 | initial: { 62 | scale: 1, 63 | boxShadow: '0 0 0px rgba(0, 0, 0, 0)', 64 | opacity: 0, 65 | y: 20, 66 | }, 67 | animate: { 68 | scale: 1, 69 | boxShadow: '0 0 0px rgba(0, 0, 0, 0)', 70 | opacity: 1, 71 | y: 0, 72 | }, 73 | hover: { 74 | scale: 1.05, 75 | boxShadow: '0 0 30px rgba(0, 0, 0, 0.1)', 76 | transition: { 77 | ease: 'easeOut', 78 | }, 79 | }, 80 | }; 81 | 82 | const handleCreateConversation = useCallback(() => { 83 | if (isPending) return; 84 | startTransition(async () => { 85 | try { 86 | const { conversation } = await createConversation({ 87 | scenarioId: props.scenario.id, 88 | }); 89 | router.push(`/conversations/${conversation.id}`); 90 | } catch (error) { 91 | console.error(error); 92 | toast.error('Unable to create conversation.'); 93 | } 94 | }); 95 | }, [props.scenario.id, router, isPending]); 96 | 97 | return ( 98 |
{ 104 | if (isPending) return; 105 | handleCreateConversation(); 106 | }} 107 | > 108 | 109 | 110 | { 124 | if (isMobile) return; 125 | if (backgroundImageUrl !== props.scenario.image_url) { 126 | setBackgroundImageUrl(props.scenario.image_url); 127 | } 128 | setShowBackgroundImage(true); 129 | }} 130 | onPointerLeave={() => { 131 | if (isMobile) return; 132 | setShowBackgroundImage(false); 133 | }} 134 | > 135 |
136 | 140 | {props.scenario.name} 148 | 149 |
156 |

157 | {isPending ? 'Creating conversation...' : props.scenario.name} 158 |

159 | {isPending ? ( 160 | 161 | ) : ( 162 |

163 | {props.scenario.description} 164 |

165 | )} 166 |
167 |
168 |
169 |
170 |
171 |
172 | ); 173 | } 174 | -------------------------------------------------------------------------------- /app/scenarios/services/create-conversation.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { createServerAnonClient } from '@/lib/supabase/server'; 6 | 7 | export async function createConversation({ 8 | scenarioId, 9 | }: { 10 | scenarioId: string; 11 | }) { 12 | const supabase = createServerAnonClient(); 13 | const { data, error } = await supabase.auth.getUser(); 14 | console.error('data', data, 'error', error); 15 | if (!data.user || error) { 16 | return redirect('/signin'); 17 | } 18 | const response = await supabase 19 | .from('conversations') 20 | .insert({ 21 | scenario_id: scenarioId, 22 | created_by: data.user.id, 23 | }) 24 | .select() 25 | .single(); 26 | if (response.error) throw response.error; 27 | return { conversation: response.data }; 28 | } 29 | -------------------------------------------------------------------------------- /app/scenarios/services/fetch-scenarios.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { cookies } from 'next/headers'; 4 | 5 | import { ConvoError } from '@/lib/convo-error'; 6 | import { createServerServiceRoleClient } from '@/lib/supabase/server'; 7 | 8 | /** 9 | * Fetches all scenarios from the database. 10 | */ 11 | export async function fetchScenarios() { 12 | cookies(); 13 | const supabase = createServerServiceRoleClient(); 14 | const response = await supabase.from('scenarios').select('*'); 15 | if (response.error) { 16 | throw new ConvoError( 17 | 'Failed to fetch scenarios', 18 | JSON.stringify({ 19 | code: response.error.code, 20 | details: response.error.details, 21 | hint: response.error.hint, 22 | message: response.error.message, 23 | }) 24 | ); 25 | } 26 | return { 27 | scenarios: response.data, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /app/shared-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { OpenGraph } from 'next/dist/lib/metadata/types/opengraph-types'; 2 | 3 | export const openGraph: OpenGraph = { 4 | title: 'Convo AI | Boundless Conversation Practice with AI', 5 | description: 6 | 'Convo AI is an innovative edtech web app designed to enhance language learning through immersive, situational conversations. Choose from a variety of scenarios covering diverse topics and interact with an AI role-playing partner. Practice speaking freely, set conversation goals, and target specific vocabulary, all while receiving personalized feedback on your linguistic performance. Start your journey to fluent communication today with Convo.', 7 | url: '/', 8 | siteName: 'Convo AI | Boundless Conversation Practice with AI', 9 | locale: 'en_US', 10 | type: 'website', 11 | images: [ 12 | { 13 | url: '/og/title-dark.jpg', 14 | width: 756, 15 | height: 491, 16 | alt: 'Convo AI | Boundless Conversation Practice with AI', 17 | }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /app/signin/anonymous-signin-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HCaptcha from '@hcaptcha/react-hcaptcha'; 4 | import { Lightning } from '@phosphor-icons/react'; 5 | import { AnimatePresence, motion } from 'framer-motion'; 6 | import { useRouter } from 'next/navigation'; 7 | import { useCallback, useRef, useState, useTransition } from 'react'; 8 | import { toast } from 'sonner'; 9 | 10 | import { AnimatedButtonWithTransition } from '@/components/animated-button-with-transition'; 11 | 12 | import { signInAnonymously } from './services/signin-anonymously'; 13 | 14 | export function AnonymousSignInButton() { 15 | const [hCaptchaToken, setHCaptchaToken] = useState(''); 16 | const [isPending, startTransition] = useTransition(); 17 | const captchaComponentRef = useRef(null); 18 | const [showCaptcha, setShowCaptcha] = useState(false); 19 | const router = useRouter(); 20 | 21 | const handleAnonymousSignIn = useCallback(() => { 22 | if (!showCaptcha) { 23 | setShowCaptcha(true); 24 | return; 25 | } 26 | startTransition(async () => { 27 | if (!hCaptchaToken) { 28 | toast.error('Please complete the captcha'); 29 | return; 30 | } 31 | try { 32 | await signInAnonymously({ captchaToken: hCaptchaToken }); 33 | toast.success('Signed in successfully!'); 34 | router.replace('/scenarios'); 35 | router.refresh(); 36 | } catch (error) { 37 | toast.error('Failed to sign in anonymously'); 38 | } finally { 39 | captchaComponentRef.current?.resetCaptcha(); 40 | } 41 | }); 42 | }, [hCaptchaToken, router, showCaptcha]); 43 | 44 | return ( 45 |
46 | 47 | {showCaptcha && ( 48 | 54 | 59 | 60 | )} 61 | 62 |
63 | } 69 | loadingLabel={'Signing into Convo...'} 70 | variant='outline' 71 | className='w-full grow' 72 | /> 73 |

74 | By signing in anonymously, your conversations will be saved with your 75 | anonymous profile. If you sign out, clear your browsing data or switch 76 | devices, you will not be able to access your saved conversations 77 | anymore. 78 |

79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/signin/google-oauth-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { GoogleLogo } from '@phosphor-icons/react'; 4 | import { useCallback, useTransition } from 'react'; 5 | import { toast } from 'sonner'; 6 | 7 | import { AnimatedButtonWithTransition } from '@/components/animated-button-with-transition'; 8 | 9 | import { signInGoogle } from './services/signin-google'; 10 | 11 | export function GoogleOAuthButton() { 12 | const [isPending, startTransition] = useTransition(); 13 | const handleGoogleOAuth = useCallback(() => { 14 | startTransition(async () => { 15 | try { 16 | await signInGoogle(); 17 | } catch (error) { 18 | toast.error('Failed to sign in with Google'); 19 | } 20 | }); 21 | }, []); 22 | 23 | return ( 24 |
25 | } 32 | loadingLabel='Signing in with Google...' 33 | /> 34 |

35 | Consider signing in with Google to access your saved conversations 36 | across devices. 37 |

38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from '@/components/ui/card'; 10 | 11 | import { openGraph } from '../shared-metadata'; 12 | import { GoogleOAuthButton } from './google-oauth-button'; 13 | 14 | export const metadata: Metadata = { 15 | title: 'Convo AI | Sign in', 16 | openGraph: { 17 | ...openGraph, 18 | url: '/signin', 19 | title: 'Convo AI | Sign in', 20 | }, 21 | alternates: { 22 | canonical: 'https://www.convo-ai.cc/signin', 23 | }, 24 | }; 25 | 26 | export default function Page() { 27 | return ( 28 |
29 |
30 | 31 | 32 | Sign in to Convo AI 33 | 34 | Sign in start any conversation with Convo AI. You will also be able 35 | to revisit your saved conversations and evaluation results in your 36 | profile. 37 | 38 | 39 | 40 |
41 |
42 | {/* OAuth buttons */} 43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/signin/services/signin-anonymously.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { createServerAnonClient } from '@/lib/supabase/server'; 4 | 5 | export async function signInAnonymously(props: { captchaToken: string }) { 6 | const supabase = createServerAnonClient(); 7 | const { data, error } = await supabase.auth.signInAnonymously({ 8 | options: { 9 | captchaToken: props.captchaToken, 10 | }, 11 | }); 12 | if (error || !data.user) { 13 | throw new Error('Failed to sign in anonymously'); 14 | } 15 | 16 | return { 17 | user: data.user, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /app/signin/services/signin-google.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserAnonClient } from '@/lib/supabase/client'; 2 | 3 | export async function signInGoogle() { 4 | const supabase = createBrowserAnonClient(); 5 | const { error } = await supabase.auth.signInWithOAuth({ 6 | provider: 'google', 7 | options: { 8 | redirectTo: `${window.location.origin}/api/auth/callback`, 9 | }, 10 | }); 11 | if (error) { 12 | console.error(error); 13 | throw new Error('Failed to sign in with Google'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | import { fetchScenarios } from './scenarios/services/fetch-scenarios'; 4 | 5 | export default async function sitemap(): Promise { 6 | const { scenarios } = await fetchScenarios(); 7 | const categories = scenarios.reduce((acc, scenario) => { 8 | scenario.categories.forEach((category) => { 9 | if (!acc.includes(category)) acc.push(category); 10 | }); 11 | return acc; 12 | }, []); 13 | 14 | return [ 15 | { 16 | url: 'https://www.convo-ai.cc/', 17 | lastModified: new Date(), 18 | }, 19 | { 20 | url: 'https://www.convo-ai.cc/scenarios', 21 | lastModified: new Date(), 22 | }, 23 | { 24 | url: 'https://www.convo-ai.cc/profile', 25 | lastModified: new Date(), 26 | }, 27 | { 28 | url: 'https://www.convo-ai.cc/signin', 29 | lastModified: new Date(), 30 | }, 31 | ...categories.map((category) => ({ 32 | url: `https://www.convo-ai.cc/scenarios?category=${encodeURIComponent(category)}`, 33 | lastModified: new Date(), 34 | })), 35 | ]; 36 | } 37 | -------------------------------------------------------------------------------- /app/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ThemeProvider } from 'next-themes'; 4 | import type { ThemeProviderProps } from 'next-themes/dist/types'; 5 | 6 | export function NextThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /app/theme-switch-holder.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSelectedLayoutSegment } from 'next/navigation'; 4 | 5 | import { ThemeSwitch } from '@/components/theme-switch'; 6 | import { cn } from '@/lib/utils'; 7 | 8 | export function ThemeSwitchHolder() { 9 | const segment = useSelectedLayoutSegment(); 10 | const isInConversation = segment === 'conversations'; 11 | return ( 12 |
18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/twitter-image.alt.txt: -------------------------------------------------------------------------------- 1 | Convo | Boundless Conversation Practice with AI -------------------------------------------------------------------------------- /app/twitter-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/app/twitter-image.jpg -------------------------------------------------------------------------------- /app/user-signin-portal-holder.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSelectedLayoutSegment } from 'next/navigation'; 4 | 5 | type UserSigninPortalHolderProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | export function UserSigninPortalHolder(props: UserSigninPortalHolderProps) { 10 | const segment = useSelectedLayoutSegment(); 11 | const isInConversation = segment === 'conversations'; 12 | if (isInConversation) return null; 13 | return
{props.children}
; 14 | } 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/animated-button-with-transition.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Spinner } from '@phosphor-icons/react'; 4 | import { AnimatePresence, motion } from 'framer-motion'; 5 | import { MouseEventHandler } from 'react'; 6 | 7 | import { cn } from '@/lib/utils'; 8 | 9 | import { Button } from './ui/button'; 10 | 11 | type AnimatedButtonWithTransitionProps = { 12 | normalLabel: string; 13 | loadingLabel: string; 14 | normalIcon?: React.ReactNode; 15 | loadingIcon?: React.ReactNode; 16 | isPending?: boolean; 17 | onClick?: MouseEventHandler; 18 | disabled?: boolean; 19 | variant?: 20 | | 'default' 21 | | 'destructive' 22 | | 'outline' 23 | | 'secondary' 24 | | 'ghost' 25 | | 'link'; 26 | className?: string; 27 | }; 28 | 29 | export function AnimatedButtonWithTransition( 30 | props: AnimatedButtonWithTransitionProps 31 | ) { 32 | return ( 33 | 34 | 50 | 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /components/animated-text.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'framer-motion'; 4 | 5 | type AnimatedTextProps = { 6 | yOffset?: number; 7 | children: string; 8 | }; 9 | 10 | export function AnimatedText(props: AnimatedTextProps) { 11 | const variants = { 12 | hidden: { 13 | opacity: 0, 14 | y: props.yOffset ?? 20, 15 | }, 16 | visible: { 17 | opacity: 1, 18 | y: 0, 19 | transition: { 20 | duration: 1, 21 | type: 'spring', 22 | bounce: 0.5, 23 | }, 24 | }, 25 | }; 26 | 27 | return ( 28 | 36 | {props.children.split(' ').map((word, index) => ( 37 | 38 | {splitTextUsingRegex(word).map((character, index) => ( 39 | 44 | {character} 45 | 46 | ))} 47 |   48 | 49 | ))} 50 | 51 | ); 52 | } 53 | 54 | function splitTextUsingRegex(text: string) { 55 | const regex = /[\s\S]/g; 56 | const characters = []; 57 | 58 | let match; 59 | while ((match = regex.exec(text))) { 60 | characters.push(match[0]); 61 | } 62 | return characters; 63 | } 64 | -------------------------------------------------------------------------------- /components/chat-bubble/services/text-to-speech.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { openai } from '@/lib/ai/openai'; 4 | 5 | export async function readText(text: string, gender?: string) { 6 | const mp3 = await openai.audio.speech.create({ 7 | model: 'tts-1', 8 | voice: gender === 'male' ? 'echo' : 'shimmer', 9 | input: text, 10 | }); 11 | 12 | const mp3Data = await mp3.arrayBuffer(); 13 | const base64Mp3 = Buffer.from(mp3Data).toString('base64'); 14 | 15 | return base64Mp3; 16 | } 17 | -------------------------------------------------------------------------------- /components/fetch-auth-user.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { createServerAnonClient } from '@/lib/supabase/server'; 4 | 5 | export async function fetchAuthUser() { 6 | const supabase = createServerAnonClient(); 7 | const { data, error } = await supabase.auth.getUser(); 8 | if (error || !data.user) return null; 9 | const authUser = data.user; 10 | const fetchUserResponse = await supabase 11 | .from('users') 12 | .select() 13 | .eq('id', authUser.id) 14 | .single(); 15 | if (fetchUserResponse.error) { 16 | console.error(fetchUserResponse.error); 17 | throw new Error('Failed to fetch user'); 18 | } 19 | return fetchUserResponse.data; 20 | } 21 | -------------------------------------------------------------------------------- /components/hover-perspective-container.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef } from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | type HoverPerspectiveContainerProps = { 8 | children: React.ReactNode; 9 | className?: string; 10 | clipping?: boolean; 11 | }; 12 | 13 | export function HoverPerspectiveContainer({ 14 | children, 15 | className, 16 | clipping, 17 | }: HoverPerspectiveContainerProps) { 18 | const boundingRef = useRef(null); 19 | return ( 20 |
{ 27 | boundingRef.current = event.currentTarget.getBoundingClientRect(); 28 | }} 29 | onMouseLeave={(event) => { 30 | boundingRef.current = null; 31 | // Restore rotation 32 | event.currentTarget.style.transform = ''; 33 | }} 34 | onMouseMove={(event) => { 35 | if (!boundingRef.current) return; 36 | const x = event.clientX - boundingRef.current.left; 37 | const y = event.clientY - boundingRef.current.top; 38 | const xPercentage = x / boundingRef.current.width; 39 | const yPercentage = y / boundingRef.current.height; 40 | const xRotation = (xPercentage - 0.5) * 20; 41 | const yRotation = (0.5 - yPercentage) * -20; 42 | event.currentTarget.style.transform = `perspective(1000px) rotateX(${yRotation}deg) rotateY(${xRotation}deg)`; 43 | // set glare x, y position 44 | event.currentTarget.style.setProperty('--x', `${xPercentage * 100}%`); 45 | event.currentTarget.style.setProperty('--y', `${yPercentage * 100}%`); 46 | }} 47 | > 48 | {children} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/label-with-padded-digits.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | 3 | type LabelWithPaddedDigitsProps = { 4 | label: string; 5 | value: number; 6 | padding: number; 7 | highlight?: boolean; 8 | }; 9 | 10 | export function LabelWithPaddedDigits(props: LabelWithPaddedDigitsProps) { 11 | return ( 12 |
19 | {props.label} 20 |
span]:flex [&>span]:items-center [&>span]:justify-center [&>span]:text-center', 24 | 'rounded-sm bg-card/20 shadow-[inset_0_0px_5px_1px_#00000020]' 25 | )} 26 | > 27 | 28 | {props.value.toString().padStart(props.padding, '0')} 29 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/pane-group-drawer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ListChecks } from '@phosphor-icons/react'; 4 | 5 | import { useScenario } from '@/app/conversations/[conversation_id]/scenario-provider'; 6 | import { Button } from '@/components/ui/button'; 7 | import { 8 | Drawer, 9 | DrawerClose, 10 | DrawerContent, 11 | DrawerDescription, 12 | DrawerFooter, 13 | DrawerHeader, 14 | DrawerTitle, 15 | DrawerTrigger, 16 | } from '@/components/ui/drawer'; 17 | import { cn } from '@/lib/utils'; 18 | 19 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; 20 | 21 | type PaneGroupDrawerProps = { 22 | children: React.ReactNode; 23 | onDrawerClose?: () => void; 24 | }; 25 | 26 | export function PaneGroupDrawer(props: PaneGroupDrawerProps) { 27 | const { scenario } = useScenario(); 28 | 29 | return ( 30 | 31 | 32 | 33 | 37 | 43 | 44 | 45 | 46 | Open details and goals 47 | 48 | 49 | div:first-child]:absolute [&>div:first-child]:left-1/2 [&>div:first-child]:top-3 [&>div:first-child]:z-50 [&>div:first-child]:mx-auto [&>div:first-child]:h-2 [&>div:first-child]:w-[100px] [&>div:first-child]:translate-x-[-50px] [&>div:first-child]:bg-foreground/60' 53 | )} 54 | > 55 |
56 |
63 |
64 |
65 | 66 | {scenario?.name} 67 | 68 | {scenario?.description} 69 | 70 | 71 | 72 |
73 | {props.children} 74 | 75 | 76 | 77 | 78 | 79 |
80 |
81 |
82 |
83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /components/theme-switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MoonStars, Sun } from '@phosphor-icons/react'; 4 | import { motion } from 'framer-motion'; 5 | import { useTheme } from 'next-themes'; 6 | 7 | export function ThemeSwitch() { 8 | const { resolvedTheme, setTheme } = useTheme(); 9 | 10 | function switchTheme() { 11 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); 12 | } 13 | 14 | return ( 15 | { 18 | if (!document.startViewTransition) switchTheme(); 19 | document.startViewTransition(switchTheme); 20 | }} 21 | whileHover={{ 22 | scale: 1.1, 23 | }} 24 | > 25 |
26 | 37 | 38 | 39 | 40 | Toggle theme 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; 4 | import * as React from 'react'; 5 | 6 | import { buttonVariants } from '@/components/ui/button'; 7 | import { cn } from '@/lib/utils'; 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root; 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger; 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal; 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )); 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ); 60 | AlertDialogHeader.displayName = 'AlertDialogHeader'; 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ); 74 | AlertDialogFooter.displayName = 'AlertDialogFooter'; 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )); 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )); 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName; 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )); 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogAction, 132 | AlertDialogCancel, 133 | AlertDialogContent, 134 | AlertDialogDescription, 135 | AlertDialogFooter, 136 | AlertDialogHeader, 137 | AlertDialogOverlay, 138 | AlertDialogPortal, 139 | AlertDialogTitle, 140 | AlertDialogTrigger, 141 | }; 142 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root; 6 | 7 | export { AspectRatio }; 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as AvatarPrimitive from '@radix-ui/react-avatar'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarFallback, AvatarImage }; 51 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | default: 'h-9 px-4 py-2', 25 | sm: 'h-8 rounded-md px-3 text-xs', 26 | lg: 'h-10 rounded-md px-8', 27 | icon: 'h-9 w-9', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = 'Card'; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = 'CardHeader'; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = 'CardTitle'; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = 'CardDescription'; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = 'CardContent'; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = 'CardFooter'; 75 | 76 | export { 77 | Card, 78 | CardContent, 79 | CardDescription, 80 | CardFooter, 81 | CardHeader, 82 | CardTitle, 83 | }; 84 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as DialogPrimitive from "@radix-ui/react-dialog" 4 | import { Cross2Icon } from "@radix-ui/react-icons" 5 | import * as React from "react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogClose, 114 | DialogContent, 115 | DialogDescription, 116 | DialogFooter, 117 | DialogHeader, 118 | DialogOverlay, 119 | DialogPortal, 120 | DialogTitle, 121 | DialogTrigger, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ) 17 | Drawer.displayName = "Drawer" 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal 22 | 23 | const DrawerClose = DrawerPrimitive.Close 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )) 56 | DrawerContent.displayName = "DrawerContent" 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ) 67 | DrawerHeader.displayName = "DrawerHeader" 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ) 78 | DrawerFooter.displayName = "DrawerFooter" 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )) 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName 106 | 107 | export { 108 | Drawer, 109 | DrawerClose, 110 | DrawerContent, 111 | DrawerDescription, 112 | DrawerFooter, 113 | DrawerHeader, 114 | DrawerOverlay, 115 | DrawerPortal, 116 | DrawerTitle, 117 | DrawerTrigger, 118 | } 119 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = 'Input'; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as LabelPrimitive from '@radix-ui/react-label'; 4 | import { cva, type VariantProps } from 'class-variance-authority'; 5 | import * as React from 'react'; 6 | 7 | import { cn } from '@/lib/utils'; 8 | 9 | const labelVariants = cva( 10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as PopoverPrimitive from '@radix-ui/react-popover'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor; 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )); 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 32 | 33 | export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }; 34 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { Toaster as Sonner } from 'sonner'; 5 | 6 | type ToasterProps = React.ComponentProps; 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = 'system' } = useTheme(); 10 | 11 | return ( 12 | 28 | ); 29 | }; 30 | 31 | export { Toaster }; 32 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; 31 | -------------------------------------------------------------------------------- /components/user-signin-portal.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '@phosphor-icons/react/dist/ssr'; 2 | import Link from 'next/link'; 3 | 4 | import { fetchAuthUser } from './fetch-auth-user'; 5 | import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; 6 | 7 | export async function UserSigninPortal() { 8 | const user = await fetchAuthUser(); 9 | 10 | if (user) { 11 | return ( 12 | 13 | {user.photo_url ? ( 14 | 15 | 19 | 20 | 21 | 22 | 23 | ) : ( 24 |
25 | 26 |
27 | )} 28 | 29 | ); 30 | } 31 | // Not signed in case. 32 | return ( 33 | 37 | Sign in 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /lib/ai/gemini.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { GoogleGenerativeAI } from '@google/generative-ai'; 4 | 5 | const googleGenAI = new GoogleGenerativeAI(process.env.GOOGLE_GENAI_API_KEY!); 6 | 7 | /** 8 | * Get a generative model from Google's Generative AI API. 9 | */ 10 | export function getGeminiModel({ modelName }: { modelName: string }) { 11 | return googleGenAI.getGenerativeModel({ model: modelName }); 12 | } 13 | -------------------------------------------------------------------------------- /lib/ai/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | export const openai = new OpenAI(); 4 | 5 | export type OpenAIMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; 6 | 7 | type CreateCompletionOptions = { 8 | messages: OpenAIMessage[]; 9 | temperature: number; 10 | returnAsJson?: boolean; 11 | maxTokens?: number; 12 | }; 13 | 14 | export async function createCompletion(options: CreateCompletionOptions) { 15 | const completion = await openai.chat.completions.create({ 16 | messages: options.messages, 17 | model: 'gpt-4o-mini', 18 | temperature: options.temperature, 19 | response_format: { 20 | type: options.returnAsJson ? 'json_object' : 'text', 21 | }, 22 | max_tokens: options.maxTokens ?? 2000, 23 | }); 24 | const content = completion.choices.at(0)?.message.content; 25 | if (!content) { 26 | throw new Error('Failed to create completion: no content'); 27 | } 28 | return content; 29 | } 30 | -------------------------------------------------------------------------------- /lib/auth-error.ts: -------------------------------------------------------------------------------- 1 | export class ConvoAuthError extends Error { 2 | constructor(message: string, stack?: any) { 3 | super(message); 4 | this.name = 'ConvoAuthError'; 5 | this.stack = stack; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/convo-error.ts: -------------------------------------------------------------------------------- 1 | export class ConvoError extends Error { 2 | constructor(message: string, stack?: any) { 3 | super(message); 4 | this.name = 'ConvoError'; 5 | this.stack = stack; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Check, Copy } from '@phosphor-icons/react'; 4 | import { AnimatePresence, motion } from 'framer-motion'; 5 | import { useCallback, useEffect, useState } from 'react'; 6 | import { toast } from 'sonner'; 7 | 8 | import { Button } from '@/components/ui/button'; 9 | import { 10 | Tooltip, 11 | TooltipContent, 12 | TooltipTrigger, 13 | } from '@/components/ui/tooltip'; 14 | 15 | type CopyToClipboardProps = { 16 | contentToCopy: string; 17 | label: string; 18 | variant?: 'icon' | 'normal'; 19 | }; 20 | 21 | export function CopyToClipboard(props: CopyToClipboardProps) { 22 | const [hasCopied, setHasCopied] = useState(false); 23 | 24 | useEffect(() => { 25 | if (hasCopied) { 26 | const timeout = setTimeout(() => { 27 | setHasCopied(false); 28 | }, 2000); 29 | return () => clearTimeout(timeout); 30 | } 31 | }, [hasCopied]); 32 | 33 | const handleCopy = useCallback(() => { 34 | if (navigator.clipboard && !hasCopied) { 35 | navigator.clipboard 36 | .writeText(props.contentToCopy) 37 | .then(() => { 38 | setHasCopied(true); 39 | toast.success('Copied!'); 40 | }) 41 | .catch((error) => { 42 | setHasCopied(false); 43 | toast.error('Failed to copy!'); 44 | console.error(error); 45 | }); 46 | } 47 | }, [props.contentToCopy, hasCopied]); 48 | 49 | // Icon variant 50 | if (props.variant === 'icon') { 51 | return ( 52 | 53 | 54 |
55 | 56 | {hasCopied ? ( 57 | 64 | 65 | 66 | ) : ( 67 | 75 | 76 | 77 | )} 78 | 79 |
80 |
81 | {props.label} 82 |
83 | ); 84 | } 85 | // Normal variant 86 | return ( 87 | 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /lib/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Database } from '@/supabase/database.types'; 2 | 3 | declare global { 4 | export type UserProfile = Database['public']['Tables']['users']['Row']; 5 | export type Scenario = Database['public']['Tables']['scenarios']['Row']; 6 | export type LlmRole = Database['public']['Tables']['llm_roles']['Row']; 7 | export type Goal = Database['public']['Tables']['goals']['Row']; 8 | export type TargetWords = Database['public']['Tables']['target_words']['Row']; 9 | export type Evaluation = Database['public']['Tables']['evaluations']['Row']; 10 | export type Conversation = 11 | Database['public']['Tables']['conversations']['Row']; 12 | } 13 | -------------------------------------------------------------------------------- /lib/supabase/client.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createBrowserClient } from '@supabase/ssr'; 4 | 5 | import { Database } from '@/supabase/database.types'; 6 | 7 | export function createBrowserAnonClient() { 8 | return createBrowserClient( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /lib/supabase/middleware.ts: -------------------------------------------------------------------------------- 1 | import { type CookieOptions, createServerClient } from '@supabase/ssr'; 2 | import { type NextRequest, NextResponse } from 'next/server'; 3 | 4 | import { Database } from '@/supabase/database.types'; 5 | 6 | export async function updateSession(request: NextRequest) { 7 | let response = NextResponse.next({ 8 | request: { 9 | headers: request.headers, 10 | }, 11 | }); 12 | 13 | const supabase = createServerClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 16 | { 17 | cookies: { 18 | get(name: string) { 19 | return request.cookies.get(name)?.value; 20 | }, 21 | set(name: string, value: string, options: CookieOptions) { 22 | request.cookies.set({ 23 | name, 24 | value, 25 | ...options, 26 | }); 27 | response = NextResponse.next({ 28 | request: { 29 | headers: request.headers, 30 | }, 31 | }); 32 | response.cookies.set({ 33 | name, 34 | value, 35 | ...options, 36 | }); 37 | }, 38 | remove(name: string, options: CookieOptions) { 39 | request.cookies.set({ 40 | name, 41 | value: '', 42 | ...options, 43 | }); 44 | response = NextResponse.next({ 45 | request: { 46 | headers: request.headers, 47 | }, 48 | }); 49 | response.cookies.set({ 50 | name, 51 | value: '', 52 | ...options, 53 | }); 54 | }, 55 | }, 56 | } 57 | ); 58 | 59 | await supabase.auth.getUser(); 60 | 61 | return response; 62 | } 63 | -------------------------------------------------------------------------------- /lib/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { type CookieOptions, createServerClient } from '@supabase/ssr'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | import { cookies } from 'next/headers'; 6 | 7 | import { Database } from '@/supabase/database.types'; 8 | 9 | export function createServerServiceRoleClient() { 10 | return createClient( 11 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 12 | process.env.SUPABASE_SERVICE_ROLE_KEY! 13 | ); 14 | } 15 | 16 | export function createServerAnonClient() { 17 | const cookieStore = cookies(); 18 | 19 | return createServerClient( 20 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 21 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 22 | { 23 | cookies: { 24 | get(name: string) { 25 | return cookieStore.get(name)?.value; 26 | }, 27 | set(name: string, value: string, options: CookieOptions) { 28 | try { 29 | cookieStore.set({ name, value, ...options }); 30 | } catch (error) { 31 | // The `set` method was called from a Server Component. 32 | // This can be ignored if you have middleware refreshing 33 | // user sessions. 34 | } 35 | }, 36 | remove(name: string, options: CookieOptions) { 37 | try { 38 | cookieStore.set({ name, value: '', ...options }); 39 | } catch (error) { 40 | // The `delete` method was called from a Server Component. 41 | // This can be ignored if you have middleware refreshing 42 | // user sessions. 43 | } 44 | }, 45 | }, 46 | } 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /lib/use-media-query.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | 5 | export function useMediaQuery(query: string) { 6 | const [matches, setMatches] = useState(false); 7 | 8 | useEffect(() => { 9 | const media = window.matchMedia(query); 10 | if (media.matches !== matches) { 11 | setMatches(media.matches); 12 | } 13 | const listener = () => setMatches(media.matches); 14 | window.addEventListener('resize', listener); 15 | return () => window.removeEventListener('resize', listener); 16 | }, [matches, query]); 17 | 18 | return matches; 19 | } 20 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | 3 | import { updateSession } from './lib/supabase/middleware'; 4 | 5 | export async function middleware(request: NextRequest) { 6 | return await updateSession(request); 7 | } 8 | 9 | export const config = { 10 | matcher: [ 11 | /* 12 | * Match all request paths except for the ones starting with: 13 | * - _next/static (static files) 14 | * - _next/image (image optimization files) 15 | * - favicon.ico (favicon file) 16 | * Feel free to modify this pattern to include more paths. 17 | */ 18 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const appSecurityHeaders = [ 4 | { key: 'X-XSS-Protection', value: '1; mode=block' }, 5 | { 6 | key: 'X-Frame-Options', 7 | value: 'SAMEORIGIN', 8 | }, 9 | ]; 10 | 11 | const withPWA = require('next-pwa')({ 12 | dest: 'public', 13 | register: true, 14 | skipWaiting: true, 15 | }); 16 | 17 | const nextConfig = { 18 | reactStrictMode: true, 19 | logging: { 20 | fetches: { 21 | fullUrl: true, 22 | }, 23 | }, 24 | async headers() { 25 | return [ 26 | { 27 | // Apply these headers to all routes in your application. 28 | source: '/:path*', 29 | headers: appSecurityHeaders, 30 | }, 31 | ]; 32 | }, 33 | async rewrites() { 34 | return [ 35 | { 36 | source: '/sitemap.txt', 37 | destination: '/api/sitemap?type=txt', 38 | }, 39 | ]; 40 | }, 41 | compiler: { 42 | styledComponents: true, 43 | }, 44 | }; 45 | 46 | module.exports = withPWA(nextConfig); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "vercel:dev": "vercel dev", 9 | "vercel:pull": "vercel pull", 10 | "vercel:build": "vercel build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "prepare": "husky install", 14 | "gen-type": "supabase gen types typescript --project-id iclcjqlxoseepblbhnbs > supabase/database.types.ts" 15 | }, 16 | "dependencies": { 17 | "@google/generative-ai": "^0.6.0", 18 | "@hcaptcha/react-hcaptcha": "^1.10.1", 19 | "@phosphor-icons/react": "^2.1.4", 20 | "@radix-ui/react-alert-dialog": "^1.0.5", 21 | "@radix-ui/react-aspect-ratio": "^1.0.3", 22 | "@radix-ui/react-avatar": "^1.0.4", 23 | "@radix-ui/react-dialog": "^1.0.5", 24 | "@radix-ui/react-icons": "^1.3.0", 25 | "@radix-ui/react-label": "^2.0.2", 26 | "@radix-ui/react-popover": "^1.0.7", 27 | "@radix-ui/react-scroll-area": "^1.0.5", 28 | "@radix-ui/react-slot": "^1.0.2", 29 | "@radix-ui/react-tooltip": "^1.0.7", 30 | "@supabase/ssr": "^0.3.0", 31 | "@supabase/supabase-js": "^2.42.5", 32 | "@types/dom-view-transitions": "^1.0.4", 33 | "@vercel/analytics": "^1.3.1", 34 | "ai": "^3.0.22", 35 | "animejs": "^3.2.2", 36 | "class-variance-authority": "^0.7.0", 37 | "clsx": "^2.1.0", 38 | "date-fns": "^3.6.0", 39 | "eslint-plugin-react": "^7.34.1", 40 | "eslint-plugin-unused-imports": "^3.1.0", 41 | "framer-motion": "^11.0.28", 42 | "lodash": "^4.17.21", 43 | "next": "^14.2.6", 44 | "next-pwa": "^5.6.0", 45 | "next-themes": "^0.3.0", 46 | "openai": "^4.56.0", 47 | "react": "^18.3.1", 48 | "react-device-detect": "^2.2.3", 49 | "react-dom": "^18.3.1", 50 | "react-markdown": "^9.0.1", 51 | "react-speech-recognition": "^3.10.0", 52 | "regenerator-runtime": "^0.14.1", 53 | "server-only": "^0.0.1", 54 | "sonner": "^1.4.41", 55 | "supabase": "^1.190.0", 56 | "tailwind-merge": "^2.2.2", 57 | "tailwind-scrollbar-hide": "^1.1.7", 58 | "tailwindcss-animate": "^1.0.7", 59 | "vaul": "^0.9.0", 60 | "zod": "^3.22.4" 61 | }, 62 | "devDependencies": { 63 | "@tailwindcss/typography": "^0.5.12", 64 | "@types/animejs": "^3.1.12", 65 | "@types/lodash": "^4.17.0", 66 | "@types/node": "^20", 67 | "@types/react": "^18", 68 | "@types/react-dom": "^18", 69 | "@types/react-speech-recognition": "^3.9.5", 70 | "eslint": "^8", 71 | "eslint-config-next": "14.2.1", 72 | "eslint-config-prettier": "^9.1.0", 73 | "eslint-plugin-simple-import-sort": "^12.0.0", 74 | "husky": "^9.0.11", 75 | "install": "^0.13.0", 76 | "postcss": "^8", 77 | "prettier": "^3.2.5", 78 | "prettier-plugin-tailwindcss": "^0.5.13", 79 | "stylelint-config-standard": "^36.0.0", 80 | "tailwindcss": "^3.4.3", 81 | "typescript": "^5", 82 | "vercel": "^34.0.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/favicon.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#ffffff", 3 | "background_color": "#ffffff", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "Convo AI | Boundless Conversation Practice with AI", 8 | "short_name": "Convo AI", 9 | "description": "Convo AI is an innovative edtech web app designed to enhance language learning through immersive, situational conversations. Choose from a variety of scenarios covering diverse topics and interact with an AI role-playing partner. Practice speaking freely, set conversation goals, and target specific vocabulary, all while receiving personalized feedback on your linguistic performance. Start your journey to fluent communication today with Convo AI.", 10 | "categories": [ 11 | "Education", 12 | "AI", 13 | "Technology" 14 | ], 15 | "icons": [ 16 | { 17 | "src": "/favicon-16x16.png", 18 | "sizes": "16x16", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/favicon-32x32.png", 23 | "sizes": "32x32", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/android-chrome-192x192.png", 28 | "sizes": "192x192", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/android-chrome-512x512.png", 33 | "sizes": "512x512", 34 | "type": "image/png" 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /public/og/title-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/og/title-dark.jpg -------------------------------------------------------------------------------- /public/twitter-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmliszt/convo-ai/15a7b43bbcfc1136fe0589086fec991167724f24/public/twitter-image.jpg -------------------------------------------------------------------------------- /supabase/.temp/cli-latest: -------------------------------------------------------------------------------- 1 | v1.190.0 -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: '', 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: '2rem', 16 | screens: { 17 | '2xl': '1400px', 18 | }, 19 | }, 20 | screens: { 21 | sm: '640px', 22 | md: '768px', 23 | lg: '1140px', 24 | xl: '1280px', 25 | '2xl': '1536px', 26 | }, 27 | extend: { 28 | colors: { 29 | border: 'hsl(var(--border))', 30 | input: 'hsl(var(--input))', 31 | ring: 'hsl(var(--ring))', 32 | background: 'hsl(var(--background))', 33 | foreground: 'hsl(var(--foreground))', 34 | primary: { 35 | DEFAULT: 'hsl(var(--primary))', 36 | foreground: 'hsl(var(--primary-foreground))', 37 | }, 38 | secondary: { 39 | DEFAULT: 'hsl(var(--secondary))', 40 | foreground: 'hsl(var(--secondary-foreground))', 41 | }, 42 | destructive: { 43 | DEFAULT: 'hsl(var(--destructive))', 44 | foreground: 'hsl(var(--destructive-foreground))', 45 | }, 46 | muted: { 47 | DEFAULT: 'hsl(var(--muted))', 48 | foreground: 'hsl(var(--muted-foreground))', 49 | }, 50 | accent: { 51 | DEFAULT: 'hsl(var(--accent))', 52 | foreground: 'hsl(var(--accent-foreground))', 53 | }, 54 | popover: { 55 | DEFAULT: 'hsl(var(--popover))', 56 | foreground: 'hsl(var(--popover-foreground))', 57 | }, 58 | card: { 59 | DEFAULT: 'hsl(var(--card))', 60 | foreground: 'hsl(var(--card-foreground))', 61 | }, 62 | }, 63 | borderRadius: { 64 | lg: 'var(--radius)', 65 | md: 'calc(var(--radius) - 2px)', 66 | sm: 'calc(var(--radius) - 4px)', 67 | }, 68 | keyframes: { 69 | 'accordion-down': { 70 | from: { height: '0' }, 71 | to: { height: 'var(--radix-accordion-content-height)' }, 72 | }, 73 | 'accordion-up': { 74 | from: { height: 'var(--radix-accordion-content-height)' }, 75 | to: { height: '0' }, 76 | }, 77 | }, 78 | animation: { 79 | 'accordion-down': 'accordion-down 0.2s ease-out', 80 | 'accordion-up': 'accordion-up 0.2s ease-out', 81 | }, 82 | }, 83 | }, 84 | plugins: [ 85 | require('tailwindcss-animate'), 86 | require('@tailwindcss/typography'), 87 | require('tailwind-scrollbar-hide'), 88 | ], 89 | future: { 90 | hoverOnlyWhenSupported: true, 91 | }, 92 | } satisfies Config; 93 | 94 | export default config; 95 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------