├── .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 |
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 |
29 |
30 |
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 |
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 |
409 |
410 | >
411 | );
412 | }
413 |
--------------------------------------------------------------------------------