├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── README.md ├── db └── dbinit.sql ├── globals.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── src ├── crawler.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── chat.ts │ │ ├── conversationLog.ts │ │ ├── crawl.ts │ │ ├── createTokenRequest.ts │ │ ├── matches.ts │ │ ├── summarizer.ts │ │ └── templates.ts │ ├── index.tsx │ └── test.tsx ├── styles │ └── globals.css └── utils │ └── config.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | PINECONE_API_KEY= 3 | PINECONE_ENVIRONMENT= 4 | PINECONE_INDEX_NAME= 5 | DATABASE_URL= 6 | ABLY_API_KEY= 7 | API_ROOT= 8 | FINGERPRINTJS_API_KEY= -------------------------------------------------------------------------------- /.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 | .env 38 | 39 | ./src/development-pinecone.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "npm run dev", 21 | "serverReadyAction": { 22 | "pattern": "started server on .+, url: (https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dotenv.enableAutocloaking": false 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinecone Chatbot Demo 2 | 3 | To run this demo, you need to have: 4 | 5 | 1. A Pinecone account. If you don't have one, you can sign up for free at [pinecone.io](https://www.pinecone.io). 6 | 2. An OpenAI account. If you don't have one, you can sign up for free at [openai.com](https://www.openai.com). 7 | 3. An Ably account. If you don't have one, you can sign up for free at [ably.io](https://www.ably.io). 8 | 4. A FingerprintJS account. If you don't have one, you can sign up for free at [fingerprintjs.com](https://www.fingerprintjs.com). 9 | 5. A CockroachDB account. If you don't have one, you can sign up for free at [cockroachlabs.com](https://www.cockroachlabs.com). 10 | 11 | ## Setup 12 | 13 | 1. Clone this repository 14 | 15 | ```bash 16 | git clone https://github.com/pinecone-io/chatbot-demo.git 17 | ``` 18 | 19 | 2. Install dependencies 20 | 21 | ```bash 22 | cd chatbot-demo 23 | npm install 24 | ``` 25 | 26 | 3. Create your Pinecone, OpenAI, Ably, FingerprintJS and Cockroach accounts and get your API keys 27 | 28 | 4. Create your Pinecone index 29 | 30 | 5. Create a `.env` file in the root directory of the project and add your API keys: 31 | 32 | ``` 33 | OPENAI_API_KEY=... 34 | PINECONE_API_KEY=... 35 | PINECONE_ENVIRONMENT=... 36 | PINECONE_INDEX_NAME=... 37 | DATABASE_URL=... 38 | ABLY_API_KEY=... 39 | FINGERPRINTJS_API_KEY=... 40 | API_ROOT="http://localhost:3000" 41 | ``` 42 | 43 | ## Start the development server 44 | 45 | ```bash 46 | npm run dev 47 | ``` 48 | -------------------------------------------------------------------------------- /db/dbinit.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE speaker AS ENUM ('user', 'ai'); 2 | 3 | CREATE TABLE conversations ( 4 | user_id STRING, 5 | entry STRING, 6 | speaker speaker, 7 | created_at TIMESTAMP PRIMARY KEY NOT NULL DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | 10 | -- DROP TABLE memories; -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | env: { 5 | FINGERPRINT: process.env.FINGERPRINTJS_API_KEY, 6 | }, 7 | publicRuntimeConfig: { 8 | apiUrl: process.env.API_URL || "http://localhost:3000", 9 | }, 10 | }; 11 | 12 | module.exports = nextConfig; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatbot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NODE_INSEPCT=true & next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ably-labs/react-hooks": "^2.1.0", 13 | "@chatscope/chat-ui-kit-react": "^1.10.1", 14 | "@dqbd/tiktoken": "^0.4.0", 15 | "@fingerprintjs/fingerprintjs": "^3.4.0", 16 | "@fingerprintjs/fingerprintjs-pro-react": "^2.3.0", 17 | "@google-cloud/bigquery": "^6.1.0", 18 | "@next/font": "13.1.6", 19 | "@pinecone-database/pinecone": "^0.1.5", 20 | "ably": "^1.2.5-beta.1", 21 | "bottleneck": "^2.19.5", 22 | "cheerio": "^1.0.0-rc.12", 23 | "eventsource": "^2.0.2", 24 | "langchain": "^0.0.67", 25 | "md5": "^2.3.0", 26 | "next": "13.1.6", 27 | "node-spider": "^1.4.1", 28 | "pg": "^8.10.0", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-markdown": "^8.0.5", 32 | "rehype-katex": "^6.0.2", 33 | "remark-math": "^5.1.1", 34 | "sequelize": "^6.29.2", 35 | "sequelize-cockroachdb": "^6.0.5", 36 | "serializr": "^3.0.2", 37 | "swr": "^2.0.4", 38 | "timeago": "^1.6.7", 39 | "timeago.js": "^4.0.2", 40 | "turndown": "^7.1.1", 41 | "url-parse": "^1.5.10", 42 | "uuidv4": "^6.2.13" 43 | }, 44 | "devDependencies": { 45 | "@types/async": "^3.2.18", 46 | "@types/express": "^4.17.17", 47 | "@types/md5": "^2.3.2", 48 | "@types/nanoid": "^3.0.0", 49 | "@types/node": "^18.16.11", 50 | "@types/pg": "^8.6.6", 51 | "@types/react": "18.0.28", 52 | "@types/react-dom": "18.0.11", 53 | "@types/url-parse": "^1.4.8", 54 | "eslint": "8.34.0", 55 | "eslint-config-next": "13.1.6", 56 | "typescript": "4.9.5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinecone-io/chatbot-demo/7acc02d0fe7310e3739ca3992b401f36f8d29b53/public/favicon.ico -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/crawler.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import * as Spider from 'node-spider' 3 | //@ts-ignore 4 | import * as TurndownService from 'turndown' 5 | import * as cheerio from 'cheerio' 6 | import parse from 'url-parse' 7 | const turndownService = new TurndownService(); 8 | 9 | export type Page = { 10 | url: string, 11 | text: string, 12 | title: string, 13 | } 14 | class Crawler { 15 | pages: Page[] = []; 16 | limit: number = 1000; 17 | urls: string[] = []; 18 | spider: Spider | null = {}; 19 | count: number = 0; 20 | textLengthMinimum: number = 200; 21 | 22 | constructor(urls: string[], limit: number = 1000, textLengthMinimum: number = 200) { 23 | this.urls = urls; 24 | this.limit = limit 25 | this.textLengthMinimum = textLengthMinimum 26 | 27 | this.count = 0 28 | this.pages = []; 29 | this.spider = {} 30 | } 31 | 32 | handleRequest = (doc: any) => { 33 | const $ = cheerio.load(doc.res.body); 34 | $("script").remove(); 35 | $("#hub-sidebar").remove(); 36 | $("header").remove(); 37 | $("nav").remove(); 38 | $("img").remove(); 39 | const title = $("title").text() || $(".article-title").text(); 40 | const html = $("body").html(); 41 | const text = turndownService.turndown(html); 42 | console.log("crawling ", doc.url) 43 | const page: Page = { 44 | url: doc.url, 45 | text, 46 | title, 47 | }; 48 | if (text.length > this.textLengthMinimum) { 49 | this.pages.push(page); 50 | } 51 | 52 | 53 | doc.$("a").each((i: number, elem: any) => { 54 | var href = doc.$(elem).attr("href")?.split("#")[0]; 55 | var targetUrl = href && doc.resolve(href); 56 | // crawl more 57 | if (targetUrl && this.urls.some(u => { 58 | const targetUrlParts = parse(targetUrl); 59 | const uParts = parse(u); 60 | return targetUrlParts.hostname === uParts.hostname 61 | }) && this.count < this.limit) { 62 | this.spider.queue(targetUrl, this.handleRequest); 63 | this.count = this.count + 1 64 | } 65 | }); 66 | }; 67 | 68 | start = async () => { 69 | this.pages = [] 70 | return new Promise((resolve, reject) => { 71 | this.spider = new Spider({ 72 | concurrent: 5, 73 | delay: 0, 74 | allowDuplicates: false, 75 | catchErrors: true, 76 | addReferrer: false, 77 | xhr: false, 78 | keepAlive: false, 79 | error: (err: any, url: string) => { 80 | console.log(err, url); 81 | reject(err) 82 | }, 83 | // Called when there are no more requests 84 | done: () => { 85 | resolve(this.pages) 86 | }, 87 | headers: { "user-agent": "node-spider" }, 88 | encoding: "utf8", 89 | }); 90 | this.urls.forEach((url) => { 91 | this.spider.queue(url, this.handleRequest); 92 | }); 93 | }) 94 | } 95 | } 96 | 97 | export { Crawler }; 98 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { FpjsProvider } from "@fingerprintjs/fingerprintjs-pro-react"; 3 | import { configureAbly } from "@ably-labs/react-hooks"; 4 | 5 | const prefix = process.env.API_ROOT || ""; 6 | 7 | const clientId = 8 | Math.random().toString(36).substring(2, 15) + 9 | Math.random().toString(36).substring(2, 15); 10 | 11 | configureAbly({ 12 | authUrl: `${prefix}/api/createTokenRequest?clientId=${clientId}`, 13 | clientId: clientId, 14 | }); 15 | 16 | const fpjsPublicApiKey = process.env.FINGERPRINT as string; 17 | 18 | export default function App({ Component, pageProps }: AppProps) { 19 | return ( 20 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/api/chat.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { PineconeClient } from "@pinecone-database/pinecone"; 3 | import * as Ably from 'ably'; 4 | import { CallbackManager } from "langchain/callbacks"; 5 | import { LLMChain } from "langchain/chains"; 6 | import { ChatOpenAI } from "langchain/chat_models"; 7 | import { OpenAIEmbeddings } from 'langchain/embeddings'; 8 | import { OpenAI } from "langchain/llms"; 9 | import { PromptTemplate } from "langchain/prompts"; 10 | import type { NextApiRequest, NextApiResponse } from 'next'; 11 | import { uuid } from 'uuidv4'; 12 | import { summarizeLongDocument } from './summarizer'; 13 | 14 | import { ConversationLog } from './conversationLog'; 15 | import { Metadata, getMatchesFromEmbeddings } from './embeddings'; 16 | import { templates } from './templates'; 17 | 18 | 19 | const llm = new OpenAI({}); 20 | let pinecone: PineconeClient | null = null 21 | 22 | const initPineconeClient = async () => { 23 | pinecone = new PineconeClient(); 24 | await pinecone.init({ 25 | environment: process.env.PINECONE_ENVIRONMENT!, 26 | apiKey: process.env.PINECONE_API_KEY!, 27 | }); 28 | } 29 | 30 | const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY }); 31 | 32 | const handleRequest = async ({ prompt, userId }: { prompt: string, userId: string }) => { 33 | if (!pinecone) { 34 | await initPineconeClient(); 35 | } 36 | 37 | let summarizedCount = 0; 38 | 39 | try { 40 | const channel = ably.channels.get(userId); 41 | const interactionId = uuid() 42 | 43 | // Retrieve the conversation log and save the user's prompt 44 | const conversationLog = new ConversationLog(userId) 45 | const conversationHistory = await conversationLog.getConversation({ limit: 10 }) 46 | await conversationLog.addEntry({ entry: prompt, speaker: "user" }) 47 | 48 | // Build an LLM chain that will improve the user prompt 49 | const inquiryChain = new LLMChain({ 50 | llm, prompt: new PromptTemplate({ 51 | template: templates.inquiryTemplate, 52 | inputVariables: ["userPrompt", "conversationHistory"], 53 | }) 54 | }); 55 | const inquiryChainResult = await inquiryChain.call({ userPrompt: prompt, conversationHistory }) 56 | const inquiry = inquiryChainResult.text 57 | 58 | console.log(inquiry) 59 | 60 | 61 | console.log(inquiry) 62 | 63 | 64 | // Embed the user's intent and query the Pinecone index 65 | const embedder = new OpenAIEmbeddings({ 66 | modelName: "text-embedding-ada-002" 67 | }); 68 | 69 | 70 | const embeddings = await embedder.embedQuery(inquiry); 71 | channel.publish({ 72 | data: { 73 | event: "status", 74 | message: "Finding matches...", 75 | } 76 | }) 77 | 78 | const matches = await getMatchesFromEmbeddings(embeddings, pinecone!, 2); 79 | 80 | 81 | 82 | const urls = matches && Array.from(new Set(matches.map(match => { 83 | const metadata = match.metadata as Metadata 84 | const { url } = metadata 85 | return url 86 | }))) 87 | 88 | console.log(urls) 89 | 90 | 91 | const docs = matches && Array.from( 92 | matches.reduce((map, match) => { 93 | const metadata = match.metadata as Metadata; 94 | const { text, url } = metadata; 95 | if (!map.has(url)) { 96 | map.set(url, text); 97 | } 98 | return map; 99 | }, new Map()) 100 | ).map(([_, text]) => text); 101 | 102 | 103 | const promptTemplate = new PromptTemplate({ 104 | template: templates.qaTemplate, 105 | inputVariables: ["summaries", "question", "conversationHistory", "urls"], 106 | }); 107 | 108 | 109 | const chat = new ChatOpenAI({ 110 | streaming: true, 111 | verbose: true, 112 | modelName: "gpt-3.5-turbo", 113 | callbackManager: CallbackManager.fromHandlers({ 114 | async handleLLMNewToken(token) { 115 | channel.publish({ 116 | data: { 117 | event: "response", 118 | token: token, 119 | interactionId 120 | } 121 | }) 122 | }, 123 | async handleLLMEnd(result) { 124 | channel.publish({ 125 | data: { 126 | event: "responseEnd", 127 | token: "END", 128 | interactionId 129 | } 130 | }) 131 | } 132 | }), 133 | }); 134 | 135 | const chain = new LLMChain({ 136 | prompt: promptTemplate, 137 | llm: chat, 138 | }); 139 | 140 | const allDocs = docs.join("\n") 141 | if (allDocs.length > 4000) { 142 | channel.publish({ 143 | data: { 144 | event: "status", 145 | message: `Just a second, forming final answer...`, 146 | } 147 | }) 148 | } 149 | 150 | const summary = allDocs.length > 4000 ? await summarizeLongDocument({ document: allDocs, inquiry }) : allDocs 151 | 152 | await chain.call({ 153 | summaries: summary, 154 | question: prompt, 155 | conversationHistory, 156 | urls 157 | }); 158 | 159 | 160 | 161 | } catch (error) { 162 | //@ts-ignore 163 | console.error(error) 164 | } 165 | } 166 | 167 | export default async function handler( 168 | req: NextApiRequest, 169 | res: NextApiResponse 170 | ) { 171 | const { body } = req; 172 | const { prompt, userId } = body; 173 | await handleRequest({ prompt, userId }) 174 | res.status(200).json({ "message": "started" }) 175 | } 176 | -------------------------------------------------------------------------------- /src/pages/api/conversationLog.ts: -------------------------------------------------------------------------------- 1 | import * as pg from 'pg'; 2 | import { Sequelize } from 'sequelize-cockroachdb'; 3 | 4 | const sequelize = new Sequelize(process.env.DATABASE_URL!, { logging: false, dialectModule: pg }); 5 | 6 | type ConversationLogEntry = { 7 | entry: string, 8 | created_at: Date, 9 | speaker: string, 10 | } 11 | 12 | class ConversationLog { 13 | constructor( 14 | public userId: string, 15 | ) { 16 | this.userId = userId 17 | } 18 | 19 | public async addEntry({ entry, speaker }: { entry: string, speaker: string }) { 20 | try { 21 | await sequelize.query(`INSERT INTO conversations (user_id, entry, speaker) VALUES (?, ?, ?) ON CONFLICT (created_at) DO NOTHING`, { 22 | replacements: [this.userId, entry, speaker], 23 | }); 24 | } catch (e) { 25 | console.log(`Error adding entry: ${e}`) 26 | } 27 | } 28 | 29 | public async getConversation({ limit }: { limit: number }): Promise { 30 | const conversation = await sequelize.query(`SELECT entry, speaker, created_at FROM conversations WHERE user_id = '${this.userId}' ORDER By created_at DESC LIMIT ${limit}`); 31 | const history = conversation[0] as ConversationLogEntry[] 32 | 33 | return history.map((entry) => { 34 | return `${entry.speaker.toUpperCase()}: ${entry.entry}` 35 | }).reverse() 36 | } 37 | 38 | public async clearConversation() { 39 | await sequelize.query(`DELETE FROM conversations WHERE user_id = '${this.userId}'`); 40 | } 41 | } 42 | 43 | export { ConversationLog } -------------------------------------------------------------------------------- /src/pages/api/crawl.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { PineconeClient, Vector } from "@pinecone-database/pinecone"; 3 | import { Crawler, Page } from '../../crawler' 4 | import { Document } from "langchain/document"; 5 | import { OpenAIEmbeddings } from "langchain/embeddings/openai"; 6 | import Bottleneck from "bottleneck"; 7 | import { uuid } from "uuidv4"; 8 | import { TokenTextSplitter } from "langchain/text_splitter"; 9 | import { summarizeLongDocument } from "./summarizer"; 10 | 11 | const limiter = new Bottleneck({ 12 | minTime: 50 13 | }); 14 | 15 | let pinecone: PineconeClient | null = null 16 | 17 | const initPineconeClient = async () => { 18 | pinecone = new PineconeClient(); 19 | console.log("init pinecone") 20 | await pinecone.init({ 21 | environment: process.env.PINECONE_ENVIRONMENT!, 22 | apiKey: process.env.PINECONE_API_KEY!, 23 | }); 24 | } 25 | 26 | type Response = { 27 | message: string 28 | } 29 | 30 | 31 | // The TextEncoder instance enc is created and its encode() method is called on the input string. 32 | // The resulting Uint8Array is then sliced, and the TextDecoder instance decodes the sliced array in a single line of code. 33 | const truncateStringByBytes = (str: string, bytes: number) => { 34 | const enc = new TextEncoder(); 35 | return new TextDecoder("utf-8").decode(enc.encode(str).slice(0, bytes)); 36 | }; 37 | 38 | 39 | const sliceIntoChunks = (arr: Vector[], chunkSize: number) => { 40 | return Array.from({ length: Math.ceil(arr.length / chunkSize) }, (_, i) => 41 | arr.slice(i * chunkSize, (i + 1) * chunkSize) 42 | ); 43 | }; 44 | 45 | export default async function handler( 46 | req: NextApiRequest, 47 | res: NextApiResponse 48 | ) { 49 | 50 | if (!process.env.PINECONE_INDEX_NAME) { 51 | res.status(500).json({ message: "PINECONE_INDEX_NAME not set" }) 52 | return 53 | } 54 | 55 | const { query } = req; 56 | const { urls: urlString, limit, indexName, summmarize } = query; 57 | const urls = (urlString as string).split(","); 58 | const crawlLimit = parseInt(limit as string) || 100; 59 | const pineconeIndexName = indexName as string || process.env.PINECONE_INDEX_NAME! 60 | const shouldSummarize = summmarize === "true" 61 | 62 | if (!pinecone) { 63 | await initPineconeClient(); 64 | } 65 | 66 | const indexes = pinecone && await pinecone.listIndexes(); 67 | if (!indexes?.includes(pineconeIndexName)) { 68 | res.status(500).json({ 69 | message: `Index ${pineconeIndexName} does not exist` 70 | }) 71 | throw new Error(`Index ${pineconeIndexName} does not exist`) 72 | } 73 | 74 | const crawler = new Crawler(urls, crawlLimit, 200) 75 | const pages = await crawler.start() as Page[] 76 | 77 | const documents = await Promise.all(pages.map(async row => { 78 | 79 | const splitter = new TokenTextSplitter({ 80 | encodingName: "gpt2", 81 | chunkSize: 300, 82 | chunkOverlap: 20, 83 | }); 84 | 85 | const pageContent = shouldSummarize ? await summarizeLongDocument({ document: row.text }) : row.text 86 | 87 | const docs = splitter.splitDocuments([ 88 | new Document({ pageContent, metadata: { url: row.url, text: truncateStringByBytes(pageContent, 36000) } }), 89 | ]); 90 | return docs 91 | })) 92 | 93 | 94 | 95 | const index = pinecone && pinecone.Index(pineconeIndexName); 96 | 97 | const embedder = new OpenAIEmbeddings({ 98 | modelName: "text-embedding-ada-002" 99 | }) 100 | let counter = 0 101 | 102 | //Embed the documents 103 | const getEmbedding = async (doc: Document) => { 104 | const embedding = await embedder.embedQuery(doc.pageContent) 105 | console.log(doc.pageContent) 106 | console.log("got embedding", embedding.length) 107 | process.stdout.write(`${Math.floor((counter / documents.flat().length) * 100)}%\r`) 108 | counter = counter + 1 109 | return { 110 | id: uuid(), 111 | values: embedding, 112 | metadata: { 113 | chunk: doc.pageContent, 114 | text: doc.metadata.text as string, 115 | url: doc.metadata.url as string, 116 | } 117 | } as Vector 118 | } 119 | const rateLimitedGetEmbedding = limiter.wrap(getEmbedding); 120 | process.stdout.write("100%\r") 121 | console.log("done embedding"); 122 | 123 | let vectors = [] as Vector[] 124 | 125 | try { 126 | vectors = await Promise.all(documents.flat().map((doc) => rateLimitedGetEmbedding(doc))) as unknown as Vector[] 127 | const chunks = sliceIntoChunks(vectors, 10) 128 | console.log(chunks.length) 129 | 130 | 131 | try { 132 | await Promise.all(chunks.map(async chunk => { 133 | await index!.upsert({ 134 | upsertRequest: { 135 | vectors: chunk as Vector[], 136 | namespace: "" 137 | } 138 | }) 139 | })) 140 | 141 | res.status(200).json({ message: "Done" }) 142 | } catch (e) { 143 | console.log(e) 144 | res.status(500).json({ message: `Error ${JSON.stringify(e)}` }) 145 | } 146 | } catch (e) { 147 | console.log(e) 148 | } 149 | } -------------------------------------------------------------------------------- /src/pages/api/createTokenRequest.ts: -------------------------------------------------------------------------------- 1 | import Ably from "ably/promises"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | let options: Ably.Types.ClientOptions = { key: process.env.ABLY_API_KEY }; 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | const client = new Ably.Realtime(options); 7 | const tokenRequestData = await client.auth.createTokenRequest({ clientId: req.query.clientId as string }); 8 | res.status(200).json(tokenRequestData); 9 | }; -------------------------------------------------------------------------------- /src/pages/api/matches.ts: -------------------------------------------------------------------------------- 1 | import { PineconeClient, ScoredVector } from "@pinecone-database/pinecone"; 2 | 3 | export type Metadata = { 4 | url: string, 5 | text: string, 6 | chunk: string, 7 | } 8 | 9 | const getMatchesFromEmbeddings = async (embeddings: number[], pinecone: PineconeClient, topK: number): Promise => { 10 | if (!process.env.PINECONE_INDEX_NAME) { 11 | throw (new Error("PINECONE_INDEX_NAME is not set")) 12 | } 13 | 14 | const index = pinecone!.Index(process.env.PINECONE_INDEX_NAME); 15 | const queryRequest = { 16 | vector: embeddings, 17 | topK, 18 | includeMetadata: true 19 | } 20 | try { 21 | const queryResult = await index.query({ 22 | queryRequest 23 | }) 24 | return queryResult.matches?.map(match => ({ 25 | ...match, 26 | metadata: match.metadata as Metadata 27 | })) || [] 28 | } catch (e) { 29 | console.log("Error querying embeddings: ", e) 30 | throw (new Error(`Error querying embeddings: ${e}`,)) 31 | } 32 | } 33 | 34 | export { getMatchesFromEmbeddings } -------------------------------------------------------------------------------- /src/pages/api/summarizer.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "langchain/llms"; 2 | import { templates } from './templates' 3 | import { LLMChain, PromptTemplate } from "langchain"; 4 | import Bottleneck from "bottleneck"; 5 | import { StructuredOutputParser } from "langchain/output_parsers"; 6 | 7 | const llm = new OpenAI({ concurrency: 10, temperature: 0, modelName: "gpt-3.5-turbo" }); 8 | 9 | const { summarizerTemplate, summarizerDocumentTemplate } = templates; 10 | 11 | const parser = StructuredOutputParser.fromNamesAndDescriptions({ 12 | answer: "answer to the user's question", 13 | source: "source used to answer the user's question, should be a website.", 14 | }); 15 | 16 | const formatInstructions = parser.getFormatInstructions(); 17 | 18 | 19 | const limiter = new Bottleneck({ 20 | minTime: 5050 21 | }); 22 | 23 | console.log(summarizerDocumentTemplate.length) 24 | const chunkSubstr = (str: string, size: number) => { 25 | const numChunks = Math.ceil(str.length / size) 26 | const chunks = new Array(numChunks) 27 | 28 | for (let i = 0, o = 0; i < numChunks; ++i, o += size) { 29 | chunks[i] = str.substr(o, size) 30 | } 31 | 32 | return chunks 33 | } 34 | 35 | const summarize = async ({ document, inquiry, onSummaryDone }: { document: string, inquiry?: string, onSummaryDone?: Function }) => { 36 | console.log("summarizing ", document.length) 37 | const promptTemplate = new PromptTemplate({ 38 | template: inquiry ? summarizerTemplate : summarizerDocumentTemplate, 39 | inputVariables: inquiry ? ["document", "inquiry"] : ["document"], 40 | }); 41 | const chain = new LLMChain({ 42 | prompt: promptTemplate, 43 | llm 44 | }) 45 | 46 | try { 47 | const result = await chain.call({ 48 | prompt: promptTemplate, 49 | document, 50 | inquiry 51 | }) 52 | 53 | console.log(result) 54 | 55 | onSummaryDone && onSummaryDone(result.text) 56 | return result.text 57 | } catch (e) { 58 | console.log(e) 59 | } 60 | } 61 | 62 | const rateLimitedSummarize = limiter.wrap(summarize) 63 | 64 | const summarizeLongDocument = async ({ document, inquiry, onSummaryDone }: { document: string, inquiry?: string, onSummaryDone?: Function }): Promise => { 65 | // Chunk document into 4000 character chunks 66 | const templateLength = inquiry ? summarizerTemplate.length : summarizerDocumentTemplate.length 67 | try { 68 | if ((document.length + templateLength) > 4000) { 69 | console.log("document is long and has to be shortened", document.length) 70 | const chunks = chunkSubstr(document, 4000 - templateLength - 1) 71 | let summarizedChunks: string[] = [] 72 | summarizedChunks = await Promise.all( 73 | chunks.map(async (chunk) => { 74 | let result 75 | if (inquiry) { 76 | result = await rateLimitedSummarize({ document: chunk, inquiry, onSummaryDone }) 77 | } else { 78 | result = await rateLimitedSummarize({ document: chunk, onSummaryDone }) 79 | } 80 | return result 81 | }) 82 | ) 83 | 84 | const result = summarizedChunks.join("\n"); 85 | console.log(result.length) 86 | 87 | if ((result.length + templateLength) > 4000) { 88 | console.log("document is STILL long and has to be shortened further") 89 | return await summarizeLongDocument({ document: result, inquiry, onSummaryDone }) 90 | } else { 91 | console.log("done") 92 | return result 93 | } 94 | 95 | } else { 96 | return document 97 | } 98 | } catch (e) { 99 | throw new Error(e as string) 100 | } 101 | } 102 | 103 | export { summarizeLongDocument } -------------------------------------------------------------------------------- /src/pages/api/templates.ts: -------------------------------------------------------------------------------- 1 | const templates = { 2 | qaTemplate: `Answer the question based on the context below. You should follow ALL the following rules when generating and answer: 3 | - There will be a CONVERSATION LOG, CONTEXT, and a QUESTION. 4 | - The final answer must always be styled using markdown. 5 | - Your main goal is to point the user to the right source of information (the source is always a URL) based on the CONTEXT you are given. 6 | - Your secondary goal is to provide the user with an answer that is relevant to the question. 7 | - Provide the user with a code example that is relevant to the question, if the context contains relevant code examples. Do not make up any code examples on your own. 8 | - Take into account the entire conversation so far, marked as CONVERSATION LOG, but prioritize the CONTEXT. 9 | - Based on the CONTEXT, choose the source that is most relevant to the QUESTION. 10 | - Do not make up any answers if the CONTEXT does not have relevant information. 11 | - Use bullet points, lists, paragraphs and text styling to present the answer in markdown. 12 | - The CONTEXT is a set of JSON objects, each includes the field "text" where the content is stored, and "url" where the url of the page is stored. 13 | - The URLs are the URLs of the pages that contain the CONTEXT. Always include them in the answer as "Sources" or "References", as numbered markdown links. 14 | - Do not mention the CONTEXT or the CONVERSATION LOG in the answer, but use them to generate the answer. 15 | - ALWAYS prefer the result with the highest "score" value. 16 | - Ignore any content that is stored in html tables. 17 | - The answer should only be based on the CONTEXT. Do not use any external sources. Do not generate the response based on the question without clear reference to the context. 18 | - Summarize the CONTEXT to make it easier to read, but don't omit any information. 19 | - It is IMPERATIVE that any link provided is found in the CONTEXT. Prefer not to provide a link if it is not found in the CONTEXT. 20 | 21 | CONVERSATION LOG: {conversationHistory} 22 | 23 | CONTEXT: {summaries} 24 | 25 | QUESTION: {question} 26 | 27 | URLS: {urls} 28 | 29 | Final Answer: `, 30 | summarizerTemplate: `Shorten the text in the CONTENT, attempting to answer the INQUIRY You should follow the following rules when generating the summary: 31 | - Any code found in the CONTENT should ALWAYS be preserved in the summary, unchanged. 32 | - Code will be surrounded by backticks (\`) or triple backticks (\`\`\`). 33 | - Summary should include code examples that are relevant to the INQUIRY, based on the content. Do not make up any code examples on your own. 34 | - The summary will answer the INQUIRY. If it cannot be answered, the summary should be empty, AND NO TEXT SHOULD BE RETURNED IN THE FINAL ANSWER AT ALL. 35 | - If the INQUIRY cannot be answered, the final answer should be empty. 36 | - The summary should be under 4000 characters. 37 | - The summary should be 2000 characters long, if possible. 38 | 39 | INQUIRY: {inquiry} 40 | CONTENT: {document} 41 | 42 | Final answer: 43 | `, 44 | summarizerDocumentTemplate: `Summarize the text in the CONTENT. You should follow the following rules when generating the summary: 45 | - Any code found in the CONTENT should ALWAYS be preserved in the summary, unchanged. 46 | - Code will be surrounded by backticks (\`) or triple backticks (\`\`\`). 47 | - Summary should include code examples when possible. Do not make up any code examples on your own. 48 | - The summary should be under 4000 characters. 49 | - The summary should be at least 1500 characters long, if possible. 50 | 51 | CONTENT: {document} 52 | 53 | Final answer: 54 | `, 55 | inquiryTemplate: `Given the following user prompt and conversation log, formulate a question that would be the most relevant to provide the user with an answer from a knowledge base. 56 | You should follow the following rules when generating and answer: 57 | - Always prioritize the user prompt over the conversation log. 58 | - Ignore any conversation log that is not directly related to the user prompt. 59 | - Only attempt to answer if a question was posed. 60 | - The question should be a single sentence 61 | - You should remove any punctuation from the question 62 | - You should remove any words that are not relevant to the question 63 | - If you are unable to formulate a question, respond with the same USER PROMPT you got. 64 | 65 | USER PROMPT: {userPrompt} 66 | 67 | CONVERSATION LOG: {conversationHistory} 68 | 69 | Final answer: 70 | `, 71 | summerierTemplate: `Summarize the following text. You should follow the following rules when generating and answer:` 72 | } 73 | 74 | export { templates } -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { useState } from "react"; 3 | import { useVisitorData } from "@fingerprintjs/fingerprintjs-pro-react"; 4 | import ReactMarkdown from "react-markdown"; 5 | import remarkMath from "remark-math"; 6 | import rehypeKatex from "rehype-katex"; 7 | import * as timeago from "timeago.js"; 8 | import { 9 | MainContainer, 10 | ChatContainer, 11 | MessageList, 12 | Message, 13 | MessageInput, 14 | ConversationHeader, 15 | TypingIndicator, 16 | } from "@chatscope/chat-ui-kit-react"; 17 | 18 | import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; 19 | import { useChannel } from "@ably-labs/react-hooks"; 20 | import { Types } from "ably"; 21 | 22 | type ConversationEntry = { 23 | message: string; 24 | speaker: "bot" | "user"; 25 | date: Date; 26 | id?: string; 27 | }; 28 | 29 | type request = { 30 | prompt: string; 31 | }; 32 | 33 | const updateChatbotMessage = ( 34 | conversation: ConversationEntry[], 35 | message: Types.Message 36 | ): ConversationEntry[] => { 37 | const interactionId = message.data.interactionId; 38 | 39 | const updatedConversation = conversation.reduce( 40 | (acc: ConversationEntry[], e: ConversationEntry) => [ 41 | ...acc, 42 | e.id === interactionId 43 | ? { ...e, message: e.message + message.data.token } 44 | : e, 45 | ], 46 | [] 47 | ); 48 | 49 | return conversation.some((e) => e.id === interactionId) 50 | ? updatedConversation 51 | : [ 52 | ...updatedConversation, 53 | { 54 | id: interactionId, 55 | message: message.data.token, 56 | speaker: "bot", 57 | date: new Date(), 58 | }, 59 | ]; 60 | }; 61 | 62 | export default function Home() { 63 | const [text, setText] = useState(""); 64 | const [extendedResult, updateExtendedResult] = useState(false); 65 | const [conversation, setConversation] = useState([]); 66 | const [botIsTyping, setBotIsTyping] = useState(false); 67 | const [statusMessage, setStatusMessage] = useState("Waiting for query..."); 68 | 69 | const { isLoading, data: visitorData } = useVisitorData( 70 | { extendedResult }, 71 | { immediate: true } 72 | ); 73 | 74 | console.log("visitorData?.visitorId!", visitorData?.visitorId!); 75 | 76 | const userId = "roie"; 77 | 78 | useChannel(userId, (message) => { 79 | switch (message.data.event) { 80 | case "response": 81 | setConversation((state) => updateChatbotMessage(state, message)); 82 | break; 83 | case "status": 84 | setStatusMessage(message.data.message); 85 | break; 86 | case "responseEnd": 87 | default: 88 | setBotIsTyping(false); 89 | setStatusMessage("Waiting for query..."); 90 | } 91 | }); 92 | 93 | const submit = async () => { 94 | setConversation((state) => [ 95 | ...state, 96 | { 97 | message: text, 98 | speaker: "user", 99 | date: new Date(), 100 | }, 101 | ]); 102 | try { 103 | setBotIsTyping(true); 104 | const response = await fetch("/api/chat", { 105 | method: "POST", 106 | headers: { 107 | "Content-Type": "application/json", 108 | }, 109 | body: JSON.stringify({ prompt: text, userId }), 110 | }); 111 | 112 | await response.json(); 113 | } catch (error) { 114 | console.error("Error submitting message:", error); 115 | } finally { 116 | setBotIsTyping(false); 117 | } 118 | setText(""); 119 | }; 120 | 121 | return ( 122 | <> 123 | 124 | Pinecone GPT 125 | 126 | 127 | 128 | 129 |
130 |
133 | 134 | 135 | 136 | 137 | 141 | 142 | 143 | 147 | ) : null 148 | } 149 | > 150 | {conversation.map((entry, index) => { 151 | return ( 152 | 163 | 164 | 167 | {entry.message} 168 | 169 | 170 | 174 | 175 | ); 176 | })} 177 | 178 | { 182 | setText(text); 183 | }} 184 | sendButton={true} 185 | autoFocus 186 | disabled={isLoading} 187 | /> 188 | 189 | 190 |
191 |
192 | 193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /src/pages/test.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useEffect, useState } from "react"; 3 | import getConfig from "next/config"; 4 | 5 | const { publicRuntimeConfig } = getConfig(); 6 | const { apiUrl } = publicRuntimeConfig; 7 | 8 | function MyComponent() { 9 | const [data, setData] = useState([]); 10 | 11 | const fetchData = async () => { 12 | try { 13 | const response = await fetch(`${apiUrl}/api/test`); 14 | const result = await response.json(); 15 | setData([...data, result]); 16 | 17 | // Schedule the next fetch 18 | setTimeout(fetchData, 1000); 19 | } catch (error) { 20 | console.error("Error fetching data:", error); 21 | setTimeout(fetchData, 5000); // Retry after 5 seconds 22 | } 23 | }; 24 | 25 | useEffect(() => { 26 | fetchData(); 27 | }, []); 28 | 29 | return ( 30 |
31 |

Streaming data:

32 |
    33 | {data.map((item, index) => ( 34 |
  • {JSON.stringify(item)}
  • 35 | ))} 36 |
37 |
38 | ); 39 | } 40 | 41 | export default MyComponent; 42 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: "mediumllweb", "sans-serif"; 3 | } 4 | p > a { 5 | color: #192bd5; 6 | text-decoration: underline; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | const config = { 4 | FINGERPRINTJS: process.env.FINGERPRINTJS_API_KEY 5 | } 6 | 7 | console.log(config) 8 | export default config -------------------------------------------------------------------------------- /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": "./src", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | --------------------------------------------------------------------------------