├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── Expression │ ├── block.tsx │ ├── index.tsx │ └── utils.ts ├── StepSlider.tsx ├── api │ └── chat │ │ └── route.ts ├── chatbot │ ├── chat-message.tsx │ └── index.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── icons.tsx ├── markdown.tsx └── ui │ ├── button.tsx │ ├── input.tsx │ └── separator.tsx ├── demo.png ├── lib ├── hooks │ ├── useChat.ts │ └── useSimulation.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .env 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | .yarn/install-state.gz 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive AI Tutor 2 | 3 | AI tutor that not just responds in text but engages with students by **performing actions** on the interactive activity. 4 | 5 | [![demo video](demo.png)](https://www.youtube.com/watch?v=5S-NEPKVzzY) 6 | -------------------------------------------------------------------------------- /app/Expression/block.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | value: string; 6 | first?: boolean; 7 | } & React.HTMLAttributes; 8 | 9 | export const ExpressionBlock = ({ 10 | value, 11 | className, 12 | first = false, 13 | ...restProps 14 | }: Props) => { 15 | return ( 16 |
24 | {value} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/Expression/index.tsx: -------------------------------------------------------------------------------- 1 | import { ExpressionBlock } from "@/app/Expression/block"; 2 | import { 3 | getBlockStyle, 4 | getExpressionMaxWidth, 5 | getExpressionWidth, 6 | } from "@/app/Expression/utils"; 7 | 8 | export type SymbolData = Record< 9 | string, 10 | | { 11 | type: "variable"; 12 | size: number; 13 | maxSize: number; 14 | color: string; 15 | } 16 | | { 17 | type: "constant"; 18 | size: number; 19 | color: string; 20 | } 21 | >; 22 | 23 | export type ExpressionSymbols = string[]; 24 | 25 | type Props = { 26 | symbolData: SymbolData; 27 | expression: ExpressionSymbols; 28 | expressionLabel: string; 29 | fullWidth?: boolean; 30 | }; 31 | 32 | export const Expression = ({ 33 | symbolData, 34 | expression, 35 | expressionLabel, 36 | fullWidth = false, 37 | }: Props) => { 38 | return ( 39 |
40 |
41 | {expressionLabel} 42 | 43 |
49 |
50 |
51 |
52 |
53 |
54 | 55 |
63 | {expression.map((symbol, index) => { 64 | const data = symbolData[symbol]; 65 | 66 | if (!data) return null; 67 | 68 | return ( 69 | 75 | ); 76 | })} 77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /app/Expression/utils.ts: -------------------------------------------------------------------------------- 1 | import { ExpressionSymbols, SymbolData } from "@/app/Expression"; 2 | 3 | const BLOCK_DEFAULT_WIDTH = 15; 4 | 5 | const getBlockWidth = (size: number) => size * BLOCK_DEFAULT_WIDTH; 6 | 7 | export const getBlockStyle = (size: number, color: string) => ({ 8 | width: `${getBlockWidth(size)}px`, 9 | backgroundColor: color, 10 | }); 11 | 12 | export const getExpressionMaxWidth = ( 13 | expressionSymbols: ExpressionSymbols, 14 | symbolData: SymbolData 15 | ) => { 16 | let maxWidth = 0; 17 | 18 | expressionSymbols.forEach((symbol) => { 19 | const data = symbolData[symbol]; 20 | if (!data) return; 21 | 22 | if (data.type === "variable") { 23 | maxWidth += getBlockWidth(data.maxSize); 24 | } else { 25 | maxWidth += getBlockWidth(data.size); 26 | } 27 | }); 28 | 29 | return maxWidth; 30 | }; 31 | 32 | export const getExpressionWidth = ( 33 | expressionSymbols: ExpressionSymbols, 34 | symbolData: SymbolData 35 | ) => { 36 | let width = 0; 37 | 38 | expressionSymbols.forEach((symbol) => { 39 | const data = symbolData[symbol]; 40 | if (!data) return; 41 | 42 | width += getBlockWidth(data.size); 43 | }); 44 | 45 | return width; 46 | }; 47 | -------------------------------------------------------------------------------- /app/StepSlider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { DragHandlers, motion } from "framer-motion"; 5 | 6 | const stepWidth = 50; 7 | 8 | const getStepperLeftPosition = (index: number) => stepWidth * index - 16; 9 | 10 | type Props = { 11 | label: string; 12 | color: string; 13 | steps: number[]; 14 | stepIndex: number; 15 | incrementStep: () => void; 16 | decrementStep: () => void; 17 | }; 18 | 19 | const StepSlider = ({ 20 | label, 21 | color, 22 | steps, 23 | stepIndex, 24 | incrementStep, 25 | decrementStep, 26 | }: Props) => { 27 | const handleDragEnd: DragHandlers["onDrag"] = (event, info) => { 28 | const { offset } = info; 29 | 30 | if (offset.x > 0) { 31 | incrementStep(); 32 | } else if (offset.x < 0) { 33 | decrementStep(); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 | {label} 40 | 41 |
47 |
48 | {steps[0]} 49 | 50 | {steps.slice(1, steps.length - 1).map((_, index) => ( 51 |
56 | ))} 57 | 58 |
59 | 60 | {steps[steps.length - 1]} 61 | 62 | 63 | 74 | {steps[stepIndex]} 75 | 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default StepSlider; 82 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import Instructor from "@instructor-ai/instructor"; 2 | import OpenAI from "openai"; 3 | import { z } from "zod"; 4 | 5 | const steps = [1, 2, 3, 4, 5, 6, 7]; 6 | 7 | const openaiClient = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }); 10 | 11 | const instructorClient = Instructor({ 12 | client: openaiClient, 13 | mode: "TOOLS", 14 | }); 15 | 16 | const initialStepIndicesSchema = z.object({ 17 | x: z 18 | .number() 19 | .describe( 20 | "The index for the x stepper to begin with for the array of [1,2,3,4,5,6,7]. If value of x should be 2 then return 1 as index" 21 | ), 22 | y: z 23 | .number() 24 | .describe( 25 | "The index for the y stepper for the array of [1,2,3,4,5,6,7]. If value of y should be 2 then return 1 as index" 26 | ), 27 | }); 28 | 29 | const actionSchema = z.object({ 30 | variable: z.enum(["x", "y"]), 31 | stepType: z.enum(["increment", "decrement"]), 32 | }); 33 | 34 | const actionsSchema = z 35 | .array(actionSchema) 36 | .describe( 37 | "The list of actions to perform for both steppers starting from the initial indices so that they can reach the correct answer" 38 | ); 39 | 40 | const simulationSchema = z 41 | .object({ 42 | initialStepIndices: initialStepIndicesSchema, 43 | actions: actionsSchema, 44 | }) 45 | .describe( 46 | "Set the initial indices for the variable steppers. The index will be used to get the value of the variable from the array [1,2,3,4,5,6,7]. The actions will be performed from the initial indices." 47 | ); 48 | 49 | const responseSchema = z.object({ 50 | // textResponse: z.string(), 51 | simulation: simulationSchema.nullable(), 52 | }); 53 | 54 | export async function POST(request: Request) { 55 | const { messages, variables } = await request.json(); 56 | 57 | console.log("messages", messages); 58 | 59 | const response = await openaiClient.chat.completions.create({ 60 | model: "gpt-4o", 61 | messages: [ 62 | { 63 | role: "system", 64 | content: 65 | "You are an expert socratic Math tutor who helps students by not giving the answer directly and instead giving them hints and asking questions so that they learn. You always get as input not just the students query but also the question as an image.\n\n" + 66 | "Use markdown and katex synta for your response\n\n" + 67 | "When responding decide if you just need to respond with text or with a series of actions that the learner can play on the interactive to help them think differently.\n\n" + 68 | "Question: Find a value for x and y that makes 2y + 5 = x + 5 true.\n\n" + 69 | "The question is presented as an interactive where students can drag a stepper left and right to control the 2 variables x and y from 1-7.\n" + 70 | "As they change the value of x and y they also see the length of the expression change as the width of x and y blocks change.\n\n" + 71 | "Predict what the stuent is struggling with and then think of a simulation that will help them.\n\n" + 72 | "If the student is trying to use one of the variables that needs a valid value outside the limit of 1-7 then nudge them to try a lower value that will give a valid answer.\n\n" + 73 | "Do not give multiple solution suggestions evenif they exist.\n\n" + 74 | "Do not just give a valid solution instead ask the student to try values for 1 variable when you respond with a valid solution for the other variable", 75 | }, 76 | ...messages, 77 | ], 78 | }); 79 | 80 | const textResponse = response.choices[0].message.content; 81 | 82 | const instructorResponse = await instructorClient.chat.completions.create({ 83 | model: "gpt-4-turbo", 84 | messages: [ 85 | { 86 | role: "system", 87 | content: 88 | "You will be given an AI math tutor's response to a student for an interactive activity for questions on linear equations with 2 variables where they visualise the 'size' of either side of the equation by putting in values for the variables as shown in the image\n" + 89 | "Come up with the initial indices for the variables and a series of actions to perform that match the AI tutor's response. Since you're responding with indices they will correspond to a value in the array [1,2,3,4,5,6,7]. So index 0 is 1, 1 is 2 and so on.\n\n" + 90 | "Example:\n" + 91 | "Question: Find a value for x and y that makes 2y + 5 = x + 5 true.\n" + 92 | "AI text response: Now, you need to choose values for y such that 2 times y equals x. For instance, if y is 2, what would x be? Let's adjust the y value to 2 and see what we need to set x to.\n" + 93 | "User's variable indices: xStepIndex = 5, yStepIndex = 0.\n" + 94 | "This means x = 6 and y = 1 since the array for the values is [1,2,3,4,5,6,7]\n" + 95 | "Since we want y = 2 we would increment y once\n" + 96 | "If y = 2 then x should be 4 but it is 6 so we decrement x twice.", 97 | }, 98 | { 99 | role: "user", 100 | content: 101 | `AI tutor's response: ${textResponse}\n\n` + 102 | `User's current variable indices: xStepIndex = ${ 103 | variables.xStepIndex 104 | }, x = ${steps[variables.xStepIndex]}, yStepIndex = ${ 105 | variables.yStepIndex 106 | }, y = ${steps[variables.yStepIndex]}`, 107 | }, 108 | ], 109 | response_model: { 110 | schema: responseSchema, 111 | name: "ResponseSchema", 112 | }, 113 | max_retries: 3, 114 | }); 115 | 116 | return Response.json({ 117 | simulation: instructorResponse.simulation, 118 | textResponse, 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /app/chatbot/chat-message.tsx: -------------------------------------------------------------------------------- 1 | import { IconOpenAI } from "@/components/icons"; 2 | import { MemoizedReactMarkdown } from "@/components/markdown"; 3 | import remarkGfm from "remark-gfm"; 4 | import rehypeKatex from "rehype-katex"; 5 | import remarkMath from "remark-math"; 6 | import "katex/dist/katex.min.css"; 7 | import { cn } from "@/lib/utils"; 8 | import { UserIcon } from "lucide-react"; 9 | import { Simulation } from "@/app/page"; 10 | import { Button } from "@/components/ui/button"; 11 | 12 | export type Message = { 13 | role: string; 14 | content: string; 15 | simulation?: Simulation; 16 | }; 17 | 18 | type Props = { 19 | message: Message; 20 | setSimulation: (simulation: Simulation) => void; 21 | }; 22 | 23 | export const ChatMessage = ({ message, setSimulation }: Props) => { 24 | const { role, content, simulation } = message; 25 | 26 | const isUser = role === "user"; 27 | 28 | return ( 29 |
30 |
37 | {isUser ? : } 38 |
39 | 40 |
41 | {children}

; 48 | }, 49 | }} 50 | > 51 | {content} 52 |
53 | 54 | {simulation && ( 55 | 56 | )} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /app/chatbot/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChatMessage, Message } from "@/app/chatbot/chat-message"; 2 | import { Simulation } from "@/app/page"; 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Loader2Icon, SendIcon } from "lucide-react"; 7 | import { useState } from "react"; 8 | 9 | type Props = { 10 | messages: Message[]; 11 | isLoading: boolean; 12 | onSubmit: (query: string) => void; 13 | setSimulation: (simulation: Simulation) => void; 14 | }; 15 | 16 | export const Chatbot = ({ 17 | isLoading, 18 | onSubmit, 19 | messages, 20 | setSimulation, 21 | }: Props) => { 22 | const [input, setInput] = useState(""); 23 | 24 | const handleInputChange: React.ChangeEventHandler = (e) => { 25 | setInput(e.target.value); 26 | }; 27 | 28 | const handleSubmit: React.FormEventHandler = (e) => { 29 | e.preventDefault(); 30 | 31 | const query = input.trim(); 32 | setInput(""); 33 | 34 | onSubmit(query); 35 | }; 36 | 37 | return ( 38 |
39 |
40 | { 41 |
42 | {messages.map( 43 | (message, index) => 44 | message.role !== "system" && ( 45 |
46 | 50 | 51 | {index < messages.length - 1 && ( 52 | 53 | )} 54 |
55 | ) 56 | )} 57 |
58 | } 59 | 60 |
61 | 67 | 68 | 75 |
76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/13point5/interactive-ai-tutor/2505233587c61bb1b97d08de12d9f2c8438a6f43/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Ambient AI Tutors", 9 | }; 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode; 15 | }>) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Expression } from "@/app/Expression"; 4 | import StepSlider from "@/app/StepSlider"; 5 | import { Chatbot } from "@/app/chatbot"; 6 | import { Button } from "@/components/ui/button"; 7 | import { useEffect, useRef, useState } from "react"; 8 | import html2canvas from "html2canvas"; 9 | import { useSimulation } from "@/lib/hooks/useSimulation"; 10 | import { useChat } from "@/lib/hooks/useChat"; 11 | 12 | type StepType = "increment" | "decrement"; 13 | export type Variable = "x" | "y"; 14 | type SimulationAction = { 15 | variable: Variable; 16 | stepType: StepType; 17 | }; 18 | export type Simulation = { 19 | initialStepIndices: Record; 20 | actions: SimulationAction[]; 21 | }; 22 | 23 | export const steps = [1, 2, 3, 4, 5, 6, 7]; 24 | 25 | const sampleSimulation: Simulation = { 26 | initialStepIndices: { 27 | x: 2, 28 | y: 1, 29 | }, 30 | actions: [ 31 | { 32 | variable: "x", 33 | stepType: "increment", 34 | }, 35 | { 36 | variable: "x", 37 | stepType: "increment", 38 | }, 39 | { 40 | variable: "y", 41 | stepType: "increment", 42 | }, 43 | { 44 | variable: "x", 45 | stepType: "increment", 46 | }, 47 | { 48 | variable: "y", 49 | stepType: "increment", 50 | }, 51 | ], 52 | }; 53 | 54 | export default function Home() { 55 | const questionAreaRef = useRef(null); 56 | 57 | const [yStepIndex, setYStepIndex] = useState(0); 58 | const [xStepIndex, setXStepIndex] = useState(0); 59 | 60 | const [simulation, setSimulation] = useState(null); 61 | 62 | const incrementStep = (variable: Variable) => { 63 | if (variable === "x") { 64 | setXStepIndex((prev) => Math.min(prev + 1, steps.length - 1)); 65 | } else if (variable === "y") { 66 | setYStepIndex((prev) => Math.min(prev + 1, steps.length - 1)); 67 | } 68 | }; 69 | 70 | const decrementStep = (variable: Variable) => { 71 | if (variable === "x") { 72 | setXStepIndex((prev) => Math.max(0, prev - 1)); 73 | } else if (variable === "y") { 74 | setYStepIndex((prev) => Math.max(0, prev - 1)); 75 | } 76 | }; 77 | 78 | const { start: handleStartSimulation } = useSimulation({ 79 | simulation, 80 | resetSimulation: () => setSimulation(null), 81 | xStepIndex, 82 | setXStepIndex, 83 | yStepIndex, 84 | setYStepIndex, 85 | incrementStep, 86 | decrementStep, 87 | }); 88 | 89 | console.log("simulation", simulation); 90 | 91 | useEffect(() => { 92 | handleStartSimulation(); 93 | }, [simulation]); 94 | 95 | const { isLoading, messages, handleSubmit } = useChat(); 96 | console.log("messages", messages); 97 | 98 | const handleQuerySubmit = async (query: string) => { 99 | if (!questionAreaRef.current) { 100 | return; 101 | } 102 | 103 | const canvas = await html2canvas(questionAreaRef.current); 104 | const base64Image = canvas.toDataURL("image/png"); 105 | 106 | await handleSubmit({ 107 | query, 108 | image: base64Image, 109 | variables: { xStepIndex, yStepIndex }, 110 | }); 111 | }; 112 | 113 | return ( 114 |
115 |
116 |

117 | Ambient AI Tutor 118 |

119 |

120 | AI tutor that not just responds in text but{" "} 121 | 122 | interacts 123 | {" "} 124 | with students by{" "} 125 | 126 | controlling 127 | {" "} 128 | the interactive activity. 129 |

130 |
131 | 132 |
133 |
137 |
138 |

139 | Find a value for x and for{" "} 140 | y that makes{" "} 141 | 2y + 5 = x + 5 true. 142 |

143 | 144 |
145 | 162 | 163 | 181 |
182 |
183 | 184 | incrementStep("y")} 190 | decrementStep={() => decrementStep("y")} 191 | /> 192 | 193 | incrementStep("x")} 199 | decrementStep={() => decrementStep("x")} 200 | /> 201 |
202 | 203 | 209 |
210 |
211 | ); 212 | } 213 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export const IconOpenAI = ({ 4 | className, 5 | ...props 6 | }: React.ComponentProps<"svg">) => { 7 | return ( 8 | 16 | OpenAI icon 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from "react"; 2 | import ReactMarkdown, { Options } from "react-markdown"; 3 | 4 | export const MemoizedReactMarkdown: FC = memo( 5 | ReactMarkdown, 6 | (prevProps, nextProps) => 7 | prevProps.children === nextProps.children && 8 | prevProps.className === nextProps.className 9 | ); 10 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /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/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/13point5/interactive-ai-tutor/2505233587c61bb1b97d08de12d9f2c8438a6f43/demo.png -------------------------------------------------------------------------------- /lib/hooks/useChat.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "@/app/chatbot/chat-message"; 2 | import { steps } from "@/app/page"; 3 | import { useState } from "react"; 4 | 5 | export const useChat = () => { 6 | const [messages, setMessages] = useState([]); 7 | 8 | const [isLoading, setIsLoading] = useState(false); 9 | 10 | const handleSubmit = async ({ 11 | query, 12 | image, 13 | variables, 14 | }: { 15 | query: string; 16 | image: string; 17 | variables: { 18 | xStepIndex: number; 19 | yStepIndex: number; 20 | }; 21 | }) => { 22 | setIsLoading(true); 23 | 24 | const newMessages = [ 25 | ...messages, 26 | { 27 | role: "user", 28 | content: query, 29 | }, 30 | ]; 31 | 32 | setMessages(newMessages); 33 | 34 | const result = await fetch("/api/chat", { 35 | method: "POST", 36 | body: JSON.stringify({ 37 | variables, 38 | messages: [ 39 | { 40 | role: "user", 41 | content: [ 42 | { 43 | type: "text", 44 | text: `User's attempts: xStepIndex = ${ 45 | variables.xStepIndex 46 | }, x = ${steps[variables.xStepIndex]}, yStepIndex = ${ 47 | variables.yStepIndex 48 | }, y = ${steps[variables.yStepIndex]}.\nUser's query: ${query}`, 49 | }, 50 | { 51 | type: "image_url", 52 | image_url: { 53 | url: image, 54 | }, 55 | }, 56 | ], 57 | }, 58 | ], 59 | }), 60 | }); 61 | const res = await result.json(); 62 | console.log("res", res); 63 | 64 | setMessages((prev) => [ 65 | ...prev, 66 | { 67 | role: "assistant", 68 | content: res.textResponse, 69 | simulation: res.simulation, 70 | }, 71 | ]); 72 | 73 | setIsLoading(false); 74 | }; 75 | 76 | return { messages, isLoading, handleSubmit }; 77 | }; 78 | -------------------------------------------------------------------------------- /lib/hooks/useSimulation.ts: -------------------------------------------------------------------------------- 1 | import { Simulation, Variable } from "@/app/page"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | export const useSimulation = ({ 7 | simulation, 8 | resetSimulation, 9 | xStepIndex, 10 | setXStepIndex, 11 | yStepIndex, 12 | setYStepIndex, 13 | incrementStep, 14 | decrementStep, 15 | }: { 16 | simulation: Simulation | null; 17 | resetSimulation: () => void; 18 | xStepIndex: number; 19 | setXStepIndex: (value: number) => void; 20 | yStepIndex: number; 21 | setYStepIndex: (value: number) => void; 22 | incrementStep: (variable: Variable) => void; 23 | decrementStep: (variable: Variable) => void; 24 | }) => { 25 | const [simulationIndex, setSimulationIndex] = useState(-1); 26 | 27 | const handleStartSimulation = () => { 28 | console.log("simulation", simulation); 29 | if (!simulation) return; 30 | 31 | setXStepIndex(simulation.initialStepIndices.x); 32 | setYStepIndex(simulation.initialStepIndices.y); 33 | 34 | setSimulationIndex(0); 35 | }; 36 | 37 | const performAction = async () => { 38 | if (simulationIndex === -1) return; 39 | 40 | if (!simulation) return; 41 | 42 | if (simulationIndex < simulation.actions.length) { 43 | await delay(500); 44 | 45 | const action = simulation.actions[simulationIndex]; 46 | if (action.stepType === "increment") { 47 | incrementStep(action.variable); 48 | } else if (action.stepType === "decrement") { 49 | decrementStep(action.variable); 50 | } 51 | } 52 | 53 | if (simulationIndex === simulation.actions.length - 1) { 54 | resetSimulation(); 55 | } 56 | }; 57 | 58 | useEffect(() => { 59 | if (simulationIndex >= 0) { 60 | performAction(); 61 | } 62 | }, [simulationIndex]); 63 | 64 | useEffect(() => { 65 | if (!simulation) return; 66 | 67 | if ( 68 | simulationIndex >= 0 && 69 | simulationIndex < simulation.actions.length - 1 70 | ) { 71 | setSimulationIndex((prev) => prev + 1); 72 | } 73 | }, [yStepIndex, xStepIndex]); 74 | 75 | return { 76 | start: handleStartSimulation, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ambient-ai-tutors", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@instructor-ai/instructor": "^1.4.0", 13 | "@radix-ui/react-separator": "^1.0.3", 14 | "@radix-ui/react-slot": "^1.0.2", 15 | "class-variance-authority": "^0.7.0", 16 | "clsx": "^2.1.1", 17 | "framer-motion": "^11.2.10", 18 | "html2canvas": "^1.4.1", 19 | "lucide-react": "^0.395.0", 20 | "next": "14.2.4", 21 | "openai": "^4.51.0", 22 | "react": "^18", 23 | "react-dom": "^18", 24 | "react-markdown": "^9.0.1", 25 | "rehype-katex": "^7.0.0", 26 | "remark-gfm": "^4.0.0", 27 | "remark-math": "^6.0.0", 28 | "tailwind-merge": "^2.3.0", 29 | "tailwindcss-animate": "^1.0.7", 30 | "zod": "^3.22.4" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^20", 34 | "@types/react": "^18", 35 | "@types/react-dom": "^18", 36 | "eslint": "^8", 37 | "eslint-config-next": "14.2.4", 38 | "postcss": "^8", 39 | "tailwindcss": "^3.4.1", 40 | "typescript": "^5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /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/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config; 79 | 80 | export default config; 81 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------