├── .eslintrc.json ├── public ├── pg.jpeg └── favicon.ico ├── styles └── globals.css ├── postcss.config.js ├── next.config.js ├── components ├── Answer │ ├── answer.module.css │ └── Answer.tsx ├── Navbar.tsx └── Footer.tsx ├── tailwind.config.js ├── pages ├── _document.tsx ├── _app.tsx ├── api │ ├── answer.ts │ └── search.ts └── index.tsx ├── .gitignore ├── tsconfig.json ├── types └── index.ts ├── license ├── schema.sql ├── package.json ├── scripts └── embed_tweet.ts ├── utils └── index.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/pg.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adshao/haoel-gpt/HEAD/public/pg.jpeg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adshao/haoel-gpt/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /components/Answer/answer.module.css: -------------------------------------------------------------------------------- 1 | .fadeIn { 2 | animation: fadeIn 0.5s ease-in-out forwards; 3 | opacity: 0; 4 | } 5 | 6 | @keyframes fadeIn { 7 | from { 8 | opacity: 0; 9 | } 10 | to { 11 | opacity: 1; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./app/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {} 6 | }, 7 | plugins: [] 8 | }; 9 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import { Inter } from "@next/font/google"; 3 | import type { AppProps } from "next/app"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export default function App({ Component, pageProps }: AppProps<{}>) { 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/answer.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIStream } from "@/utils"; 2 | 3 | export const config = { 4 | runtime: "edge" 5 | }; 6 | 7 | const handler = async (req: Request): Promise => { 8 | try { 9 | const { prompt, apiKey } = (await req.json()) as { 10 | prompt: string; 11 | apiKey: string; 12 | }; 13 | 14 | const stream = await OpenAIStream(prompt, apiKey); 15 | 16 | return new Response(stream); 17 | } catch (error) { 18 | console.error(error); 19 | return new Response("Error", { status: 500 }); 20 | } 21 | }; 22 | 23 | export default handler; 24 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # tsn 39 | tsn.json 40 | 41 | .env 42 | out.log 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /components/Answer/Answer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import styles from "./answer.module.css"; 3 | 4 | interface AnswerProps { 5 | text: string; 6 | } 7 | 8 | export const Answer: React.FC = ({ text }) => { 9 | const [words, setWords] = useState([]); 10 | 11 | useEffect(() => { 12 | setWords(text.split(" ")); 13 | }, [text]); 14 | 15 | return ( 16 |
17 | {words.map((word, index) => ( 18 | 23 | {word}{" "} 24 | 25 | ))} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { IconExternalLink } from "@tabler/icons-react"; 2 | import { FC } from "react"; 3 | 4 | export const Navbar: FC = () => { 5 | return ( 6 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export enum OpenAIModel { 2 | DAVINCI_TURBO = "gpt-3.5-turbo" 3 | } 4 | 5 | export type PGEssay = { 6 | tweet_url: string; 7 | tweet_date: string; 8 | content: string; 9 | length: number; 10 | tokens: number; 11 | }; 12 | 13 | export type PGChunk = { 14 | essay_title: string; 15 | essay_url: string; 16 | essay_date: string; 17 | essay_thanks: string; 18 | content: string; 19 | content_length: number; 20 | content_tokens: number; 21 | embedding: number[]; 22 | }; 23 | 24 | export type PGJSON = { 25 | current_date: string; 26 | author: string; 27 | url: string; 28 | length: number; 29 | tokens: number; 30 | tweets: PGEssay[]; 31 | }; 32 | 33 | // Replace the following import line: 34 | // import { PGEssay, PGJSON } from "@/types"; 35 | // with these type definitions: 36 | 37 | export type Tweet = { 38 | tweet_url: string; 39 | tweet_date: string; 40 | content: string; 41 | author: string; 42 | embedding: number[]; 43 | }; 44 | 45 | export type TweetJSON = { 46 | tweets: Tweet[]; 47 | }; 48 | 49 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mckay Wrigley 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 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | -- RUN 1st 2 | create extension vector; 3 | 4 | -- RUN 2nd 5 | create table pg ( 6 | id bigserial primary key, 7 | tweet_url text, 8 | tweet_date timestamp without time zone, 9 | content text, 10 | author text, 11 | embedding vector (1536) 12 | ); 13 | 14 | -- RUN 3rd after running the scripts 15 | create or replace function pg_search ( 16 | query_embedding vector(1536), 17 | similarity_threshold float, 18 | match_count int 19 | ) 20 | returns table ( 21 | id bigint, 22 | tweet_url text, 23 | tweet_date timestamp without time zone, 24 | content text, 25 | author text, 26 | similarity float 27 | ) 28 | language plpgsql 29 | as $$ 30 | begin 31 | return query 32 | select 33 | pg.id, 34 | pg.tweet_url, 35 | pg.tweet_date, 36 | pg.content, 37 | pg.author, 38 | 1 - (pg.embedding <=> query_embedding) as similarity 39 | from pg 40 | where 1 - (pg.embedding <=> query_embedding) > similarity_threshold 41 | order by pg.embedding <=> query_embedding 42 | limit match_count; 43 | end; 44 | $$; 45 | 46 | -- RUN 4th 47 | create index on pg 48 | using ivfflat (embedding vector_cosine_ops) 49 | with (lists = 100); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "haoel-gpt", 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 | "scrape": "tsx scripts/scrape.ts", 11 | "embed": "tsx scripts/embed_tweet.ts" 12 | }, 13 | "dependencies": { 14 | "@next/font": "^13.2.3", 15 | "@tabler/icons-react": "^2.4.0", 16 | "@types/node": "18.14.0", 17 | "@types/react": "18.0.28", 18 | "@types/react-dom": "18.0.11", 19 | "csv-parser": "^3.0.0", 20 | "endent": "^2.1.0", 21 | "eslint": "8.34.0", 22 | "eslint-config-next": "13.1.6", 23 | "eventsource-parser": "^0.1.0", 24 | "next": "13.1.6", 25 | "react": "18.2.0", 26 | "react-dom": "18.2.0", 27 | "typescript": "4.9.5" 28 | }, 29 | "devDependencies": { 30 | "@next/env": "^13.2.3", 31 | "@supabase/supabase-js": "^2.8.0", 32 | "autoprefixer": "^10.4.13", 33 | "axios": "^1.3.4", 34 | "cheerio": "^1.0.0-rc.12", 35 | "gpt-3-encoder": "^1.1.4", 36 | "openai": "^3.1.0", 37 | "postcss": "^8.4.21", 38 | "tailwindcss": "^3.2.7", 39 | "tsx": "^3.12.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { IconBrandGithub, IconBrandTwitter } from "@tabler/icons-react"; 2 | import { FC } from "react"; 3 | 4 | export const Footer: FC = () => { 5 | return ( 6 |
7 |
8 | 9 |
10 | Created by 11 | 17 | Adam Shao 18 | 19 | based on the tweets of 20 | 26 | Hao Chen 左耳朵耗子 27 | 28 |
29 | 30 |
31 | 37 | 38 | 39 | 40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /pages/api/search.ts: -------------------------------------------------------------------------------- 1 | import { supabaseAdmin } from "@/utils"; 2 | 3 | export const config = { 4 | runtime: "edge" 5 | }; 6 | 7 | const handler = async (req: Request): Promise => { 8 | try { 9 | const { query, apiKey, matches } = (await req.json()) as { 10 | query: string; 11 | apiKey: string; 12 | matches: number; 13 | }; 14 | 15 | const input = query.replace(/\n/g, " "); 16 | 17 | const res = await fetch("https://api.openai.com/v1/embeddings", { 18 | headers: { 19 | "Content-Type": "application/json", 20 | Authorization: `Bearer ${apiKey}` 21 | }, 22 | method: "POST", 23 | body: JSON.stringify({ 24 | model: "text-embedding-ada-002", 25 | input 26 | }) 27 | }); 28 | 29 | const json = await res.json(); 30 | const embedding = json.data[0].embedding; 31 | 32 | const { data: chunks, error } = await supabaseAdmin.rpc("pg_search", { 33 | query_embedding: embedding, 34 | similarity_threshold: 0.01, 35 | match_count: matches 36 | }); 37 | 38 | if (error) { 39 | console.error(error); 40 | return new Response("Error", { status: 500 }); 41 | } 42 | 43 | return new Response(JSON.stringify(chunks), { status: 200 }); 44 | } catch (error) { 45 | console.error(error); 46 | return new Response("Error", { status: 500 }); 47 | } 48 | }; 49 | 50 | export default handler; 51 | -------------------------------------------------------------------------------- /scripts/embed_tweet.ts: -------------------------------------------------------------------------------- 1 | import { Tweet, TweetJSON } from "@/types"; 2 | import { loadEnvConfig } from "@next/env"; 3 | import { createClient } from "@supabase/supabase-js"; 4 | import fs from "fs"; 5 | import csv from 'csv-parser'; 6 | import { Configuration, OpenAIApi } from "openai"; 7 | 8 | loadEnvConfig(""); 9 | 10 | const generateEmbeddings = async (tweets: Tweet[]) => { 11 | const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY }); 12 | const openai = new OpenAIApi(configuration); 13 | 14 | const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!); 15 | 16 | const rows = await Promise.all(tweets.map(async (tweet) => { 17 | const { tweet_url, tweet_date, content, author } = tweet; 18 | 19 | const embeddingResponse = await openai.createEmbedding({ 20 | model: "text-embedding-ada-002", 21 | input: content 22 | }); 23 | 24 | const [{ embedding }] = embeddingResponse.data.data; 25 | 26 | return { 27 | tweet_url: tweet_url, 28 | tweet_date: tweet_date.split(' ')[0] + ' ' + tweet_date.split(' ')[1], 29 | content: content, 30 | author: author, 31 | embedding: embedding 32 | }; 33 | })); 34 | 35 | const { data, error } = await supabase.from("pg").insert(rows).select("*"); 36 | 37 | if (error) { 38 | console.log("error", error); 39 | } else { 40 | console.log("saved", rows.length); 41 | } 42 | 43 | await new Promise((resolve) => setTimeout(resolve, 200)); 44 | }; 45 | 46 | let tweets: Tweet[] = []; 47 | 48 | (async () => { 49 | fs.createReadStream('../twint/haoel.csv') 50 | .pipe(csv()) 51 | .on('data', (row) => { 52 | // Parse the necessary fields from each row 53 | let tweet_url = row['url']; 54 | let tweet_date = row['Date Created']; 55 | let content = row['Text']; 56 | let author = 'haoel'; 57 | 58 | // Create a new tweet object and add it to the array 59 | tweets.push({ tweet_url, tweet_date, content, author, embedding: [] }); 60 | }) 61 | .on('end', async () => { 62 | // Filter tweets 63 | const filteredTweets = tweets.filter(tweet => new Date(tweet.tweet_date) < new Date('2022-07-15 06:03:13')); 64 | 65 | // Generate embeddings 66 | for (let i = 0; i < filteredTweets.length; i += 100) { 67 | await generateEmbeddings(filteredTweets.slice(i, i + 100)); 68 | } 69 | }); 70 | })(); -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIModel } from "@/types"; 2 | import { createClient } from "@supabase/supabase-js"; 3 | import { createParser, ParsedEvent, ReconnectInterval } from "eventsource-parser"; 4 | 5 | export const supabaseAdmin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!); 6 | 7 | export const OpenAIStream = async (prompt: string, apiKey: string) => { 8 | const encoder = new TextEncoder(); 9 | const decoder = new TextDecoder(); 10 | 11 | const res = await fetch("https://api.openai.com/v1/chat/completions", { 12 | headers: { 13 | "Content-Type": "application/json", 14 | Authorization: `Bearer ${apiKey}` 15 | }, 16 | method: "POST", 17 | body: JSON.stringify({ 18 | model: OpenAIModel.DAVINCI_TURBO, 19 | messages: [ 20 | { 21 | role: "system", 22 | content: "You are a helpful assistant that accurately answers queries using tweets from Hao Chen's twitter account. Use the text provided to form your answer, but avoid copying word-for-word from the essays. Try to use your own words when possible. Keep your answer under 10 sentences. Be accurate, helpful, concise, and clear. 除非要求你使用其它语言,请尽量用中文回答。" 23 | }, 24 | { 25 | role: "user", 26 | content: prompt 27 | } 28 | ], 29 | max_tokens: 500, 30 | temperature: 0.0, 31 | stream: true 32 | }) 33 | }); 34 | 35 | if (res.status !== 200) { 36 | throw new Error("OpenAI API returned an error"); 37 | } 38 | 39 | const stream = new ReadableStream({ 40 | async start(controller) { 41 | const onParse = (event: ParsedEvent | ReconnectInterval) => { 42 | if (event.type === "event") { 43 | const data = event.data; 44 | 45 | if (data === "[DONE]") { 46 | controller.close(); 47 | return; 48 | } 49 | 50 | try { 51 | const json = JSON.parse(data); 52 | const text = json.choices[0].delta.content; 53 | const queue = encoder.encode(text); 54 | controller.enqueue(queue); 55 | } catch (e) { 56 | controller.error(e); 57 | } 58 | } 59 | }; 60 | 61 | const parser = createParser(onParse); 62 | 63 | for await (const chunk of res.body as any) { 64 | parser.feed(decoder.decode(chunk)); 65 | } 66 | } 67 | }); 68 | 69 | return stream; 70 | }; 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hao Chen GPT 2 | 3 | AI-powered search and chat for [Hao Chen's](https://twitter.com/haoel) tweets. 4 | 5 | All code & data used is 100% open-source. 6 | 7 | ## Dataset 8 | 9 | The dataset is a CSV file containing all tweets from [here](https://github.com/yihong0618/twint/blob/master/haoel.csv). 10 | 11 | ## How It Works 12 | 13 | Hao Chen GPT provides 2 things: 14 | 15 | 1. A search interface. 16 | 2. A chat interface. 17 | 18 | ### Search 19 | 20 | Search was created with [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings) (`text-embedding-ada-002`). 21 | 22 | First, we loop over the essays and generate embeddings for each chunk of text. 23 | 24 | Then in the app we take the user's search query, generate an embedding, and use the result to find the most similar passages from the book. 25 | 26 | The comparison is done using cosine similarity across our database of vectors. 27 | 28 | Our database is a Postgres database with the [pgvector](https://github.com/pgvector/pgvector) extension hosted on [Supabase](https://supabase.com/). 29 | 30 | Results are ranked by similarity score and returned to the user. 31 | 32 | ### Chat 33 | 34 | Chat builds on top of search. It uses search results to create a prompt that is fed into GPT-3.5-turbo. 35 | 36 | This allows for a chat-like experience where the user can ask questions about the book and get answers. 37 | 38 | ## Running Locally 39 | 40 | Here's a quick overview of how to run it locally. 41 | 42 | ### Requirements 43 | 44 | 1. Set up OpenAI 45 | 46 | You'll need an OpenAI API key to generate embeddings. 47 | 48 | 2. Set up Supabase and create a database 49 | 50 | Note: You don't have to use Supabase. Use whatever method you prefer to store your data. But I like Supabase and think it's easy to use. 51 | 52 | There is a schema.sql file in the root of the repo that you can use to set up the database. 53 | 54 | Run that in the SQL editor in Supabase as directed. 55 | 56 | I recommend turning on Row Level Security and setting up a service role to use with the app. 57 | 58 | ### Repo Setup 59 | 60 | 3. Clone repo 61 | 62 | ```bash 63 | git clone https://github.com/adshao/haoel-gpt.git 64 | ``` 65 | 66 | 4. Install dependencies 67 | 68 | ```bash 69 | npm i 70 | ``` 71 | 72 | 5. Set up environment variables 73 | 74 | Create a .env.local file in the root of the repo with the following variables: 75 | 76 | ```bash 77 | OPENAI_API_KEY= 78 | 79 | NEXT_PUBLIC_SUPABASE_URL= 80 | SUPABASE_SERVICE_ROLE_KEY= 81 | ``` 82 | 83 | ### Dataset 84 | 85 | 6. Run scraping script 86 | 87 | ```bash 88 | npm run scrape 89 | ``` 90 | 91 | This scrapes all of the essays from Hao Chen's website and saves them to a json file. 92 | 93 | 7. Run embedding script 94 | 95 | ```bash 96 | npm run embed 97 | ``` 98 | 99 | This reads the json file, generates embeddings for each chunk of text, and saves the results to your database. 100 | 101 | There is a 200ms delay between each request to avoid rate limiting. 102 | 103 | This process will take 20-30 minutes. 104 | 105 | ### App 106 | 107 | 8. Run app 108 | 109 | ```bash 110 | npm run dev 111 | ``` 112 | 113 | ## Credits 114 | 115 | Thanks to [Hao Chen](https://twitter.com/haoel) for his writing. 116 | 117 | I highly recommend you read his essays. 118 | 119 | 3 years ago they convinced me to learn to code, and it changed my life. 120 | 121 | ## Contact 122 | 123 | If you have any questions, feel free to reach out to me on [Twitter](https://twitter.com/AdamShao)! 124 | 125 | ## Notes 126 | 127 | I sacrificed composability for simplicity in the app. 128 | 129 | Yes, you can make things more modular and reusable. 130 | 131 | But I kept pretty much everything in the homepage component for the sake of simplicity. 132 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Answer } from "@/components/Answer/Answer"; 2 | import { Footer } from "@/components/Footer"; 3 | import { Navbar } from "@/components/Navbar"; 4 | import { Tweet } from "@/types"; 5 | import { IconArrowRight, IconExternalLink, IconSearch } from "@tabler/icons-react"; 6 | import endent from "endent"; 7 | import Head from "next/head"; 8 | import { KeyboardEvent, useEffect, useRef, useState } from "react"; 9 | 10 | export default function Home() { 11 | const inputRef = useRef(null); 12 | 13 | const [query, setQuery] = useState(""); 14 | const [chunks, setChunks] = useState([]); 15 | const [answer, setAnswer] = useState(""); 16 | const [loading, setLoading] = useState(false); 17 | 18 | const [showSettings, setShowSettings] = useState(false); 19 | const [mode, setMode] = useState<"search" | "chat">("chat"); 20 | const [matchCount, setMatchCount] = useState(5); 21 | const [apiKey, setApiKey] = useState(""); 22 | 23 | const handleSearch = async () => { 24 | if (!apiKey) { 25 | alert("Please enter an API key."); 26 | return; 27 | } 28 | 29 | if (!query) { 30 | alert("Please enter a query."); 31 | return; 32 | } 33 | 34 | setAnswer(""); 35 | setChunks([]); 36 | 37 | setLoading(true); 38 | 39 | const searchResponse = await fetch("/api/search", { 40 | method: "POST", 41 | headers: { 42 | "Content-Type": "application/json" 43 | }, 44 | body: JSON.stringify({ query, apiKey, matches: matchCount }) 45 | }); 46 | 47 | if (!searchResponse.ok) { 48 | setLoading(false); 49 | throw new Error(searchResponse.statusText); 50 | } 51 | 52 | const results: Tweet[] = await searchResponse.json(); 53 | 54 | setChunks(results); 55 | 56 | setLoading(false); 57 | 58 | inputRef.current?.focus(); 59 | 60 | return results; 61 | }; 62 | 63 | const handleAnswer = async () => { 64 | if (!apiKey) { 65 | alert("Please enter an API key."); 66 | return; 67 | } 68 | 69 | if (!query) { 70 | alert("Please enter a query."); 71 | return; 72 | } 73 | 74 | setAnswer(""); 75 | setChunks([]); 76 | 77 | setLoading(true); 78 | 79 | const searchResponse = await fetch("/api/search", { 80 | method: "POST", 81 | headers: { 82 | "Content-Type": "application/json" 83 | }, 84 | body: JSON.stringify({ query, apiKey, matches: matchCount }) 85 | }); 86 | 87 | if (!searchResponse.ok) { 88 | setLoading(false); 89 | throw new Error(searchResponse.statusText); 90 | } 91 | 92 | const results: Tweet[] = await searchResponse.json(); 93 | 94 | setChunks(results); 95 | 96 | const prompt = endent` 97 | Use the following passages to provide an answer to the query: "${query}" 98 | 99 | ${results?.map((d: any) => d.content).join("\n\n")} 100 | `; 101 | 102 | const answerResponse = await fetch("/api/answer", { 103 | method: "POST", 104 | headers: { 105 | "Content-Type": "application/json" 106 | }, 107 | body: JSON.stringify({ prompt, apiKey }) 108 | }); 109 | 110 | if (!answerResponse.ok) { 111 | setLoading(false); 112 | throw new Error(answerResponse.statusText); 113 | } 114 | 115 | const data = answerResponse.body; 116 | 117 | if (!data) { 118 | return; 119 | } 120 | 121 | setLoading(false); 122 | 123 | const reader = data.getReader(); 124 | const decoder = new TextDecoder(); 125 | let done = false; 126 | 127 | while (!done) { 128 | const { value, done: doneReading } = await reader.read(); 129 | done = doneReading; 130 | const chunkValue = decoder.decode(value); 131 | setAnswer((prev) => prev + chunkValue); 132 | } 133 | 134 | inputRef.current?.focus(); 135 | }; 136 | 137 | const handleKeyDown = (e: KeyboardEvent) => { 138 | if (e.key === "Enter") { 139 | if (mode === "search") { 140 | handleSearch(); 141 | } else { 142 | handleAnswer(); 143 | } 144 | } 145 | }; 146 | 147 | const handleSave = () => { 148 | if (apiKey.length !== 51) { 149 | alert("Please enter a valid API key."); 150 | return; 151 | } 152 | 153 | localStorage.setItem("PG_KEY", apiKey); 154 | localStorage.setItem("PG_MATCH_COUNT", matchCount.toString()); 155 | localStorage.setItem("PG_MODE", mode); 156 | 157 | setShowSettings(false); 158 | inputRef.current?.focus(); 159 | }; 160 | 161 | const handleClear = () => { 162 | localStorage.removeItem("PG_KEY"); 163 | localStorage.removeItem("PG_MATCH_COUNT"); 164 | localStorage.removeItem("PG_MODE"); 165 | 166 | setApiKey(""); 167 | setMatchCount(5); 168 | setMode("search"); 169 | }; 170 | 171 | useEffect(() => { 172 | if (matchCount > 10) { 173 | setMatchCount(10); 174 | } else if (matchCount < 1) { 175 | setMatchCount(1); 176 | } 177 | }, [matchCount]); 178 | 179 | useEffect(() => { 180 | const PG_KEY = localStorage.getItem("PG_KEY"); 181 | const PG_MATCH_COUNT = localStorage.getItem("PG_MATCH_COUNT"); 182 | const PG_MODE = localStorage.getItem("PG_MODE"); 183 | 184 | if (PG_KEY) { 185 | setApiKey(PG_KEY); 186 | } 187 | 188 | if (PG_MATCH_COUNT) { 189 | setMatchCount(parseInt(PG_MATCH_COUNT)); 190 | } 191 | 192 | if (PG_MODE) { 193 | setMode(PG_MODE as "search" | "chat"); 194 | } 195 | 196 | inputRef.current?.focus(); 197 | }, []); 198 | 199 | return ( 200 | <> 201 | 202 | Chen Hao GPT 203 | 207 | 211 | 215 | 216 | 217 |
218 | 219 |
220 |
221 | 227 | 228 | {showSettings && ( 229 |
230 |
231 |
Mode
232 | 240 |
241 | 242 |
243 |
Tweet Count
244 | setMatchCount(Number(e.target.value))} 250 | className="max-w-[400px] block w-full rounded-md border border-gray-300 p-2 text-black shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm" 251 | /> 252 |
253 | 254 |
255 |
OpenAI API Key
256 | { 262 | setApiKey(e.target.value); 263 | 264 | if (e.target.value.length !== 51) { 265 | setShowSettings(true); 266 | } 267 | }} 268 | /> 269 |
270 | 271 |
272 |
276 | Save 277 |
278 | 279 |
283 | Clear 284 |
285 |
286 |
287 | )} 288 | 289 | {apiKey.length === 51 ? ( 290 |
291 | 292 | 293 | setQuery(e.target.value)} 300 | onKeyDown={handleKeyDown} 301 | /> 302 | 303 | 309 |
310 | ) : ( 311 |
312 | Please enter your 313 | 317 | OpenAI API key 318 | 319 | in settings. 320 |
321 | )} 322 | 323 | {loading ? ( 324 |
325 | {mode === "chat" && ( 326 | <> 327 |
Answer
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 | 336 | )} 337 | 338 |
Tweets
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 | ) : answer ? ( 348 |
349 |
Answer
350 | 351 | 352 |
353 |
Tweets
354 | 355 | {chunks.map((chunk, index) => ( 356 |
357 |
358 |
359 |
360 | {/*
{chunk.tweet_title}
*/} 361 |
{chunk.tweet_date}
362 |
363 | 369 | 370 | 371 |
372 |
{chunk.content}
373 |
374 |
375 | ))} 376 |
377 |
378 | ) : chunks.length > 0 ? ( 379 |
380 |
Tweets
381 | {chunks.map((chunk, index) => ( 382 |
383 |
384 |
385 |
386 | {/*
{chunk.tweet_title}
*/} 387 |
{chunk.tweet_date}
388 |
389 | 395 | 396 | 397 |
398 |
{chunk.content}
399 |
400 |
401 | ))} 402 |
403 | ) : ( 404 |
{`AI-powered search & chat for @haoel's tweets.`}
405 | )} 406 |
407 |
408 |
410 | 411 | ); 412 | } 413 | --------------------------------------------------------------------------------