├── .eslintrc.json ├── .example.env ├── .gitignore ├── .prettierrc ├── README.md ├── app ├── api │ ├── getChat │ │ └── route.ts │ ├── getParsedSources │ │ └── route.ts │ └── getSources │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components ├── Chat.tsx ├── FinalInputArea.tsx ├── Footer.tsx ├── Header.tsx ├── Hero.tsx ├── InitialInputArea.tsx ├── Sources.tsx ├── TypeAnimation.tsx └── logo.tsx ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── arrow-up.svg ├── basketball-new.svg ├── desktop-screenshot.png ├── finance.svg ├── github.svg ├── light-new.svg ├── new-bg.png ├── new-logo.svg ├── og-image.png ├── screenshot-mobile.png ├── similarTopics.svg ├── simple-logo.png ├── simple-logo.svg ├── togethercomputer.png ├── twitter.svg ├── up-arrow.svg └── us.svg ├── tailwind.config.ts ├── tsconfig.json └── utils ├── TogetherAIStream.ts └── utils.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | # Note: You either need BING_SEARCH_API or SERPER_API_KEY, not both 2 | TOGETHER_API_KEY= 3 | BING_API_KEY= 4 | SERPER_API_KEY= 5 | HELICONE_API_KEY= 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | .env 39 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { "plugins": ["prettier-plugin-tailwindcss"] } 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Llama Tutor 3 |

Llama Tutor

4 |
5 | 6 |

7 | An open source AI personal tutor. Powered by Llama 3 70B & Together.ai 8 |

9 | 10 | ## Tech stack 11 | 12 | - Llama 3.1 70B from Meta for the LLM 13 | - Together AI for LLM inference 14 | - Next.js app router with Tailwind 15 | - Serper for the search API 16 | - Helicone for observability 17 | - Plausible for website analytics 18 | 19 | ## Cloning & running 20 | 21 | 1. Fork or clone the repo 22 | 2. Create an account at [Together AI](https://togetherai.link) for the LLM 23 | 3. Create an account at [SERP API](https://serper.dev/) or with Azure ([Bing Search API](https://www.microsoft.com/en-us/bing/apis/bing-web-search-api)) 24 | 4. Create an account at [Helicone](https://www.helicone.ai/) for observability 25 | 5. Create a `.env` (use the `.example.env` for reference) and replace the API keys 26 | 6. Run `npm install` and `npm run dev` to install dependencies and run locally 27 | 28 | ## Future Tasks 29 | 30 | - [ ] Add a share & copy buttons that folks can click on after convos are generated 31 | - [ ] Add potential follow up questions + new chat at the end of chat page 32 | - [ ] Split the page into two pages and add back the footer 33 | - [ ] Move all my icons into their own typescript file (transform.tools) 34 | - [ ] Add a more detailed landing page with a nice section with the GitHub link 35 | - [ ] Add nice hamburger menu on mobile 36 | - [ ] Try out the generative UI stuff from Vercel 37 | - [ ] Add a nicer dropdown overall 38 | -------------------------------------------------------------------------------- /app/api/getChat/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TogetherAIStream, 3 | TogetherAIStreamPayload, 4 | } from "@/utils/TogetherAIStream"; 5 | 6 | export async function POST(request: Request) { 7 | let { messages } = await request.json(); 8 | 9 | console.log("messages", messages); 10 | try { 11 | console.log("[getChat] Fetching answer stream from Together API"); 12 | const payload: TogetherAIStreamPayload = { 13 | model: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", 14 | messages, 15 | stream: true, 16 | }; 17 | const stream = await TogetherAIStream(payload); 18 | 19 | return new Response(stream, { 20 | headers: new Headers({ 21 | "Cache-Control": "no-cache", 22 | }), 23 | }); 24 | } catch (e) { 25 | return new Response("Error. Answer stream failed.", { status: 202 }); 26 | } 27 | } 28 | 29 | export const runtime = "edge"; 30 | -------------------------------------------------------------------------------- /app/api/getParsedSources/route.ts: -------------------------------------------------------------------------------- 1 | import { Readability } from "@mozilla/readability"; 2 | import jsdom, { JSDOM } from "jsdom"; 3 | import { cleanedText, fetchWithTimeout } from "@/utils/utils"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export const maxDuration = 30; 7 | 8 | export async function POST(request: Request) { 9 | let { sources } = await request.json(); 10 | 11 | console.log("[getAnswer] Fetching text from source URLS"); 12 | let finalResults = await Promise.all( 13 | sources.map(async (result: any) => { 14 | try { 15 | // Fetch the source URL, or abort if it's been 3 seconds 16 | const response = await fetchWithTimeout(result.url); 17 | const html = await response.text(); 18 | const virtualConsole = new jsdom.VirtualConsole(); 19 | const dom = new JSDOM(html, { virtualConsole }); 20 | 21 | const doc = dom.window.document; 22 | const parsed = new Readability(doc).parse(); 23 | let parsedContent = parsed 24 | ? cleanedText(parsed.textContent) 25 | : "Nothing found"; 26 | 27 | return { 28 | ...result, 29 | fullContent: parsedContent, 30 | }; 31 | } catch (e) { 32 | console.log(`error parsing ${result.name}, error: ${e}`); 33 | return { 34 | ...result, 35 | fullContent: "not available", 36 | }; 37 | } 38 | }), 39 | ); 40 | 41 | return NextResponse.json(finalResults); 42 | } 43 | -------------------------------------------------------------------------------- /app/api/getSources/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { z } from "zod"; 3 | 4 | let excludedSites = ["youtube.com"]; 5 | let searchEngine: "bing" | "serper" = "serper"; 6 | 7 | export async function POST(request: Request) { 8 | let { question } = await request.json(); 9 | 10 | const finalQuestion = `what is ${question}`; 11 | 12 | if (searchEngine === "bing") { 13 | const BING_API_KEY = process.env["BING_API_KEY"]; 14 | if (!BING_API_KEY) { 15 | throw new Error("BING_API_KEY is required"); 16 | } 17 | 18 | const params = new URLSearchParams({ 19 | q: `${finalQuestion} ${excludedSites.map((site) => `-site:${site}`).join(" ")}`, 20 | mkt: "en-US", 21 | count: "6", 22 | safeSearch: "Strict", 23 | }); 24 | 25 | const response = await fetch( 26 | `https://api.bing.microsoft.com/v7.0/search?${params}`, 27 | { 28 | method: "GET", 29 | headers: { 30 | "Ocp-Apim-Subscription-Key": BING_API_KEY, 31 | }, 32 | }, 33 | ); 34 | 35 | const BingJSONSchema = z.object({ 36 | webPages: z.object({ 37 | value: z.array(z.object({ name: z.string(), url: z.string() })), 38 | }), 39 | }); 40 | 41 | const rawJSON = await response.json(); 42 | const data = BingJSONSchema.parse(rawJSON); 43 | 44 | let results = data.webPages.value.map((result) => ({ 45 | name: result.name, 46 | url: result.url, 47 | })); 48 | 49 | return NextResponse.json(results); 50 | // TODO: Figure out a way to remove certain results like YT 51 | } else if (searchEngine === "serper") { 52 | const SERPER_API_KEY = process.env["SERPER_API_KEY"]; 53 | if (!SERPER_API_KEY) { 54 | throw new Error("SERPER_API_KEY is required"); 55 | } 56 | 57 | const response = await fetch("https://google.serper.dev/search", { 58 | method: "POST", 59 | headers: { 60 | "X-API-KEY": SERPER_API_KEY, 61 | "Content-Type": "application/json", 62 | }, 63 | body: JSON.stringify({ 64 | q: finalQuestion, 65 | num: 9, 66 | }), 67 | }); 68 | 69 | const rawJSON = await response.json(); 70 | 71 | const SerperJSONSchema = z.object({ 72 | organic: z.array(z.object({ title: z.string(), link: z.string() })), 73 | }); 74 | 75 | const data = SerperJSONSchema.parse(rawJSON); 76 | 77 | let results = data.organic.map((result) => ({ 78 | name: result.title, 79 | url: result.link, 80 | })); 81 | 82 | return NextResponse.json(results); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/llamatutor/457519ed2b3af83f838de7911b0cbb4546fbb8a7/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | /* Hide scrollbar for Chrome, Safari and Opera */ 10 | .no-scrollbar::-webkit-scrollbar { 11 | display: none; 12 | } 13 | /* Hide scrollbar for IE, Edge and Firefox */ 14 | .no-scrollbar { 15 | -ms-overflow-style: none; /* IE and Edge */ 16 | scrollbar-width: none; /* Firefox */ 17 | } 18 | .loader { 19 | text-align: left; 20 | display: flex; 21 | gap: 3px; 22 | } 23 | 24 | .loader span { 25 | display: inline-block; 26 | vertical-align: middle; 27 | width: 7px; 28 | height: 7px; 29 | /* background: #4b4b4b; */ 30 | background: white; 31 | border-radius: 50%; 32 | animation: loader 0.6s infinite alternate; 33 | } 34 | 35 | .loader span:nth-of-type(2) { 36 | animation-delay: 0.2s; 37 | } 38 | 39 | .loader span:nth-of-type(3) { 40 | animation-delay: 0.6s; 41 | } 42 | 43 | @keyframes loader { 44 | 0% { 45 | opacity: 1; 46 | transform: scale(0.6); 47 | } 48 | 49 | 100% { 50 | opacity: 0.3; 51 | transform: scale(1); 52 | } 53 | } 54 | } 55 | 56 | body { 57 | margin: 0px !important; 58 | } 59 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Montserrat } from "next/font/google"; 3 | import PlausibleProvider from "next-plausible"; 4 | import "./globals.css"; 5 | import Image from "next/image"; 6 | import bgImage from "../public/new-bg.png"; 7 | 8 | const montserrat = Montserrat({ subsets: ["latin"] }); 9 | 10 | let title = "Llama Tutor – AI Personal Tutor"; 11 | let description = "Learn faster with our open source AI personal tutor"; 12 | let url = "https://llamatutor.com/"; 13 | let ogimage = "https://llamatutor.together.ai/og-image.png"; 14 | let sitename = "llamatutor.com"; 15 | 16 | export const metadata: Metadata = { 17 | metadataBase: new URL(url), 18 | title, 19 | description, 20 | icons: { 21 | icon: "/favicon.ico", 22 | }, 23 | openGraph: { 24 | images: [ogimage], 25 | title, 26 | description, 27 | url: url, 28 | siteName: sitename, 29 | locale: "en_US", 30 | type: "website", 31 | }, 32 | twitter: { 33 | card: "summary_large_image", 34 | images: [ogimage], 35 | title, 36 | description, 37 | }, 38 | }; 39 | 40 | export default function RootLayout({ 41 | children, 42 | }: Readonly<{ 43 | children: React.ReactNode; 44 | }>) { 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 54 | 59 | {children} 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Footer from "@/components/Footer"; 4 | import Header from "@/components/Header"; 5 | import Hero from "@/components/Hero"; 6 | import Sources from "@/components/Sources"; 7 | import { useState } from "react"; 8 | import { 9 | createParser, 10 | ParsedEvent, 11 | ReconnectInterval, 12 | } from "eventsource-parser"; 13 | import { getSystemPrompt } from "@/utils/utils"; 14 | import Chat from "@/components/Chat"; 15 | 16 | export default function Home() { 17 | const [inputValue, setInputValue] = useState(""); 18 | const [topic, setTopic] = useState(""); 19 | const [showResult, setShowResult] = useState(false); 20 | const [sources, setSources] = useState<{ name: string; url: string }[]>([]); 21 | const [isLoadingSources, setIsLoadingSources] = useState(false); 22 | const [messages, setMessages] = useState<{ role: string; content: string }[]>( 23 | [], 24 | ); 25 | const [loading, setLoading] = useState(false); 26 | const [ageGroup, setAgeGroup] = useState("Middle School"); 27 | 28 | const handleInitialChat = async () => { 29 | setShowResult(true); 30 | setLoading(true); 31 | setTopic(inputValue); 32 | setInputValue(""); 33 | 34 | await handleSourcesAndChat(inputValue); 35 | 36 | setLoading(false); 37 | }; 38 | 39 | const handleChat = async (messages?: { role: string; content: string }[]) => { 40 | setLoading(true); 41 | const chatRes = await fetch("/api/getChat", { 42 | method: "POST", 43 | headers: { 44 | "Content-Type": "application/json", 45 | }, 46 | body: JSON.stringify({ messages }), 47 | }); 48 | 49 | if (!chatRes.ok) { 50 | throw new Error(chatRes.statusText); 51 | } 52 | 53 | // This data is a ReadableStream 54 | const data = chatRes.body; 55 | if (!data) { 56 | return; 57 | } 58 | let fullAnswer = ""; 59 | 60 | const onParse = (event: ParsedEvent | ReconnectInterval) => { 61 | if (event.type === "event") { 62 | const data = event.data; 63 | try { 64 | const text = JSON.parse(data).text ?? ""; 65 | fullAnswer += text; 66 | // Update messages with each chunk 67 | setMessages((prev) => { 68 | const lastMessage = prev[prev.length - 1]; 69 | if (lastMessage.role === "assistant") { 70 | return [ 71 | ...prev.slice(0, -1), 72 | { ...lastMessage, content: lastMessage.content + text }, 73 | ]; 74 | } else { 75 | return [...prev, { role: "assistant", content: text }]; 76 | } 77 | }); 78 | } catch (e) { 79 | console.error(e); 80 | } 81 | } 82 | }; 83 | 84 | // https://web.dev/streams/#the-getreader-and-read-methods 85 | const reader = data.getReader(); 86 | const decoder = new TextDecoder(); 87 | const parser = createParser(onParse); 88 | let done = false; 89 | 90 | while (!done) { 91 | const { value, done: doneReading } = await reader.read(); 92 | done = doneReading; 93 | const chunkValue = decoder.decode(value); 94 | parser.feed(chunkValue); 95 | } 96 | setLoading(false); 97 | }; 98 | 99 | async function handleSourcesAndChat(question: string) { 100 | setIsLoadingSources(true); 101 | let sourcesResponse = await fetch("/api/getSources", { 102 | method: "POST", 103 | body: JSON.stringify({ question }), 104 | }); 105 | let sources; 106 | if (sourcesResponse.ok) { 107 | sources = await sourcesResponse.json(); 108 | 109 | setSources(sources); 110 | } else { 111 | setSources([]); 112 | } 113 | setIsLoadingSources(false); 114 | 115 | const parsedSourcesRes = await fetch("/api/getParsedSources", { 116 | method: "POST", 117 | body: JSON.stringify({ sources }), 118 | }); 119 | let parsedSources; 120 | if (parsedSourcesRes.ok) { 121 | parsedSources = await parsedSourcesRes.json(); 122 | } 123 | 124 | const initialMessage = [ 125 | { role: "system", content: getSystemPrompt(parsedSources, ageGroup) }, 126 | { role: "user", content: `${question}` }, 127 | ]; 128 | setMessages(initialMessage); 129 | await handleChat(initialMessage); 130 | } 131 | 132 | return ( 133 | <> 134 |
135 | 136 |
139 | {showResult ? ( 140 |
141 |
142 |
143 | 152 | 153 |
154 |
155 |
156 | ) : ( 157 | 165 | )} 166 |
167 | {/*