├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── actions.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.png └── page.tsx ├── components.json ├── components ├── deploy-button.tsx ├── dynamic-chart.tsx ├── header.tsx ├── project-info.tsx ├── query-viewer.tsx ├── results.tsx ├── search.tsx ├── skeleton-card.tsx ├── suggested-queries.tsx └── ui │ ├── alert.tsx │ ├── button.tsx │ ├── card.tsx │ ├── chart.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── input.tsx │ ├── query-with-tooltips.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── table.tsx │ ├── tabs.tsx │ └── tooltip.tsx ├── lib ├── rechart-format.ts ├── seed.ts ├── types.ts └── utils.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── github.svg ├── next.svg └── vercel.svg ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY="your_api_key_here" 2 | POSTGRES_URL="..." 3 | POSTGRES_PRISMA_URL="..." 4 | POSTGRES_URL_NO_SSL="..." 5 | POSTGRES_URL_NON_POOLING="..." 6 | POSTGRES_USER="..." 7 | POSTGRES_HOST="..." 8 | POSTGRES_PASSWORD="..." 9 | POSTGRES_DATABASE="..." -------------------------------------------------------------------------------- /.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 | next-env.d.ts 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # Turborepo 36 | .turbo 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | 41 | unicorns.csv -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Natural Language PostgreSQL 2 | 3 | 4 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fnatural-language-postgres&env=OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fnatural-language-postgres%2Fblob%2Fmain%2F.env.example&demo-title=Natural%20Language%20Postgres&demo-description=Query%20PostgreSQL%20database%20using%20natural%20language%20and%20visualize%20results%20with%20Next.js%20and%20AI%20SDK.&demo-url=https%3A%2F%2Fnatural-language-postgres.vercel.app&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D) 5 | 6 | This project is a Next.js application that allows users to query a PostgreSQL database using natural language and visualize the results. It's powered by the AI SDK by Vercel and uses OpenAI's GPT-4o model to translate natural language queries into SQL. 7 | 8 | ## Features 9 | 10 | - Natural Language to SQL: Users can input queries in plain English, which are then converted to SQL using AI. 11 | - Data Visualization: Results are displayed in both table and chart formats, with the chart type automatically selected based on the data. 12 | - Query Explanation: Users can view the full SQL query and get an AI-generated explanation of each part of the query. 13 | 14 | ## Technology Stack 15 | 16 | - Next.js for the frontend and API routes 17 | - AI SDK by Vercel for AI integration 18 | - OpenAI's GPT-4o for natural language processing 19 | - PostgreSQL for data storage 20 | - Vercel Postgres for database hosting 21 | - Framer Motion for animations 22 | - ShadowUI for UI components 23 | - Tailwind CSS for styling 24 | - Recharts for data visualization 25 | 26 | ## How It Works 27 | 28 | 1. The user enters a natural language query about unicorn companies. 29 | 2. The application uses GPT-4 to generate an appropriate SQL query. 30 | 3. The SQL query is executed against the PostgreSQL database. 31 | 4. Results are displayed in a table format. 32 | 5. An AI-generated chart configuration is created based on the data. 33 | 6. The results are visualized using the generated chart configuration. 34 | 7. Users can toggle between table and chart views. 35 | 8. Users can request an explanation of the SQL query, which is also generated by AI. 36 | 37 | ## Data 38 | 39 | The database contains information about unicorn companies, including: 40 | 41 | - Company name 42 | - Valuation 43 | - Date joined (unicorn status) 44 | - Country 45 | - City 46 | - Industry 47 | - Select investors 48 | 49 | This data is based on CB Insights' list of unicorn companies. 50 | 51 | ## Getting Started 52 | 53 | To get the project up and running, follow these steps: 54 | 55 | 1. Install dependencies: 56 | 57 | ```bash 58 | pnpm install 59 | ``` 60 | 61 | 2. Copy the example environment file: 62 | 63 | ```bash 64 | cp .env.example .env 65 | ``` 66 | 67 | 3. Add your OpenAI API key and PostgreSQL connection string to the `.env` file: 68 | 69 | ``` 70 | OPENAI_API_KEY=your_api_key_here 71 | POSTGRES_URL="..." 72 | POSTGRES_PRISMA_URL="..." 73 | POSTGRES_URL_NO_SSL="..." 74 | POSTGRES_URL_NON_POOLING="..." 75 | POSTGRES_USER="..." 76 | POSTGRES_HOST="..." 77 | POSTGRES_PASSWORD="..." 78 | POSTGRES_DATABASE="..." 79 | ``` 80 | 4. Download the dataset: 81 | - Go to https://www.cbinsights.com/research-unicorn-companies 82 | - Download the unicorn companies dataset 83 | - Save the file as `unicorns.csv` in the root of your project 84 | 85 | 5. Seed the database: 86 | ```bash 87 | pnpm run seed 88 | ``` 89 | 90 | 6. Start the development server: 91 | ```bash 92 | pnpm run dev 93 | ``` 94 | 95 | Your project should now be running on [http://localhost:3000](http://localhost:3000). 96 | 97 | ## Deployment 98 | 99 | The project is set up for easy deployment on Vercel. Use the "Deploy with Vercel" button in the repository to create your own instance of the application. 100 | 101 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fnatural-language-postgres&env=OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fnatural-language-postgres%2Fblob%2Fmain%2F.env.example&demo-title=Natural%20Language%20Postgres&demo-description=Query%20PostgreSQL%20database%20using%20natural%20language%20and%20visualize%20results%20with%20Next.js%20and%20AI%20SDK.&demo-url=https%3A%2F%2Fnatural-language-postgres.vercel.app&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D) 102 | 103 | 104 | ## Learn More 105 | 106 | To learn more about the technologies used in this project, check out the following resources: 107 | 108 | - [Next.js Documentation](https://nextjs.org/docs) 109 | - [AI SDK](https://sdk.vercel.ai/docs) 110 | - [OpenAI](https://openai.com/) 111 | - [Vercel Postgres powered by Neon](https://vercel.com/docs/storage/vercel-postgres) 112 | - [Framer Motion](https://www.framer.com/motion/) 113 | - [ShadcnUI](https://ui.shadcn.com/) 114 | - [Tailwind CSS](https://tailwindcss.com/docs) 115 | - [Recharts](https://recharts.org/en-US/) -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { Config, configSchema, explanationsSchema, Result } from "@/lib/types"; 4 | import { openai } from "@ai-sdk/openai"; 5 | import { sql } from "@vercel/postgres"; 6 | import { generateObject } from "ai"; 7 | import { z } from "zod"; 8 | 9 | export const generateQuery = async (input: string) => { 10 | "use server"; 11 | try { 12 | const result = await generateObject({ 13 | model: openai("gpt-4o"), 14 | system: `You are a SQL (postgres) and data visualization expert. Your job is to help the user write a SQL query to retrieve the data they need. The table schema is as follows: 15 | 16 | unicorns ( 17 | id SERIAL PRIMARY KEY, 18 | company VARCHAR(255) NOT NULL UNIQUE, 19 | valuation DECIMAL(10, 2) NOT NULL, 20 | date_joined DATE, 21 | country VARCHAR(255) NOT NULL, 22 | city VARCHAR(255) NOT NULL, 23 | industry VARCHAR(255) NOT NULL, 24 | select_investors TEXT NOT NULL 25 | ); 26 | 27 | Only retrieval queries are allowed. 28 | 29 | For things like industry, company names and other string fields, use the ILIKE operator and convert both the search term and the field to lowercase using LOWER() function. For example: LOWER(industry) ILIKE LOWER('%search_term%'). 30 | 31 | Note: select_investors is a comma-separated list of investors. Trim whitespace to ensure you're grouping properly. Note, some fields may be null or have only one value. 32 | When answering questions about a specific field, ensure you are selecting the identifying column (ie. what is Vercel's valuation would select company and valuation'). 33 | 34 | The industries available are: 35 | - healthcare & life sciences 36 | - consumer & retail 37 | - financial services 38 | - enterprise tech 39 | - insurance 40 | - media & entertainment 41 | - industrials 42 | - health 43 | 44 | If the user asks for a category that is not in the list, infer based on the list above. 45 | 46 | Note: valuation is in billions of dollars so 10b would be 10.0. 47 | Note: if the user asks for a rate, return it as a decimal. For example, 0.1 would be 10%. 48 | 49 | If the user asks for 'over time' data, return by year. 50 | 51 | When searching for UK or USA, write out United Kingdom or United States respectively. 52 | 53 | EVERY QUERY SHOULD RETURN QUANTITATIVE DATA THAT CAN BE PLOTTED ON A CHART! There should always be at least two columns. If the user asks for a single column, return the column and the count of the column. If the user asks for a rate, return the rate as a decimal. For example, 0.1 would be 10%. 54 | `, 55 | prompt: `Generate the query necessary to retrieve the data the user wants: ${input}`, 56 | schema: z.object({ 57 | query: z.string(), 58 | }), 59 | }); 60 | return result.object.query; 61 | } catch (e) { 62 | console.error(e); 63 | throw new Error("Failed to generate query"); 64 | } 65 | }; 66 | 67 | export const runGenerateSQLQuery = async (query: string) => { 68 | "use server"; 69 | // Check if the query is a SELECT statement 70 | if ( 71 | !query.trim().toLowerCase().startsWith("select") || 72 | query.trim().toLowerCase().includes("drop") || 73 | query.trim().toLowerCase().includes("delete") || 74 | query.trim().toLowerCase().includes("insert") || 75 | query.trim().toLowerCase().includes("update") || 76 | query.trim().toLowerCase().includes("alter") || 77 | query.trim().toLowerCase().includes("truncate") || 78 | query.trim().toLowerCase().includes("create") || 79 | query.trim().toLowerCase().includes("grant") || 80 | query.trim().toLowerCase().includes("revoke") 81 | ) { 82 | throw new Error("Only SELECT queries are allowed"); 83 | } 84 | 85 | let data: any; 86 | try { 87 | data = await sql.query(query); 88 | } catch (e: any) { 89 | if (e.message.includes('relation "unicorns" does not exist')) { 90 | console.log( 91 | "Table does not exist, creating and seeding it with dummy data now...", 92 | ); 93 | // throw error 94 | throw Error("Table does not exist"); 95 | } else { 96 | throw e; 97 | } 98 | } 99 | 100 | return data.rows as Result[]; 101 | }; 102 | 103 | export const explainQuery = async (input: string, sqlQuery: string) => { 104 | "use server"; 105 | try { 106 | const result = await generateObject({ 107 | model: openai("gpt-4o"), 108 | schema: z.object({ 109 | explanations: explanationsSchema, 110 | }), 111 | system: `You are a SQL (postgres) expert. Your job is to explain to the user write a SQL query you wrote to retrieve the data they asked for. The table schema is as follows: 112 | unicorns ( 113 | id SERIAL PRIMARY KEY, 114 | company VARCHAR(255) NOT NULL UNIQUE, 115 | valuation DECIMAL(10, 2) NOT NULL, 116 | date_joined DATE, 117 | country VARCHAR(255) NOT NULL, 118 | city VARCHAR(255) NOT NULL, 119 | industry VARCHAR(255) NOT NULL, 120 | select_investors TEXT NOT NULL 121 | ); 122 | 123 | When you explain you must take a section of the query, and then explain it. Each "section" should be unique. So in a query like: "SELECT * FROM unicorns limit 20", the sections could be "SELECT *", "FROM UNICORNS", "LIMIT 20". 124 | If a section doesnt have any explanation, include it, but leave the explanation empty. 125 | 126 | `, 127 | prompt: `Explain the SQL query you generated to retrieve the data the user wanted. Assume the user is not an expert in SQL. Break down the query into steps. Be concise. 128 | 129 | User Query: 130 | ${input} 131 | 132 | Generated SQL Query: 133 | ${sqlQuery}`, 134 | }); 135 | return result.object; 136 | } catch (e) { 137 | console.error(e); 138 | throw new Error("Failed to generate query"); 139 | } 140 | }; 141 | 142 | export const generateChartConfig = async ( 143 | results: Result[], 144 | userQuery: string, 145 | ) => { 146 | "use server"; 147 | const system = `You are a data visualization expert. `; 148 | 149 | try { 150 | const { object: config } = await generateObject({ 151 | model: openai("gpt-4o"), 152 | system, 153 | prompt: `Given the following data from a SQL query result, generate the chart config that best visualises the data and answers the users query. 154 | For multiple groups use multi-lines. 155 | 156 | Here is an example complete config: 157 | export const chartConfig = { 158 | type: "pie", 159 | xKey: "month", 160 | yKeys: ["sales", "profit", "expenses"], 161 | colors: { 162 | sales: "#4CAF50", // Green for sales 163 | profit: "#2196F3", // Blue for profit 164 | expenses: "#F44336" // Red for expenses 165 | }, 166 | legend: true 167 | } 168 | 169 | User Query: 170 | ${userQuery} 171 | 172 | Data: 173 | ${JSON.stringify(results, null, 2)}`, 174 | schema: configSchema, 175 | }); 176 | 177 | const colors: Record = {}; 178 | config.yKeys.forEach((key, index) => { 179 | colors[key] = `hsl(var(--chart-${index + 1}))`; 180 | }); 181 | 182 | const updatedConfig: Config = { ...config, colors }; 183 | return { config: updatedConfig }; 184 | } catch (e) { 185 | // @ts-expect-errore 186 | console.error(e.message); 187 | throw new Error("Failed to generate chart suggestion"); 188 | } 189 | }; 190 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/natural-language-postgres/2db29a44457d59d55e885270563b10eebb98b247/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | 12 | 13 | @layer base { 14 | :root { 15 | --background: 0 0% 100%; 16 | --foreground: 0 0% 3.9%; 17 | --card: 0 0% 100%; 18 | --card-foreground: 0 0% 3.9%; 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 0 0% 3.9%; 21 | --primary: 0 0% 9%; 22 | --primary-foreground: 0 0% 98%; 23 | --secondary: 0 0% 96.1%; 24 | --secondary-foreground: 0 0% 9%; 25 | --muted: 0 0% 96.1%; 26 | --muted-foreground: 0 0% 45.1%; 27 | --accent: 0 0% 96.1%; 28 | --accent-foreground: 0 0% 9%; 29 | --destructive: 0 84.2% 60.2%; 30 | --destructive-foreground: 0 0% 98%; 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | --radius: 0.5rem; 35 | --chart-1: 240 2% 26%; 36 | --chart-2: 240 1% 42%; 37 | --chart-3: 240 1% 58%; 38 | --chart-4: 240 1% 74%; 39 | --chart-5: 359 2% 90%; 40 | } 41 | 42 | .dark { 43 | --background: 0 0% 3.9%; 44 | --foreground: 0 0% 98%; 45 | --card: 0 0% 3.9%; 46 | --card-foreground: 0 0% 98%; 47 | --popover: 0 0% 3.9%; 48 | --popover-foreground: 0 0% 98%; 49 | --primary: 0 0% 98%; 50 | --primary-foreground: 0 0% 9%; 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | --muted: 0 0% 14.9%; 54 | --muted-foreground: 0 0% 63.9%; 55 | --accent: 0 0% 14.9%; 56 | --accent-foreground: 0 0% 98%; 57 | --destructive: 0 62.8% 30.6%; 58 | --destructive-foreground: 0 0% 98%; 59 | --border: 0 0% 14.9%; 60 | --input: 0 0% 14.9%; 61 | --ring: 0 0% 83.1%; 62 | --chart-1: 359 2% 90%; 63 | --chart-2: 240 1% 74%; 64 | --chart-3: 240 1% 58%; 65 | --chart-4: 240 1% 42%; 66 | --chart-5: 240 2% 26%; 67 | } 68 | } 69 | 70 | body { 71 | @apply bg-neutral-50 dark:bg-neutral-900; 72 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { GeistMono } from "geist/font/mono"; 3 | import { GeistSans } from "geist/font/sans"; 4 | import { ThemeProvider } from "next-themes"; 5 | 6 | export const metadata = { 7 | metadataBase: new URL("https://natural-language-postgres.vercel.app"), 8 | title: "Natural Language Postgres", 9 | description: 10 | "Chat with a Postgres database using natural language powered by the AI SDK by Vercel.", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/natural-language-postgres/2db29a44457d59d55e885270563b10eebb98b247/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { motion, AnimatePresence } from "framer-motion"; 5 | import { 6 | generateChartConfig, 7 | generateQuery, 8 | runGenerateSQLQuery, 9 | } from "./actions"; 10 | import { Config, Result } from "@/lib/types"; 11 | import { Loader2 } from "lucide-react"; 12 | import { toast } from "sonner"; 13 | import { ProjectInfo } from "@/components/project-info"; 14 | import { Results } from "@/components/results"; 15 | import { SuggestedQueries } from "@/components/suggested-queries"; 16 | import { QueryViewer } from "@/components/query-viewer"; 17 | import { Search } from "@/components/search"; 18 | import { Header } from "@/components/header"; 19 | 20 | export default function Page() { 21 | const [inputValue, setInputValue] = useState(""); 22 | const [submitted, setSubmitted] = useState(false); 23 | const [results, setResults] = useState([]); 24 | const [columns, setColumns] = useState([]); 25 | const [activeQuery, setActiveQuery] = useState(""); 26 | const [loading, setLoading] = useState(false); 27 | const [loadingStep, setLoadingStep] = useState(1); 28 | const [chartConfig, setChartConfig] = useState(null); 29 | 30 | const handleSubmit = async (suggestion?: string) => { 31 | const question = suggestion ?? inputValue; 32 | if (inputValue.length === 0 && !suggestion) return; 33 | clearExistingData(); 34 | if (question.trim()) { 35 | setSubmitted(true); 36 | } 37 | setLoading(true); 38 | setLoadingStep(1); 39 | setActiveQuery(""); 40 | try { 41 | const query = await generateQuery(question); 42 | if (query === undefined) { 43 | toast.error("An error occurred. Please try again."); 44 | setLoading(false); 45 | return; 46 | } 47 | setActiveQuery(query); 48 | setLoadingStep(2); 49 | const companies = await runGenerateSQLQuery(query); 50 | const columns = companies.length > 0 ? Object.keys(companies[0]) : []; 51 | setResults(companies); 52 | setColumns(columns); 53 | setLoading(false); 54 | const generation = await generateChartConfig(companies, question); 55 | setChartConfig(generation.config); 56 | } catch (e) { 57 | toast.error("An error occurred. Please try again."); 58 | setLoading(false); 59 | } 60 | }; 61 | 62 | const handleSuggestionClick = async (suggestion: string) => { 63 | setInputValue(suggestion); 64 | try { 65 | await handleSubmit(suggestion); 66 | } catch (e) { 67 | toast.error("An error occurred. Please try again."); 68 | } 69 | }; 70 | 71 | const clearExistingData = () => { 72 | setActiveQuery(""); 73 | setResults([]); 74 | setColumns([]); 75 | setChartConfig(null); 76 | }; 77 | 78 | const handleClear = () => { 79 | setSubmitted(false); 80 | setInputValue(""); 81 | clearExistingData(); 82 | }; 83 | 84 | return ( 85 |
86 |
87 | 93 |
94 |
95 | 102 |
106 |
107 | 108 | {!submitted ? ( 109 | 112 | ) : ( 113 | 121 | {activeQuery.length > 0 && ( 122 | 126 | )} 127 | {loading ? ( 128 |
129 | 130 |

131 | {loadingStep === 1 132 | ? "Generating SQL query..." 133 | : "Running SQL query..."} 134 |

135 |
136 | ) : results.length === 0 ? ( 137 |
138 |

139 | No results found. 140 |

141 |
142 | ) : ( 143 | 148 | )} 149 |
150 | )} 151 |
152 |
153 |
154 |
155 | 156 |
157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /components/deploy-button.tsx: -------------------------------------------------------------------------------- 1 | export const DeployButton = () => ( 2 | 6 | Deploy with Vercel 7 | 8 | ); -------------------------------------------------------------------------------- /components/dynamic-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { 5 | Bar, 6 | BarChart, 7 | Line, 8 | LineChart, 9 | Area, 10 | AreaChart, 11 | Pie, 12 | PieChart, 13 | Cell, 14 | XAxis, 15 | YAxis, 16 | CartesianGrid, 17 | Legend, 18 | } from "recharts"; 19 | import { 20 | ChartContainer, 21 | ChartTooltip, 22 | ChartTooltipContent, 23 | } from "@/components/ui/chart"; 24 | import { Config, Result } from "@/lib/types"; 25 | import { Label } from "recharts"; 26 | import { transformDataForMultiLineChart } from "@/lib/rechart-format"; 27 | 28 | function toTitleCase(str: string): string { 29 | return str 30 | .split("_") 31 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 32 | .join(" "); 33 | } 34 | const colors = [ 35 | "hsl(var(--chart-1))", 36 | "hsl(var(--chart-2))", 37 | "hsl(var(--chart-3))", 38 | "hsl(var(--chart-4))", 39 | "hsl(var(--chart-5))", 40 | "hsl(var(--chart-6))", 41 | "hsl(var(--chart-7))", 42 | "hsl(var(--chart-8))", 43 | ]; 44 | 45 | export function DynamicChart({ 46 | chartData, 47 | chartConfig, 48 | }: { 49 | chartData: Result[]; 50 | chartConfig: Config; 51 | }) { 52 | const renderChart = () => { 53 | if (!chartData || !chartConfig) return
No chart data
; 54 | const parsedChartData = chartData.map((item) => { 55 | const parsedItem: { [key: string]: any } = {}; 56 | for (const [key, value] of Object.entries(item)) { 57 | parsedItem[key] = isNaN(Number(value)) ? value : Number(value); 58 | } 59 | return parsedItem; 60 | }); 61 | 62 | chartData = parsedChartData; 63 | 64 | const processChartData = (data: Result[], chartType: string) => { 65 | if (chartType === "bar" || chartType === "pie") { 66 | if (data.length <= 8) { 67 | return data; 68 | } 69 | 70 | const subset = data.slice(0, 20); 71 | return subset; 72 | } 73 | return data; 74 | }; 75 | 76 | chartData = processChartData(chartData, chartConfig.type); 77 | // console.log({ chartData, chartConfig }); 78 | 79 | switch (chartConfig.type) { 80 | case "bar": 81 | return ( 82 | 83 | 84 | 85 | 91 | 92 | 98 | } /> 99 | {chartConfig.legend && } 100 | {chartConfig.yKeys.map((key, index) => ( 101 | 106 | ))} 107 | 108 | ); 109 | case "line": 110 | const { data, xAxisField, lineFields } = transformDataForMultiLineChart( 111 | chartData, 112 | chartConfig, 113 | ); 114 | const useTransformedData = 115 | chartConfig.multipleLines && 116 | chartConfig.measurementColumn && 117 | chartConfig.yKeys.includes(chartConfig.measurementColumn); 118 | // console.log(useTransformedData, "useTransformedData"); 119 | // const useTransformedData = false; 120 | return ( 121 | 122 | 123 | 126 | 134 | 135 | 141 | } /> 142 | {chartConfig.legend && } 143 | {useTransformedData 144 | ? lineFields.map((key, index) => ( 145 | 151 | )) 152 | : chartConfig.yKeys.map((key, index) => ( 153 | 159 | ))} 160 | 161 | ); 162 | case "area": 163 | return ( 164 | 165 | 166 | 167 | 168 | } /> 169 | {chartConfig.legend && } 170 | {chartConfig.yKeys.map((key, index) => ( 171 | 178 | ))} 179 | 180 | ); 181 | case "pie": 182 | return ( 183 | 184 | 192 | {chartData.map((_, index) => ( 193 | 197 | ))} 198 | 199 | } /> 200 | {chartConfig.legend && } 201 | 202 | ); 203 | default: 204 | return
Unsupported chart type: {chartConfig.type}
; 205 | } 206 | }; 207 | 208 | return ( 209 |
210 |

{chartConfig.title}

211 | {chartConfig && chartData.length > 0 && ( 212 | { 215 | acc[key] = { 216 | label: key, 217 | color: colors[index % colors.length], 218 | }; 219 | return acc; 220 | }, 221 | {} as Record, 222 | )} 223 | className="h-[320px] w-full" 224 | > 225 | {renderChart()} 226 | 227 | )} 228 |
229 |

{chartConfig.description}

230 |

{chartConfig.takeaway}

231 |
232 |
233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | import { DeployButton } from "./deploy-button"; 3 | import { Button } from "./ui/button"; 4 | import { useTheme } from "next-themes"; 5 | 6 | export const Header = ({ handleClear }: { handleClear: () => void }) => { 7 | const { theme, setTheme } = useTheme(); 8 | 9 | return ( 10 |
11 |

handleClear()} 14 | > 15 | Natural Language PostgreSQL 16 |

17 |
18 | 30 |
31 | 32 |
33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/project-info.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import { DeployButton } from "./deploy-button"; 3 | import { Alert, AlertDescription } from "./ui/alert"; 4 | import Link from "next/link"; 5 | 6 | export const ProjectInfo = () => { 7 | return ( 8 |
9 | 10 | 11 | 12 | This application uses the{" "} 13 | 18 | AI SDK 19 | {" "} 20 | to allow you to query a PostgreSQL database with natural language. The 21 | dataset is CB Insights' list of all unicorn companies. Learn more 22 | at{" "} 23 | 28 | CB Insights 29 | 30 | . 31 |
32 | 33 |
34 |
35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/query-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button } from "./ui/button"; 3 | import { QueryWithTooltips } from "./ui/query-with-tooltips"; 4 | import { explainQuery } from "@/app/actions"; 5 | import { QueryExplanation } from "@/lib/types"; 6 | import { CircleHelp, Loader2 } from "lucide-react"; 7 | 8 | export const QueryViewer = ({ 9 | activeQuery, 10 | inputValue, 11 | }: { 12 | activeQuery: string; 13 | inputValue: string; 14 | }) => { 15 | const activeQueryCutoff = 100; 16 | 17 | const [queryExplanations, setQueryExplanations] = useState< 18 | QueryExplanation[] | null 19 | >(); 20 | const [loadingExplanation, setLoadingExplanation] = useState(false); 21 | const [queryExpanded, setQueryExpanded] = useState(activeQuery.length > activeQueryCutoff); 22 | 23 | const handleExplainQuery = async () => { 24 | setQueryExpanded(true); 25 | setLoadingExplanation(true); 26 | const { explanations } = await explainQuery(inputValue, activeQuery); 27 | setQueryExplanations(explanations); 28 | setLoadingExplanation(false); 29 | }; 30 | 31 | if (activeQuery.length === 0) return null; 32 | 33 | return ( 34 |
35 |
38 |
39 | {queryExpanded ? ( 40 | queryExplanations && queryExplanations.length > 0 ? ( 41 | <> 42 | 46 |

47 | Generated explanation! Hover over different parts of the SQL 48 | query to see explanations. 49 |

50 | 51 | ) : ( 52 |
53 | {activeQuery} 54 | 68 |
69 | ) 70 | ) : ( 71 | 72 | {activeQuery.slice(0, activeQueryCutoff)} 73 | {activeQuery.length > activeQueryCutoff ? "..." : ""} 74 | 75 | )} 76 |
77 |
78 | {!queryExpanded && ( 79 | 87 | )} 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /components/results.tsx: -------------------------------------------------------------------------------- 1 | import { Config, Result, Unicorn } from "@/lib/types"; 2 | import { DynamicChart } from "./dynamic-chart"; 3 | import { SkeletonCard } from "./skeleton-card"; 4 | import { 5 | TableHeader, 6 | TableRow, 7 | TableHead, 8 | TableBody, 9 | TableCell, 10 | Table, 11 | } from "./ui/table"; 12 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; 13 | 14 | export const Results = ({ 15 | results, 16 | columns, 17 | chartConfig, 18 | }: { 19 | results: Result[]; 20 | columns: string[]; 21 | chartConfig: Config | null; 22 | }) => { 23 | const formatColumnTitle = (title: string) => { 24 | return title 25 | .split("_") 26 | .map((word, index) => 27 | index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word, 28 | ) 29 | .join(" "); 30 | }; 31 | 32 | const formatCellValue = (column: string, value: any) => { 33 | if (column.toLowerCase().includes("valuation")) { 34 | const parsedValue = parseFloat(value); 35 | if (isNaN(parsedValue)) { 36 | return ""; 37 | } 38 | const formattedValue = parsedValue.toFixed(2); 39 | const trimmedValue = formattedValue.replace(/\.?0+$/, ""); 40 | return `$${trimmedValue}B`; 41 | } 42 | if (column.toLowerCase().includes("rate")) { 43 | const parsedValue = parseFloat(value); 44 | if (isNaN(parsedValue)) { 45 | return ""; 46 | } 47 | const percentage = (parsedValue * 100).toFixed(2); 48 | return `${percentage}%`; 49 | } 50 | if (value instanceof Date) { 51 | return value.toLocaleDateString(); 52 | } 53 | return String(value); 54 | }; 55 | 56 | return ( 57 |
58 | 59 | 60 | Table 61 | 67 | Chart 68 | 69 | 70 | 71 |
72 | 73 | 74 | 75 | {columns.map((column, index) => ( 76 | 80 | {formatColumnTitle(column)} 81 | 82 | ))} 83 | 84 | 85 | 86 | {results.map((company, index) => ( 87 | 88 | {columns.map((column, cellIndex) => ( 89 | 93 | {formatCellValue( 94 | column, 95 | company[column as keyof Unicorn], 96 | )} 97 | 98 | ))} 99 | 100 | ))} 101 | 102 |
103 |
104 |
105 | 106 |
107 | {chartConfig && results.length > 0 ? ( 108 | 109 | ) : ( 110 | 111 | )} 112 |
113 |
114 |
115 |
116 | ); 117 | }; 118 | -------------------------------------------------------------------------------- /components/search.tsx: -------------------------------------------------------------------------------- 1 | import { Search as SearchIcon } from "lucide-react"; 2 | import { Button } from "./ui/button"; 3 | import { Input } from "./ui/input"; 4 | 5 | export const Search = ({ 6 | handleSubmit, 7 | inputValue, 8 | setInputValue, 9 | submitted, 10 | handleClear, 11 | }: { 12 | handleSubmit: () => Promise; 13 | inputValue: string; 14 | setInputValue: React.Dispatch>; 15 | submitted: boolean; 16 | handleClear: () => void; 17 | }) => { 18 | return ( 19 |
{ 21 | e.preventDefault(); 22 | await handleSubmit(); 23 | }} 24 | className="mb-6" 25 | > 26 |
27 |
28 | setInputValue(e.target.value)} 33 | className="pr-10 text-base" 34 | /> 35 | 36 |
37 |
38 | {submitted ? ( 39 | 47 | ) : ( 48 | 51 | )} 52 |
53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /components/skeleton-card.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export function SkeletonCard() { 4 | return ( 5 |
6 |
7 | 8 |
9 | 10 |
11 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/suggested-queries.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { Button } from "./ui/button"; 3 | 4 | export const SuggestedQueries = ({ 5 | handleSuggestionClick, 6 | }: { 7 | handleSuggestionClick: (suggestion: string) => void; 8 | }) => { 9 | const suggestionQueries = [ 10 | { 11 | desktop: "Compare count of unicorns in SF and NY over time", 12 | mobile: "SF vs NY", 13 | }, 14 | { 15 | desktop: "Compare unicorn valuations in the US vs China over time", 16 | mobile: "US vs China", 17 | }, 18 | { 19 | desktop: "Countries with highest unicorn density", 20 | mobile: "Top countries", 21 | }, 22 | { 23 | desktop: 24 | "Show the number of unicorns founded each year over the past two decades", 25 | mobile: "Yearly count", 26 | }, 27 | { 28 | desktop: "Display the cumulative total valuation of unicorns over time", 29 | mobile: "Total value", 30 | }, 31 | { 32 | desktop: 33 | "Compare the yearly funding amounts for fintech vs healthtech unicorns", 34 | mobile: "Fintech vs health", 35 | }, 36 | { 37 | desktop: "Which cities have with most SaaS unicorns", 38 | mobile: "SaaS cities", 39 | }, 40 | { 41 | desktop: "Show the countries with highest unicorn density", 42 | mobile: "Dense nations", 43 | }, 44 | { 45 | desktop: 46 | "Show the number of unicorns (grouped by year) over the past decade", 47 | mobile: "Decade trend", 48 | }, 49 | { 50 | desktop: 51 | "Compare the average valuation of AI companies vs. biotech companies", 52 | mobile: "AI vs biotech", 53 | }, 54 | { 55 | desktop: "Investors with the most unicorns", 56 | mobile: "Top investors", 57 | }, 58 | ]; 59 | 60 | return ( 61 | 69 |

70 | Try these queries: 71 |

72 |
73 | {suggestionQueries.map((suggestion, index) => ( 74 | 84 | ))} 85 |
86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/ui/chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RechartsPrimitive from "recharts" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | // Format: { THEME_NAME: CSS_SELECTOR } 9 | const THEMES = { light: "", dark: ".dark" } as const 10 | 11 | export type ChartConfig = { 12 | [k in string]: { 13 | label?: React.ReactNode 14 | icon?: React.ComponentType 15 | } & ( 16 | | { color?: string; theme?: never } 17 | | { color?: never; theme: Record } 18 | ) 19 | } 20 | 21 | type ChartContextProps = { 22 | config: ChartConfig 23 | } 24 | 25 | const ChartContext = React.createContext(null) 26 | 27 | function useChart() { 28 | const context = React.useContext(ChartContext) 29 | 30 | if (!context) { 31 | throw new Error("useChart must be used within a ") 32 | } 33 | 34 | return context 35 | } 36 | 37 | const ChartContainer = React.forwardRef< 38 | HTMLDivElement, 39 | React.ComponentProps<"div"> & { 40 | config: ChartConfig 41 | children: React.ComponentProps< 42 | typeof RechartsPrimitive.ResponsiveContainer 43 | >["children"] 44 | } 45 | >(({ id, className, children, config, ...props }, ref) => { 46 | const uniqueId = React.useId() 47 | const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` 48 | 49 | return ( 50 | 51 |
60 | 61 | 62 | {children} 63 | 64 |
65 |
66 | ) 67 | }) 68 | ChartContainer.displayName = "Chart" 69 | 70 | const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 71 | const colorConfig = Object.entries(config).filter( 72 | ([_, config]) => config.theme || config.color 73 | ) 74 | 75 | if (!colorConfig.length) { 76 | return null 77 | } 78 | 79 | return ( 80 |