├── .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 |
--------------------------------------------------------------------------------