├── .eslintrc.json ├── .gitignore ├── README.md ├── components ├── Answer │ ├── Answer.tsx │ └── answer.module.css ├── Import │ ├── EmbeddingButton.tsx │ ├── Import.tsx │ └── ImportButton.tsx ├── Kindle │ ├── BookDisplay.tsx │ ├── BookList.tsx │ └── BookListItem.tsx ├── Layout │ ├── Footer.tsx │ └── Navbar.tsx ├── Passage.tsx ├── Search.tsx └── Settings.tsx ├── license ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ └── answer.ts └── index.tsx ├── postcss.config.js ├── public └── favicon.ico ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── types └── index.ts └── utils ├── app └── index.ts └── server └── index.ts /.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 | # 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kindle GPT 2 | 3 | AI search & chat on your Kindle highlights. 4 | 5 | Supports .csv exporting of your embedded data. 6 | 7 | Code is 100% open source. 8 | 9 | Note: I recommend using on desktop only. 10 | 11 | ## How It Works 12 | 13 | ### Export Kindle Notebook 14 | 15 | In the Kindle App you can export your highlights as a notebook. 16 | 17 | The notebook provides you with a .html file of your highlights. 18 | 19 | ### Import & Parse Kindle Highlights 20 | 21 | Import the .html file into the app. 22 | 23 | It will parse the highlights and display them. 24 | 25 | ### Generate Embeddings 26 | 27 | After parsing is complete, the highlights are ready to be embedded. 28 | 29 | Kindle GPT uses [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings) (`text-embedding-ada-002`) to generate embeddings for each highlight. 30 | 31 | The embedded text is the chapter/section name + the highlighted text. I found this to be the best way to get the most relevant passages. 32 | 33 | You will also receive a downloaded .csv file of your embedded notebook to use wherever you'd like - including for importing to Kindle GPT for later use. 34 | 35 | ### Search Embedded Highlights 36 | 37 | Now you can query your highlights using the search bar. 38 | 39 | The 1st step is to get the cosine similarity for your query and all of the highlights. 40 | 41 | Then, the most relevant results are returned (maxing out at ~2k tokens, up to 10). 42 | 43 | ### Create Prompt & Generate Answer 44 | 45 | The results are used to create a prompt that feeds into GPT-3.5-turbo. 46 | 47 | And finally, you get your answer! 48 | 49 | ## Data 50 | 51 | All data is stored locally. 52 | 53 | Kindle GPT doesn't use a database. 54 | 55 | You can re-import any of your generated .csv files at any time to avoid having to re-embed your notebooks. 56 | 57 | ## Running Locally 58 | 59 | 1. Set up OpenAI 60 | 61 | You'll need an OpenAI API key to generate embeddings and perform chat completions. 62 | 63 | 2. Clone repo 64 | 65 | ```bash 66 | git clone https://github.com/mckaywrigley/kindle-gpt.git 67 | ``` 68 | 69 | 3. Install dependencies 70 | 71 | ```bash 72 | npm i 73 | ``` 74 | 75 | 4. Run app 76 | 77 | ```bash 78 | npm run dev 79 | ``` 80 | 81 | ## Contact 82 | 83 | If you have any questions, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley)! 84 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /components/Import/EmbeddingButton.tsx: -------------------------------------------------------------------------------- 1 | import { KindleEmbedding, KindleNotebook } from "@/types"; 2 | import { IconArrowRight } from "@tabler/icons-react"; 3 | import { Configuration, OpenAIApi } from "openai"; 4 | import { FC, useEffect, useState } from "react"; 5 | 6 | interface EmbeddingButtonProps { 7 | book: KindleNotebook; 8 | apiKey: string; 9 | onEmbedding: (notebook: KindleNotebook) => void; 10 | onLoadingChange: (loading: boolean) => void; 11 | onLoadingMessageChange: (message: string) => void; 12 | } 13 | 14 | export const EmbeddingButton: FC = ({ book, apiKey, onEmbedding, onLoadingChange, onLoadingMessageChange }) => { 15 | const [length, setLength] = useState(0); 16 | 17 | const handleEmbeddings = async (notebook: KindleNotebook, apiKey: string) => { 18 | onLoadingChange(true); 19 | 20 | const configuration = new Configuration({ apiKey }); 21 | const openai = new OpenAIApi(configuration); 22 | 23 | let count = 1; 24 | const totalCount = notebook.highlights.reduce((acc, section) => acc + section.highlights.length, 0); 25 | 26 | let embeddings: KindleEmbedding[] = []; 27 | 28 | for (let i = 0; i < notebook.highlights.length; i++) { 29 | const section = notebook.highlights[i]; 30 | 31 | for (let j = 0; j < section.highlights.length; j++) { 32 | const highlight = section.highlights[j]; 33 | 34 | const textToEmbed = `${section.sectionTitle}. ${highlight.highlight}`; 35 | 36 | if (textToEmbed.length / 4 < 6000) { 37 | const res = await openai.createEmbedding({ 38 | model: "text-embedding-ada-002", 39 | input: `${section.sectionTitle}. ${highlight.highlight}` 40 | }); 41 | 42 | const [{ embedding }] = res.data.data; 43 | 44 | if (!embedding) { 45 | continue; 46 | } 47 | 48 | const newEmbedding: KindleEmbedding = { 49 | title: notebook.title, 50 | author: notebook.author, 51 | sectionTitle: section.sectionTitle, 52 | type: highlight.type, 53 | page: highlight.page, 54 | highlight: highlight.highlight, 55 | embedding 56 | }; 57 | 58 | embeddings.push(newEmbedding); 59 | } 60 | 61 | onLoadingMessageChange(`Embedding ${count} of ${totalCount}`); 62 | count++; 63 | 64 | await new Promise((resolve) => setTimeout(resolve, 500)); 65 | } 66 | } 67 | 68 | const embeddedNotebook: KindleNotebook = { 69 | ...notebook, 70 | embeddings 71 | }; 72 | 73 | onEmbedding(embeddedNotebook); 74 | 75 | const headers = "title,author,sectionTitle,type,page,highlight,embedding"; 76 | const csv = embeddings 77 | .map((row) => { 78 | const embeddingString = row.embedding.join(","); 79 | return `"${row.title}","${row.author}","${row.sectionTitle}","${row.type}","${row.page}","${row.highlight}","[${embeddingString}]"`; 80 | }) 81 | .join("\n"); 82 | const csvData = `${headers}\n${csv}`; 83 | 84 | const blob = new Blob([csvData], { type: "text/csv;charset=utf-8;" }); 85 | const link = document.createElement("a"); 86 | link.href = URL.createObjectURL(blob); 87 | link.download = `${embeddedNotebook.title}-embedded.csv`; 88 | link.click(); 89 | 90 | onLoadingChange(false); 91 | }; 92 | 93 | const getCost = (num: number) => { 94 | const tokensEstimate = num / 4; 95 | const cost = (tokensEstimate / 1000) * 0.0004; 96 | 97 | return `OpenAI Cost: ~$${cost.toFixed(4)} (Yes. It's that cheap.)`; 98 | }; 99 | 100 | useEffect(() => { 101 | let count = 0; 102 | 103 | for (let i = 0; i < book.highlights.length; i++) { 104 | const section = book.highlights[i]; 105 | 106 | for (let j = 0; j < section.highlights.length; j++) { 107 | const highlight = section.highlights[j]; 108 | count += highlight.highlight.length; 109 | } 110 | } 111 | 112 | setLength(count); 113 | }, []); 114 | 115 | return ( 116 |
117 |
Import complete.
118 |
Generate embeddings for this notebook to enable chat.
119 |
You will also get a .csv file of your data.
120 |
{`${getCost(length)}`}
121 | 122 | 131 |
132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /components/Import/Import.tsx: -------------------------------------------------------------------------------- 1 | import { KindleNotebook } from "@/types"; 2 | import { FC, useState } from "react"; 3 | import { EmbeddingButton } from "./EmbeddingButton"; 4 | import { ImportButton } from "./ImportButton"; 5 | 6 | interface ImportProps { 7 | book: KindleNotebook | undefined; 8 | apiKey: string; 9 | onImport: (notebook: KindleNotebook) => void; 10 | onEmbedding: (notebook: KindleNotebook) => void; 11 | onClick: () => void; 12 | } 13 | 14 | export const Import: FC = ({ book, apiKey, onImport, onEmbedding, onClick }) => { 15 | const [loading, setLoading] = useState(false); 16 | const [loadingMessage, setLoadingMessage] = useState(""); 17 | 18 | return ( 19 |
20 | {loading ? ( 21 |
22 |
This may take several minutes.
23 |
Do not close this window.
24 | 25 |
{loadingMessage}
26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ) : book ? ( 36 | book.embeddings.length > 0 ? ( 37 | 43 | ) : ( 44 |
45 | 51 | 52 | 59 |
60 | ) 61 | ) : ( 62 | 68 | )} 69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /components/Import/ImportButton.tsx: -------------------------------------------------------------------------------- 1 | import { KindleEmbedding, KindleNotebook, KindleSection } from "@/types"; 2 | import { parseHighlights } from "@/utils/app"; 3 | import { IconBookUpload } from "@tabler/icons-react"; 4 | import { FC } from "react"; 5 | 6 | interface ImportButtonProps { 7 | text: string; 8 | onImport: (notebook: KindleNotebook) => void; 9 | onLoadingChange: (loading: boolean) => void; 10 | onLoadingMessageChange: (message: string) => void; 11 | } 12 | 13 | export const ImportButton: FC = ({ text, onImport, onLoadingChange, onLoadingMessageChange }) => { 14 | const readCsvFile = async (file: File) => { 15 | onLoadingChange(true); 16 | 17 | onLoadingMessageChange("Importing Kindle Notebook..."); 18 | 19 | const reader = new FileReader(); 20 | 21 | reader.onload = async (e) => { 22 | const text = e.target?.result; 23 | 24 | let importedNotebook: KindleNotebook = { 25 | title: "", 26 | author: "", 27 | highlights: [], 28 | embeddings: [] 29 | }; 30 | 31 | let sections: KindleSection[] = []; 32 | 33 | if (typeof text === "string") { 34 | const rows = text.split("\n"); 35 | rows.shift(); 36 | 37 | const firstRow = rows[0].split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); 38 | 39 | importedNotebook.title = firstRow[0].replace(/"/g, ""); 40 | importedNotebook.author = firstRow[1].replace(/"/g, ""); 41 | 42 | for (let i = 0; i < rows.length; i++) { 43 | const row = rows[i]; 44 | const cleanedRow = row.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); 45 | 46 | const title = cleanedRow[0].replace(/"/g, ""); 47 | const author = cleanedRow[1].replace(/"/g, ""); 48 | const sectionTitle = cleanedRow[2].replace(/"/g, ""); 49 | const type = cleanedRow[3].replace(/"/g, ""); 50 | const page = cleanedRow[4].replace(/"/g, ""); 51 | const highlight = cleanedRow[5].replace(/"/g, ""); 52 | const embedding = cleanedRow[6].replace(/"/g, ""); 53 | 54 | const kindleEmbedding: KindleEmbedding = { 55 | title, 56 | author, 57 | sectionTitle, 58 | type, 59 | page, 60 | highlight, 61 | embedding: JSON.parse(embedding) 62 | }; 63 | 64 | importedNotebook.embeddings.push(kindleEmbedding); 65 | 66 | const sectionIndex = sections.findIndex((section) => section.sectionTitle === sectionTitle); 67 | 68 | if (sectionIndex === -1) { 69 | const newSection: KindleSection = { 70 | sectionTitle, 71 | highlights: [] 72 | }; 73 | 74 | newSection.highlights.push(kindleEmbedding); 75 | sections.push(newSection); 76 | } else { 77 | sections[sectionIndex].highlights.push(kindleEmbedding); 78 | } 79 | } 80 | 81 | importedNotebook.highlights = sections; 82 | 83 | onImport(importedNotebook); 84 | 85 | onLoadingChange(false); 86 | } 87 | }; 88 | 89 | reader.readAsText(file); 90 | }; 91 | 92 | const readHtmlFile = async (file: File) => { 93 | const reader = new FileReader(); 94 | 95 | reader.onload = async (e) => { 96 | const text = e.target?.result; 97 | 98 | const notebook = parseHighlights(text); 99 | 100 | if (notebook) { 101 | onImport(notebook); 102 | } 103 | }; 104 | 105 | reader.readAsText(file); 106 | }; 107 | 108 | return ( 109 |
110 |
Import exported notebook from Kindle (.html) or previously embedded notebook (.csv).
111 | 120 | { 125 | if (e.target.files) { 126 | const file = e.target.files[0]; 127 | 128 | if (file.name.split(".").pop() === "csv") { 129 | readCsvFile(file); 130 | } else if (file.name.split(".").pop() === "html") { 131 | readHtmlFile(file); 132 | } 133 | } 134 | }} 135 | accept=".csv,.html" 136 | /> 137 |
138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /components/Kindle/BookDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { KindleNotebook } from "@/types"; 2 | import { FC } from "react"; 3 | 4 | interface BookDisplay { 5 | notebook: KindleNotebook; 6 | } 7 | 8 | export const BookDisplay: FC = ({ notebook }) => { 9 | return ( 10 |
11 |
{notebook.title}
12 |
{notebook.author}
13 | 14 | {notebook.highlights.map((section, index) => { 15 | return ( 16 |
20 |
{section.sectionTitle}
21 | {section.highlights.map((highlight, index) => { 22 | return ( 23 |
  • 27 | {highlight.highlight} 28 |
  • 29 | ); 30 | })} 31 |
    32 | ); 33 | })} 34 |
    35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/Kindle/BookList.tsx: -------------------------------------------------------------------------------- 1 | import { KindleNotebook } from "@/types"; 2 | import { IconArrowLeft } from "@tabler/icons-react"; 3 | import { FC, useState } from "react"; 4 | import { BookDisplay } from "./BookDisplay"; 5 | import { BookListItem } from "./BookListItem"; 6 | 7 | interface BookListProps { 8 | notebooks: KindleNotebook[]; 9 | onDelete: (notebooks: KindleNotebook[]) => void; 10 | } 11 | 12 | export const BookList: FC = ({ notebooks, onDelete }) => { 13 | const [selected, setSelected] = useState(); 14 | 15 | const handleDelete = () => { 16 | const proceed = confirm("Are you sure you want to delete this book?"); 17 | 18 | if (!proceed) return; 19 | 20 | const newBooks = notebooks.filter((notebook) => notebook.title !== selected?.title); 21 | localStorage.setItem("books", JSON.stringify(newBooks)); 22 | onDelete(newBooks); 23 | setSelected(undefined); 24 | }; 25 | 26 | return ( 27 |
    28 | {selected ? ( 29 |
    30 |
    31 | 38 | 39 | 45 |
    46 | 47 |
    48 | 49 |
    50 |
    51 | ) : ( 52 |
    53 | {notebooks.map((notebook, index) => { 54 | return ( 55 |
    59 | 63 |
    64 | ); 65 | })} 66 |
    67 | )} 68 |
    69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /components/Kindle/BookListItem.tsx: -------------------------------------------------------------------------------- 1 | import { KindleNotebook } from "@/types"; 2 | import { FC } from "react"; 3 | 4 | interface BookListItemProps { 5 | notebook: KindleNotebook; 6 | onSelect: (notebook: KindleNotebook) => void; 7 | } 8 | 9 | export const BookListItem: FC = ({ notebook, onSelect }) => { 10 | return ( 11 |
    onSelect(notebook)} 14 | > 15 |
    {notebook.title}
    16 |
    {notebook.author}
    17 |
    18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/Layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const Footer: FC = () => { 4 | return ( 5 |
    6 |
    7 | Created by 8 | 14 | Mckay Wrigley 15 | 16 | . 17 |
    18 |
    19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /components/Layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { IconBrandGithub, IconBrandTwitter } from "@tabler/icons-react"; 2 | import { FC } from "react"; 3 | 4 | export const Navbar: FC = () => { 5 | return ( 6 |
    7 | 15 |
    16 |
    17 | See an 18 | 24 | example 25 | 26 | . 27 |
    28 | 34 | 35 | 36 | 37 | 43 | 44 | 45 |
    46 |
    47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /components/Passage.tsx: -------------------------------------------------------------------------------- 1 | import { KindleEmbedding } from "@/types"; 2 | import { FC } from "react"; 3 | 4 | interface PassageProps { 5 | passage: KindleEmbedding; 6 | } 7 | 8 | export const Passage: FC = ({ passage }) => { 9 | return ( 10 |
    11 |
    {passage.sectionTitle}
    12 |
    {passage.highlight}
    13 |
    page {passage.page}
    14 |
    15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { IconArrowRight, IconSearch } from "@tabler/icons-react"; 2 | import { FC, KeyboardEvent, useState } from "react"; 3 | 4 | interface SearchProps { 5 | onAnswer: (query: string) => void; 6 | } 7 | 8 | export const Search: FC = ({ onAnswer }) => { 9 | const [query, setQuery] = useState(""); 10 | 11 | const handleKeyDown = (e: KeyboardEvent) => { 12 | if (e.key === "Enter") { 13 | onAnswer(query); 14 | } 15 | }; 16 | 17 | return ( 18 |
    19 | 20 | 21 | setQuery(e.target.value)} 27 | onKeyDown={handleKeyDown} 28 | /> 29 | 30 | 33 |
    34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | interface SettingsProps { 4 | apiKey: string; 5 | onApiKeyChange: (apiKey: string) => void; 6 | } 7 | 8 | export const Settings: FC = ({ apiKey, onApiKeyChange }) => { 9 | const [show, setShow] = useState(false); 10 | 11 | const handleSave = () => { 12 | if (apiKey.length !== 51) { 13 | alert("Please enter a valid API key."); 14 | return; 15 | } 16 | 17 | localStorage.setItem("KINDLE_KEY", apiKey); 18 | 19 | setShow(false); 20 | }; 21 | 22 | const handleClear = () => { 23 | localStorage.removeItem("KINDLE_KEY"); 24 | onApiKeyChange(""); 25 | }; 26 | 27 | return ( 28 |
    29 | 35 | 36 | {show && ( 37 |
    38 |
    39 |
    OpenAI API Key
    40 | { 46 | onApiKeyChange(e.target.value); 47 | }} 48 | /> 49 |
    50 | 51 |
    52 |
    56 | Save 57 |
    58 | 59 |
    63 | Clear 64 |
    65 |
    66 |
    67 | )} 68 |
    69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kindle-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 | }, 11 | "dependencies": { 12 | "@tabler/icons-react": "^2.9.0", 13 | "@types/node": "18.14.6", 14 | "@types/react": "18.0.28", 15 | "@types/react-dom": "18.0.11", 16 | "endent": "^2.1.0", 17 | "eslint": "8.35.0", 18 | "eslint-config-next": "13.2.3", 19 | "eventsource-parser": "^0.1.0", 20 | "next": "13.2.3", 21 | "openai": "^3.2.1", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "typescript": "4.9.5" 25 | }, 26 | "devDependencies": { 27 | "autoprefixer": "^10.4.13", 28 | "postcss": "^8.4.21", 29 | "tailwindcss": "^3.2.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { Inter } from "next/font/google"; 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/_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/api/answer.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIStream } from "@/utils/server"; 2 | 3 | export const config = { 4 | runtime: "edge" 5 | }; 6 | 7 | const handler = async (req: Request): Promise => { 8 | try { 9 | const { title, author, prompt, apiKey } = (await req.json()) as { 10 | title: string; 11 | author: string; 12 | prompt: string; 13 | apiKey: string; 14 | }; 15 | 16 | const stream = await OpenAIStream(title, author, prompt, apiKey); 17 | 18 | return new Response(stream); 19 | } catch (error) { 20 | console.error(error); 21 | return new Response("Error", { status: 500 }); 22 | } 23 | }; 24 | 25 | export default handler; 26 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Answer } from "@/components/Answer/Answer"; 2 | import { Import } from "@/components/Import/Import"; 3 | import { BookDisplay } from "@/components/Kindle/BookDisplay"; 4 | import { Footer } from "@/components/Layout/Footer"; 5 | import { Navbar } from "@/components/Layout/Navbar"; 6 | import { Passage } from "@/components/Passage"; 7 | import { Search } from "@/components/Search"; 8 | import { Settings } from "@/components/Settings"; 9 | import { KindleEmbedding, KindleNotebook } from "@/types"; 10 | import { cosSim } from "@/utils/app"; 11 | import endent from "endent"; 12 | import Head from "next/head"; 13 | import { Configuration, OpenAIApi } from "openai"; 14 | import { useEffect, useState } from "react"; 15 | 16 | export default function Home() { 17 | const [apiKey, setApiKey] = useState(""); 18 | const [book, setBook] = useState(); 19 | const [answer, setAnswer] = useState(""); 20 | const [passages, setPassages] = useState([]); 21 | const [loading, setLoading] = useState(false); 22 | 23 | const handleAnswer = async (query: string) => { 24 | if (!book) { 25 | return; 26 | } 27 | 28 | setLoading(true); 29 | setAnswer(""); 30 | 31 | const configuration = new Configuration({ apiKey }); 32 | const openai = new OpenAIApi(configuration); 33 | 34 | const embeddingResponse = await openai.createEmbedding({ 35 | model: "text-embedding-ada-002", 36 | input: query 37 | }); 38 | 39 | const [{ embedding }] = embeddingResponse.data.data; 40 | 41 | let similarities = book.embeddings.map((notebookEmbedding) => { 42 | const similarity = cosSim(embedding, notebookEmbedding.embedding); 43 | 44 | return { 45 | similarity, 46 | notebookEmbedding 47 | }; 48 | }); 49 | 50 | similarities = similarities.sort((a, b) => b.similarity - a.similarity); 51 | 52 | let length = 0; 53 | let count = 0; 54 | 55 | const selected = similarities.filter((similarity) => { 56 | length += similarity.notebookEmbedding.highlight.length; 57 | 58 | if (length < 1000 && count < 10) { 59 | count++; 60 | return true; 61 | } 62 | 63 | return false; 64 | }); 65 | 66 | setPassages(selected.map((similarity) => similarity.notebookEmbedding)); 67 | 68 | const prompt = endent` 69 | You are ${book.author}. 70 | 71 | Use the following passages from ${book.title} by ${book.author} to help provide an answer to the query: "${query}" 72 | 73 | Passsages: 74 | ${selected.map((similarity) => similarity.notebookEmbedding.highlight).join("\n\n")} 75 | 76 | Your answer: 77 | `; 78 | 79 | const answerResponse = await fetch("/api/answer", { 80 | method: "POST", 81 | headers: { 82 | "Content-Type": "application/json" 83 | }, 84 | body: JSON.stringify({ title: book.title, author: book.author, prompt, apiKey }) 85 | }); 86 | 87 | if (!answerResponse.ok) { 88 | setLoading(false); 89 | throw new Error(answerResponse.statusText); 90 | } 91 | 92 | const data = answerResponse.body; 93 | 94 | if (!data) { 95 | return; 96 | } 97 | 98 | setLoading(false); 99 | 100 | const reader = data.getReader(); 101 | const decoder = new TextDecoder(); 102 | let done = false; 103 | 104 | while (!done) { 105 | const { value, done: doneReading } = await reader.read(); 106 | done = doneReading; 107 | const chunkValue = decoder.decode(value); 108 | setAnswer((prev) => prev + chunkValue); 109 | } 110 | }; 111 | 112 | useEffect(() => { 113 | const KEY = localStorage.getItem("KINDLE_KEY"); 114 | 115 | if (KEY) { 116 | setApiKey(KEY); 117 | } 118 | }, []); 119 | 120 | return ( 121 | <> 122 | 123 | Kindle GPT 124 | 128 | 132 | 136 | 137 | 138 |
    139 | 140 |
    141 |
    142 | 146 |
    147 | 148 | {apiKey.length === 51 ? ( 149 | { 155 | setAnswer(""); 156 | setPassages([]); 157 | }} 158 | /> 159 | ) : ( 160 |
    161 | Please enter your 162 | 166 | OpenAI API key 167 | 168 | in settings. 169 |
    170 | )} 171 | 172 |
    173 | {book ? ( 174 |
    175 | {book.embeddings.length > 0 && ( 176 |
    177 | 178 |
    179 | )} 180 | 181 | {loading ? ( 182 | <> 183 |
    184 |
    Answer
    185 |
    186 |
    187 |
    188 |
    189 |
    190 |
    191 |
    192 | 193 |
    Passages
    194 |
    195 |
    196 |
    197 |
    198 |
    199 |
    200 |
    201 |
    202 | 203 | ) : ( 204 | answer && ( 205 |
    206 |
    Answer
    207 | 208 |
    209 | ) 210 | )} 211 | 212 | {passages.length > 0 && ( 213 |
    214 |
    Passages
    215 | {passages.map((passage, index) => { 216 | return ( 217 |
    221 | 222 |
    223 | ); 224 | })} 225 |
    226 | )} 227 | 228 |
    Notebook
    229 | 230 |
    231 | ) : null} 232 |
    233 |
    234 |
    235 |
    236 | 237 | ); 238 | } 239 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mckaywrigley/kindle-gpt/a439978e08f9afb27517d4aad9d8ab19dc17ef62/public/favicon.ico -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export enum OpenAIModel { 2 | DAVINCI_TURBO = "gpt-3.5-turbo" 3 | } 4 | 5 | interface KindleNotebook { 6 | title: string; 7 | author: string; 8 | highlights: KindleSection[]; 9 | embeddings: KindleEmbedding[]; 10 | } 11 | 12 | interface KindleSection { 13 | sectionTitle: string; 14 | highlights: KindleHighlight[]; 15 | } 16 | 17 | interface KindleHighlight { 18 | type: string; 19 | page: string; 20 | highlight: string; 21 | } 22 | 23 | interface KindleEmbedding { 24 | title: string; 25 | author: string; 26 | sectionTitle: string; 27 | type: string; 28 | page: string; 29 | highlight: string; 30 | embedding: number[]; 31 | } 32 | 33 | export type { KindleNotebook, KindleSection, KindleEmbedding }; 34 | -------------------------------------------------------------------------------- /utils/app/index.ts: -------------------------------------------------------------------------------- 1 | import { KindleNotebook } from "@/types"; 2 | 3 | export const cosSim = (A: number[], B: number[]) => { 4 | let dotproduct = 0; 5 | let mA = 0; 6 | let mB = 0; 7 | 8 | for (let i = 0; i < A.length; i++) { 9 | dotproduct += A[i] * B[i]; 10 | mA += A[i] * A[i]; 11 | mB += B[i] * B[i]; 12 | } 13 | 14 | mA = Math.sqrt(mA); 15 | mB = Math.sqrt(mB); 16 | 17 | const similarity = dotproduct / (mA * mB); 18 | 19 | return similarity; 20 | }; 21 | 22 | const fixBrokenHTML = (sourceHTML: any) => { 23 | let fixedHTML = sourceHTML.replace(/(\r\n|\n|\r)/gm, ""); 24 | fixedHTML = fixedHTML.replace("
    ", "
    "); 25 | 26 | if (!fixedHTML.includes("")) { 27 | return fixedHTML; 28 | } 29 | 30 | return fixedHTML.replaceAll("", "").replaceAll("
    ", "
    "); 31 | }; 32 | 33 | const highlightType = (text: any) => { 34 | const match = text.match(/^(.*)\s-\s/); 35 | if (match) { 36 | return match[1]; 37 | } 38 | }; 39 | 40 | const extractPageNumber = (text: any) => { 41 | const match = text.match(/\s-\s\w*\s+([0-9]+)/); 42 | if (match) { 43 | return match[1]; 44 | } 45 | }; 46 | 47 | const extractLocation = (text: any) => { 48 | const match = text.match(/\s·\s\w*\s+([0-9]+)/); 49 | if (match) { 50 | return match[1]; 51 | } 52 | }; 53 | 54 | const cleanUpText = (text: any) => { 55 | let newText = text; 56 | 57 | [",", ".", ";", "(", ")", "“"].forEach((mark) => { 58 | newText = newText.replaceAll(` ${mark}`, mark); 59 | }); 60 | 61 | return newText; 62 | }; 63 | 64 | export const parseHighlights = (rawHTML: any) => { 65 | const fixedHTML = fixBrokenHTML(rawHTML); 66 | 67 | const domparser = new DOMParser(); 68 | const doc = domparser.parseFromString(fixedHTML, "text/html"); 69 | 70 | const container = doc.querySelector(".bodyContainer"); 71 | const bookTitle: any = doc.querySelector(".bookTitle"); 72 | const bookAuthors: any = doc.querySelector(".authors"); 73 | 74 | if (container) { 75 | const nodes = Array.from(container.children); 76 | 77 | const sections = []; 78 | let currentSection: any = null; 79 | let currentHighlight: any = null; 80 | 81 | nodes.forEach((node) => { 82 | if (node.nodeType === Node.TEXT_NODE) { 83 | return; 84 | } 85 | 86 | if (node.className === "sectionHeading") { 87 | if (currentSection !== null) { 88 | sections.push({ ...currentSection }); 89 | } 90 | 91 | currentSection = { 92 | sectionTitle: node.innerHTML.trim(), 93 | highlights: [] 94 | }; 95 | } 96 | 97 | if (node.className === "noteHeading") { 98 | const heading = node.innerHTML.trim(); 99 | 100 | currentHighlight = { 101 | type: highlightType(heading), 102 | page: extractPageNumber(heading), 103 | location: extractLocation(heading), 104 | highlight: "" 105 | }; 106 | } 107 | 108 | if (node.className === "noteText") { 109 | if (!currentHighlight) return; 110 | 111 | currentHighlight.highlight = cleanUpText(node.innerHTML.trim()); 112 | 113 | if (currentHighlight !== null) { 114 | currentSection.highlights.push({ ...currentHighlight }); 115 | } 116 | } 117 | }); 118 | 119 | if (currentSection !== null) { 120 | sections.push({ ...currentSection }); 121 | } 122 | 123 | const notebook: KindleNotebook = { 124 | title: bookTitle.innerHTML.trim().replace(/&/g, "&"), 125 | author: bookAuthors.innerHTML.trim(), 126 | highlights: sections, 127 | embeddings: [] 128 | }; 129 | 130 | return notebook; 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /utils/server/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIModel } from "@/types"; 2 | import { createParser, ParsedEvent, ReconnectInterval } from "eventsource-parser"; 3 | 4 | export const OpenAIStream = async (title: string, author: string, prompt: string, apiKey: string) => { 5 | const encoder = new TextEncoder(); 6 | const decoder = new TextDecoder(); 7 | 8 | const res = await fetch("https://api.openai.com/v1/chat/completions", { 9 | headers: { 10 | "Content-Type": "application/json", 11 | Authorization: `Bearer ${apiKey}` 12 | }, 13 | method: "POST", 14 | body: JSON.stringify({ 15 | model: OpenAIModel.DAVINCI_TURBO, 16 | messages: [ 17 | { 18 | role: "system", 19 | content: `You are ${author}, and you provide answers to your book "${title}." You can use the provided book highlights to help provide an answer, but only reference them when they're relevant. Generally keep your answer to 2-3 sentences.` 20 | }, 21 | { 22 | role: "user", 23 | content: prompt 24 | } 25 | ], 26 | max_tokens: 120, 27 | temperature: 0.2, 28 | stream: true 29 | }) 30 | }); 31 | 32 | if (res.status !== 200) { 33 | throw new Error("OpenAI API returned an error"); 34 | } 35 | 36 | const stream = new ReadableStream({ 37 | async start(controller) { 38 | const onParse = (event: ParsedEvent | ReconnectInterval) => { 39 | if (event.type === "event") { 40 | const data = event.data; 41 | 42 | if (data === "[DONE]") { 43 | controller.close(); 44 | return; 45 | } 46 | 47 | try { 48 | const json = JSON.parse(data); 49 | const text = json.choices[0].delta.content; 50 | const queue = encoder.encode(text); 51 | controller.enqueue(queue); 52 | } catch (e) { 53 | controller.error(e); 54 | } 55 | } 56 | }; 57 | 58 | const parser = createParser(onParse); 59 | 60 | for await (const chunk of res.body as any) { 61 | parser.feed(decoder.decode(chunk)); 62 | } 63 | } 64 | }); 65 | 66 | return stream; 67 | }; 68 | --------------------------------------------------------------------------------