├── .env.example ├── .gitignore ├── .nvmrc ├── .prettierignore ├── LICENSE ├── README.md ├── app ├── PostHogPageView.tsx ├── api │ └── research │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.png ├── page.tsx ├── posthog.ts ├── providers.tsx └── twitter-image.png ├── components.json ├── components ├── chat │ ├── api-key-dialog.tsx │ ├── chat.tsx │ ├── download-txt.tsx │ ├── input.tsx │ ├── markdown.tsx │ ├── message.tsx │ └── research-progress.tsx ├── site-header.tsx ├── theme-provider.tsx └── ui │ ├── button.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── slider.tsx │ └── tooltip.tsx ├── lib ├── constants.ts ├── deep-research │ ├── ai │ │ ├── providers.ts │ │ ├── text-splitter.test.ts │ │ └── text-splitter.ts │ ├── deep-research.ts │ ├── feedback.ts │ ├── index.ts │ └── prompt.ts ├── hooks │ └── use-scroll-to-bottom.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── logo.png └── providers │ └── openai.webp ├── supabase ├── .gitignore ├── config.toml ├── functions │ ├── _shared │ │ ├── cors.ts │ │ ├── feedback.ts │ │ └── types.ts │ ├── feedback │ │ └── index.ts │ ├── keys │ │ └── index.ts │ └── research │ │ ├── deep-research │ │ ├── ai │ │ │ ├── providers.ts │ │ │ ├── text-splitter.test.ts │ │ │ └── text-splitter.ts │ │ ├── deep-research.ts │ │ ├── feedback.ts │ │ ├── index.ts │ │ └── prompt.ts │ │ └── index.ts └── seed.sql ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | 2 | #### AI API KEYS 3 | OPENAI_API_KEY=your-openai-api-key 4 | FIRECRAWL_KEY=your-firecrawl-api-key 5 | NEXT_PUBLIC_ENABLE_API_KEYS=false 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Output files 4 | output.md 5 | 6 | # Dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # Local env files 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | # Testing 19 | coverage 20 | 21 | # Turbo 22 | .turbo 23 | 24 | # Vercel 25 | .vercel 26 | 27 | # Build Outputs 28 | .next/ 29 | out/ 30 | build 31 | dist 32 | 33 | .open-next 34 | 35 | # Debug 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | 40 | # Misc 41 | .DS_Store 42 | *.pem 43 | 44 | 45 | # IDE/Editor specific 46 | .idea/ 47 | .vscode/ 48 | *.swp 49 | *.swo 50 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.hbs -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 David Zhang 4 | Copyright (c) 2025 Fekri 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supa Deep Research 2 | 3 | This app was created by the [Supavec team](https://www.supavec.com), based on [open-deep-research](https://github.com/fdarkaou/open-deep-research). 4 | 5 | I wanted to run this app on affordable + OSS stack, which is Supabase. 6 | 7 | There're 3 main APIs: 8 | - /api/feedback: Supabase Edge Functions 9 | - /api/keys: Supabase Edge Functions 10 | - /api/research: (Still) Vercel 11 | 12 | I really want to move `/api/research` to Supabase Edge Functions, but the CPU time exceeded error prevents me from doing so. 13 | 14 | Hopefully soon! 15 | 16 | (`/supabase/functions/api/research` does work on your local tho with `$ supabase functions serve research --no-verify-jwt`) 17 | 18 | Also, I tried to [host this app on Cloudflare Workers](https://x.com/martindonadieu/status/1889630161819074988), but it was too slow. A simple API call takes about 10 secs (sry I'm a CF noob). 19 | 20 | ## Overview 21 | 22 | Supa Deep Research Web UI is an AI-powered research assistant that transforms the original CLI tool into a modern web interface using Next.js and shadcn/ui. Try it out at [supa-deep-research.com](https://www.supa-deep-research.com) with your own API keys, or host it yourself. 23 | 24 | The system combines search engines (via FireCrawl), web scraping, and language models (via OpenAI) to perform deep research on any topic. Key features include: 25 | 26 | - **Intelligent Research Process:** 27 | 28 | - Performs iterative research by recursively exploring topics in depth 29 | - Uses LLMs to generate targeted search queries based on research goals 30 | - Creates follow-up questions to better understand research needs 31 | - Processes multiple searches and results in parallel for efficiency 32 | - Configurable depth and breadth parameters to control research scope 33 | 34 | - **Research Output:** 35 | 36 | - Produces detailed markdown reports with findings and sources 37 | - Real-time progress tracking of research steps 38 | - Built-in markdown viewer for reviewing results 39 | - Downloadable research reports 40 | 41 | - **Modern Interface:** 42 | - Interactive controls for adjusting research parameters 43 | - Visual feedback for ongoing research progress 44 | - HTTP-only cookie storage for API keys 45 | 46 | The system maintains the core research capabilities of the original CLI while providing an intuitive visual interface for controlling and monitoring the research process. 47 | 48 | 49 | ### Installation 50 | 51 | 1. **Clone and Install** 52 | 53 | ```bash 54 | git clone https://github.com/taishikato/supa-deep-research.git 55 | cd open-deep-research 56 | npm install 57 | ``` 58 | 59 | 2. **Configure Environment** 60 | 61 | Create `.env.local` and add: 62 | 63 | ```bash 64 | OPENAI_API_KEY=your-openai-api-key 65 | FIRECRAWL_KEY=your-firecrawl-api-key 66 | NEXT_PUBLIC_ENABLE_API_KEYS=false # Set to false to disable API key dialog 67 | ``` 68 | 69 | 3. **Run the App** 70 | ```bash 71 | npm run dev 72 | ``` 73 | Visit [http://localhost:3000](http://localhost:3000) 74 | 75 | ## API Key Management 76 | 77 | By default (`NEXT_PUBLIC_ENABLE_API_KEYS=true`), the app includes an API key input dialog that allows users to try out the research assistant directly in their browser using their own API keys. Keys are stored securely in HTTP-only cookies and are never exposed to client-side JavaScript. 78 | 79 | For your own deployment, you can disable this dialog by setting `NEXT_PUBLIC_ENABLE_API_KEYS=false` and configure the API keys directly in your `.env.local` file instead. 80 | 81 | ## License 82 | 83 | MIT License. Feel free to use and modify the code for your own projects as you wish. 84 | 85 | ## Acknowledgements 86 | 87 | - **Original CLI:** [dzhng/deep-research](https://github.com/dzhng/deep-research) 88 | - **Original Web UI:** [Open Deep Research](https://anotherwrapper.com/open-deep-research) 89 | - **Tools:** Next.js, shadcn/ui, Vercel AI SDK, Supabase 90 | 91 | Happy researching! 92 | -------------------------------------------------------------------------------- /app/PostHogPageView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useSearchParams } from "next/navigation"; 4 | import { useEffect, Suspense } from "react"; 5 | import { usePostHog } from "posthog-js/react"; 6 | 7 | function PostHogPageView(): null { 8 | const pathname = usePathname(); 9 | const searchParams = useSearchParams(); 10 | const posthog = usePostHog(); 11 | 12 | // Track pageviews 13 | useEffect(() => { 14 | if (pathname && posthog) { 15 | let url = window.origin + pathname; 16 | if (searchParams.toString()) { 17 | url = url + `?${searchParams.toString()}`; 18 | } 19 | 20 | posthog.capture("$pageview", { $current_url: url }); 21 | } 22 | }, [pathname, searchParams, posthog]); 23 | 24 | return null; 25 | } 26 | 27 | // Wrap this in Suspense to avoid the `useSearchParams` usage above 28 | // from de-opting the whole app into client-side rendering 29 | // See: https://nextjs.org/docs/messages/deopted-into-client-rendering 30 | export function SuspendedPostHogPageView() { 31 | return ( 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/api/research/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export const maxDuration = 300; 4 | 5 | import { 6 | deepResearch, 7 | generateFeedback, 8 | writeFinalReport, 9 | } from "@/lib/deep-research"; 10 | import { type AIModel, createModel } from "@/lib/deep-research/ai/providers"; 11 | import PostHogClient from "@/app/posthog"; 12 | 13 | export async function POST(req: NextRequest) { 14 | const posthog = PostHogClient(); 15 | 16 | posthog.capture({ 17 | distinctId: req.headers.get("x-forwarded-for") ?? "unknown", 18 | event: "Research started", 19 | }); 20 | 21 | try { 22 | const { 23 | query, 24 | breadth = 3, 25 | depth = 2, 26 | modelId = "o3-mini", 27 | } = await req.json(); 28 | 29 | // Retrieve API keys from secure cookies 30 | const openaiKey = req.cookies.get("openai-key")?.value; 31 | const firecrawlKey = req.cookies.get("firecrawl-key")?.value; 32 | 33 | // Add API key validation 34 | if (process.env.NEXT_PUBLIC_ENABLE_API_KEYS === "true") { 35 | if (!openaiKey || !firecrawlKey) { 36 | return Response.json( 37 | { error: "API keys are required but not provided" }, 38 | { status: 401 }, 39 | ); 40 | } 41 | } 42 | 43 | console.log("\n🔬 [RESEARCH ROUTE] === Request Started ==="); 44 | console.log("Query:", query); 45 | console.log("Model ID:", modelId); 46 | console.log("Configuration:", { 47 | breadth, 48 | depth, 49 | }); 50 | console.log("API Keys Present:", { 51 | OpenAI: openaiKey ? "✅" : "❌", 52 | FireCrawl: firecrawlKey ? "✅" : "❌", 53 | }); 54 | 55 | try { 56 | const model = createModel(modelId as AIModel, openaiKey); 57 | console.log("\n🤖 [RESEARCH ROUTE] === Model Created ==="); 58 | console.log("Using Model:", modelId); 59 | 60 | // Create a ReadableStream with a custom controller 61 | const stream = new ReadableStream({ 62 | start: async (controller) => { 63 | const encoder = new TextEncoder(); 64 | 65 | const writeToStream = async (data: any) => { 66 | try { 67 | const encodedData = encoder.encode( 68 | `data: ${JSON.stringify(data)}\n\n`, 69 | ); 70 | controller.enqueue(encodedData); 71 | } catch (error) { 72 | console.error("Stream write error:", error); 73 | } 74 | }; 75 | 76 | try { 77 | console.log("\n🚀 [RESEARCH ROUTE] === Research Started ==="); 78 | 79 | const feedbackQuestions = await generateFeedback({ 80 | query, 81 | apiKey: openaiKey, 82 | modelId, 83 | }); 84 | console.log("before writeToStream"); 85 | 86 | await writeToStream({ 87 | type: "progress", 88 | step: { 89 | type: "query", 90 | content: "Generated feedback questions", 91 | }, 92 | }); 93 | console.log("after writeToStream"); 94 | 95 | const { learnings, visitedUrls } = await deepResearch({ 96 | query, 97 | breadth, 98 | depth, 99 | model, 100 | firecrawlKey, 101 | onProgress: async (update: string) => { 102 | console.log("\n📊 [RESEARCH ROUTE] Progress Update:", update); 103 | await writeToStream({ 104 | type: "progress", 105 | step: { 106 | type: "research", 107 | content: update, 108 | }, 109 | }); 110 | }, 111 | }); 112 | 113 | console.log("\n✅ [RESEARCH ROUTE] === Research Completed ==="); 114 | console.log("Learnings Count:", learnings.length); 115 | console.log("Visited URLs Count:", visitedUrls.length); 116 | 117 | const report = await writeFinalReport({ 118 | prompt: query, 119 | learnings, 120 | visitedUrls, 121 | model, 122 | }); 123 | 124 | await writeToStream({ 125 | type: "result", 126 | feedbackQuestions, 127 | learnings, 128 | visitedUrls, 129 | report, 130 | }); 131 | } catch (error) { 132 | console.error( 133 | "\n❌ [RESEARCH ROUTE] === Research Process Error ===", 134 | ); 135 | console.error("Error:", error); 136 | await writeToStream({ 137 | type: "error", 138 | message: "Research failed", 139 | }); 140 | } finally { 141 | controller.close(); 142 | } 143 | }, 144 | }); 145 | 146 | return new Response(stream, { 147 | headers: { 148 | "Content-Type": "text/event-stream", 149 | "Cache-Control": "no-cache", 150 | Connection: "keep-alive", 151 | }, 152 | }); 153 | } catch (error) { 154 | console.error("\n💥 [RESEARCH ROUTE] === Route Error ==="); 155 | console.error("Error:", error); 156 | return Response.json({ error: "Research failed" }, { status: 500 }); 157 | } 158 | } catch (error) { 159 | console.error("\n💥 [RESEARCH ROUTE] === Parse Error ==="); 160 | console.error("Error:", error); 161 | return Response.json({ error: "Research failed" }, { status: 500 }); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supavec/supa-deep-research/8a51cdf82e0e04cf51cfc0c28854a2701ee6a456/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | /* @import url("https://fonts.googleapis.com/css2?family=Poetsen+One&display=swap"); */ 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | /* Hide scroll bar */ 8 | @layer utilities { 9 | /* Hide scrollbar for Chrome, Safari and Opera */ 10 | .no-scrollbar::-webkit-scrollbar { 11 | display: none; 12 | } 13 | 14 | /* Hide scrollbar for IE, Edge and Firefox */ 15 | .no-scrollbar { 16 | -ms-overflow-style: none; /* IE and Edge */ 17 | scrollbar-width: none; /* Firefox */ 18 | } 19 | } 20 | 21 | @layer base { 22 | :root { 23 | --background: 0 0% 100%; 24 | --foreground: 222.2 84% 4.9%; 25 | 26 | --card: 0 0% 100%; 27 | --card-foreground: 222.2 84% 4.9%; 28 | 29 | --popover: 0 0% 100%; 30 | --popover-foreground: 222.2 84% 4.9%; 31 | 32 | --primary: 222.2 47.4% 11.2%; 33 | --primary-foreground: 210 40% 98%; 34 | 35 | --secondary: 210 40% 96.1%; 36 | --secondary-foreground: 222.2 47.4% 11.2%; 37 | 38 | --muted: 210 40% 96.1%; 39 | --muted-foreground: 215.4 16.3% 46.9%; 40 | 41 | --accent: 0 0% 9%; /* #171717 */ 42 | --accent-foreground: 0 0% 100%; /* White text */ 43 | 44 | --destructive: 0 84.2% 60.2%; 45 | --destructive-foreground: 210 40% 98%; 46 | 47 | --border: 214.3 31.8% 91.4%; 48 | --input: 214.3 31.8% 91.4%; 49 | --ring: 222.2 84% 4.9%; 50 | 51 | --radius: 0.5rem; 52 | 53 | --sidebar-background: 0 0% 98%; 54 | 55 | --sidebar-foreground: 240 5.3% 26.1%; 56 | 57 | --sidebar-primary: 240 5.9% 10%; 58 | 59 | --sidebar-primary-foreground: 0 0% 98%; 60 | 61 | --sidebar-accent: 240 4.8% 95.9%; 62 | 63 | --sidebar-accent-foreground: 240 5.9% 10%; 64 | 65 | --sidebar-border: 220 13% 91%; 66 | 67 | --sidebar-ring: 217.2 91.2% 59.8%; 68 | } 69 | 70 | .dark { 71 | --background: 222.2 84% 4.9%; 72 | --foreground: 210 40% 98%; 73 | 74 | --card: 222.2 84% 4.9%; 75 | --card-foreground: 210 40% 98%; 76 | 77 | --popover: 222.2 84% 4.9%; 78 | --popover-foreground: 210 40% 98%; 79 | 80 | --primary: 210 40% 98%; 81 | --primary-foreground: 222.2 47.4% 11.2%; 82 | 83 | --secondary: 217.2 32.6% 17.5%; 84 | --secondary-foreground: 210 40% 98%; 85 | 86 | --muted: 217.2 32.6% 17.5%; 87 | --muted-foreground: 215 20.2% 65.1%; 88 | 89 | --accent: 217.2 32.6% 17.5%; 90 | --accent-foreground: 210 40% 98%; 91 | 92 | --destructive: 0 62.8% 30.6%; 93 | --destructive-foreground: 210 40% 98%; 94 | 95 | --border: 217.2 32.6% 17.5%; 96 | --input: 217.2 32.6% 17.5%; 97 | --ring: 212.7 26.8% 83.9%; 98 | --sidebar-background: 240 5.9% 10%; 99 | --sidebar-foreground: 240 4.8% 95.9%; 100 | --sidebar-primary: 224.3 76.3% 48%; 101 | --sidebar-primary-foreground: 0 0% 100%; 102 | --sidebar-accent: 240 3.7% 15.9%; 103 | --sidebar-accent-foreground: 240 4.8% 95.9%; 104 | --sidebar-border: 240 3.7% 15.9%; 105 | --sidebar-ring: 217.2 91.2% 59.8%; 106 | } 107 | } 108 | 109 | @layer base { 110 | * { 111 | @apply border-border; 112 | } 113 | body { 114 | @apply bg-background text-foreground; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | 3 | import type { Metadata } from "next"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { APP_NAME, APP_DESCRIPTION } from "@/lib/constants"; 6 | import { GeistMono } from "geist/font/mono"; 7 | import { GeistSans } from "geist/font/sans"; 8 | import { CSPostHogProvider } from "./providers"; 9 | import { GoogleAnalytics } from "@next/third-parties/google"; 10 | 11 | export const metadata: Metadata = { 12 | title: APP_NAME, 13 | description: APP_DESCRIPTION, 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 27 | 28 | 29 | 34 | {children} 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supavec/supa-deep-research/8a51cdf82e0e04cf51cfc0c28854a2701ee6a456/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chat/chat"; 2 | import { Header } from "@/components/site-header"; 3 | 4 | export default function ResearchPage() { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/posthog.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from "posthog-node"; 2 | 3 | export default function PostHogClient() { 4 | const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { 5 | host: process.env.NEXT_PUBLIC_POSTHOG_HOST!, 6 | flushAt: 1, 7 | flushInterval: 0, 8 | }); 9 | return posthogClient; 10 | } 11 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import posthog from "posthog-js"; 4 | import { PostHogProvider } from "posthog-js/react"; 5 | import { SuspendedPostHogPageView } from "./PostHogPageView"; 6 | 7 | if (typeof window !== "undefined") { 8 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, { 9 | api_host: "/ingest", 10 | ui_host: "https://us.posthog.com", 11 | person_profiles: "identified_only", 12 | }); 13 | } 14 | export function CSPostHogProvider({ children }: { children: React.ReactNode }) { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supavec/supa-deep-research/8a51cdf82e0e04cf51cfc0c28854a2701ee6a456/app/twitter-image.png -------------------------------------------------------------------------------- /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.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/chat/api-key-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Image from "next/image"; 5 | import { 6 | LockIcon, 7 | KeyIcon, 8 | Loader2Icon, 9 | ShieldCheckIcon, 10 | GithubIcon, 11 | } from "lucide-react"; 12 | 13 | import { 14 | Dialog, 15 | DialogContent, 16 | DialogHeader, 17 | DialogTitle, 18 | DialogDescription, 19 | DialogFooter, 20 | } from "@/components/ui/dialog"; 21 | import { Input } from "@/components/ui/input"; 22 | import { Button } from "@/components/ui/button"; 23 | import { APP_NAME } from "@/lib/constants"; 24 | 25 | interface ApiKeyDialogProps { 26 | show: boolean; 27 | onClose: (open: boolean) => void; 28 | onSuccess: () => void; 29 | } 30 | 31 | export function ApiKeyDialog({ show, onClose, onSuccess }: ApiKeyDialogProps) { 32 | const [openaiKey, setOpenaiKey] = useState(""); 33 | const [firecrawlKey, setFirecrawlKey] = useState(""); 34 | const [loading, setLoading] = useState(false); 35 | 36 | const handleApiKeySubmit = async () => { 37 | if (!openaiKey || !firecrawlKey) return; 38 | setLoading(true); 39 | const res = await fetch("/api/keys", { 40 | method: "POST", 41 | headers: { "Content-Type": "application/json" }, 42 | body: JSON.stringify({ openaiKey, firecrawlKey }), 43 | }); 44 | if (res.ok) { 45 | onClose(false); 46 | onSuccess(); 47 | } 48 | setLoading(false); 49 | }; 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | {APP_NAME} 57 | 58 | 59 |
60 |
61 |

62 | 63 | Secure API Key Setup 64 |

65 |

66 | To use Deep Research, you'll need to provide your API keys. 67 | These keys are stored securely using HTTP-only cookies and are 68 | never exposed to client-side JavaScript. 69 |

70 |
71 |
72 |

73 | Self-hosting option:{" "} 74 | You can clone the repository and host this application on 75 | your own infrastructure. This gives you complete control 76 | over your data and API key management. 77 |

78 | 84 | View self-hosting instructions 85 | 91 | 97 | 98 | 99 |
100 |
101 |
102 | 103 |
104 |
105 |

106 | OpenAI Logo 113 | OpenAI API Key 114 |

115 |

116 | Powers our advanced language models for research analysis 117 | and synthesis. 118 | 124 | Get your OpenAI key → 125 | 126 |

127 |
128 | 129 |
130 |

131 | 🔥 FireCrawl API Key 132 |

133 |

134 | Enables real-time web crawling and data gathering 135 | capabilities. 136 | 142 | Get your FireCrawl key → 143 | 144 |

145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | 156 |
157 | setOpenaiKey(e.target.value)} 161 | placeholder="sk-..." 162 | className="pr-10 font-mono text-sm bg-background/50 border-border focus:border-primary focus:ring-primary h-9 sm:h-10" 163 | /> 164 |
165 | 166 |
167 |
168 |

169 | Starts with 'sk-' and contains about 50 characters 170 |

171 |
172 | 173 |
174 | 177 |
178 | setFirecrawlKey(e.target.value)} 182 | placeholder="fc-..." 183 | className="pr-10 font-mono text-sm bg-background/50 border-border focus:border-primary focus:ring-primary h-9 sm:h-10" 184 | /> 185 |
186 | 187 |
188 |
189 |

190 | Usually starts with 'fc-' for production keys 191 |

192 |
193 |
194 |
195 | 196 |
197 | 198 | Your keys are stored securely 199 |
200 |
201 | 207 | 208 | Get source code 209 | 210 |
211 | 226 |
227 |
228 |
229 | ); 230 | } 231 | -------------------------------------------------------------------------------- /components/chat/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { type Message } from "ai"; 5 | import { motion } from "framer-motion"; 6 | import { GithubIcon, PanelRightOpen } from "lucide-react"; 7 | 8 | import { useScrollToBottom } from "@/lib/hooks/use-scroll-to-bottom"; 9 | 10 | import DownloadTxtButton from "./download-txt"; 11 | import { MultimodalInput } from "./input"; 12 | import { PreviewMessage, ProgressStep } from "./message"; 13 | import { ResearchProgress } from "./research-progress"; 14 | 15 | export function Chat({ 16 | id, 17 | initialMessages, 18 | }: { 19 | id: string; 20 | initialMessages: Message[]; 21 | }) { 22 | const [messages, setMessages] = useState(initialMessages); 23 | const [isLoading, setIsLoading] = useState(false); 24 | const [progress, setProgress] = useState([]); 25 | const [containerRef, messagesEndRef] = useScrollToBottom(); 26 | 27 | // New state to store the final report text 28 | const [finalReport, setFinalReport] = useState(null); 29 | 30 | // States for interactive feedback workflow 31 | const [stage, setStage] = useState<"initial" | "feedback" | "researching">( 32 | "initial" 33 | ); 34 | const [initialQuery, setInitialQuery] = useState(""); 35 | 36 | // Add state for mobile progress panel visibility 37 | const [showProgress, setShowProgress] = useState(false); 38 | 39 | // New state to track if we're on mobile (using 768px as breakpoint for md) 40 | const [isMobile, setIsMobile] = useState(false); 41 | useEffect(() => { 42 | const handleResize = () => { 43 | setIsMobile(window.innerWidth < 768); 44 | }; 45 | handleResize(); 46 | window.addEventListener("resize", handleResize); 47 | return () => window.removeEventListener("resize", handleResize); 48 | }, []); 49 | 50 | // Update the condition to only be true when there are actual research steps 51 | const hasStartedResearch = 52 | progress.filter( 53 | (step) => 54 | // Only count non-report steps or initial report steps 55 | step.type !== "report" || 56 | step.content.includes("Generating") || 57 | step.content.includes("Synthesizing") 58 | ).length > 0; 59 | 60 | // Helper function to call the research endpoint 61 | const sendResearchQuery = async ( 62 | query: string, 63 | config: { breadth: number; depth: number; modelId: string } 64 | ) => { 65 | try { 66 | setIsLoading(true); 67 | setProgress([]); 68 | 69 | // Create the EventSource for SSE 70 | const response = await fetch("/api/research", { 71 | method: "POST", 72 | headers: { 73 | "Content-Type": "application/json", 74 | Accept: "text/event-stream", 75 | }, 76 | body: JSON.stringify({ 77 | query, 78 | breadth: config.breadth, 79 | depth: config.depth, 80 | modelId: config.modelId, 81 | }), 82 | }); 83 | 84 | if (!response.ok) { 85 | throw new Error(`HTTP error! status: ${response.status}`); 86 | } 87 | 88 | const reader = response.body?.getReader(); 89 | if (!reader) throw new Error("No reader available"); 90 | 91 | const decoder = new TextDecoder(); 92 | let buffer = ""; 93 | 94 | while (true) { 95 | const { value, done } = await reader.read(); 96 | if (done) break; 97 | 98 | buffer += decoder.decode(value, { stream: true }); 99 | 100 | // Process complete messages 101 | const messages = buffer.split("\n\n"); 102 | buffer = messages.pop() || ""; // Keep the last incomplete message in buffer 103 | 104 | for (const message of messages) { 105 | const lines = message.split("\n"); 106 | const dataLine = lines.find((line) => line.startsWith("data: ")); 107 | if (!dataLine) continue; 108 | 109 | try { 110 | const event = JSON.parse(dataLine.slice(6)); 111 | console.log("Received event:", event); // Debug log 112 | 113 | switch (event.type) { 114 | case "connected": 115 | console.log("SSE Connection established"); 116 | break; 117 | 118 | case "progress": 119 | setProgress((prev) => { 120 | // Avoid duplicate progress updates 121 | if ( 122 | prev.length > 0 && 123 | prev[prev.length - 1].content === event.step.content 124 | ) { 125 | return prev; 126 | } 127 | return [...prev, event.step]; 128 | }); 129 | break; 130 | 131 | case "result": 132 | setFinalReport(event.report); 133 | setMessages((prev) => [ 134 | ...prev, 135 | { 136 | id: crypto.randomUUID(), 137 | role: "assistant", 138 | content: event.report, 139 | }, 140 | ]); 141 | break; 142 | 143 | case "error": 144 | throw new Error(event.message); 145 | } 146 | } catch (e) { 147 | console.error("Error parsing SSE message:", e); 148 | } 149 | } 150 | } 151 | } catch (error) { 152 | console.error("Research error:", error); 153 | setMessages((prev) => [ 154 | ...prev, 155 | { 156 | id: crypto.randomUUID(), 157 | role: "assistant", 158 | content: "Sorry, there was an error conducting the research.", 159 | }, 160 | ]); 161 | } finally { 162 | setIsLoading(false); 163 | } 164 | }; 165 | 166 | const handleSubmit = async ( 167 | userInput: string, 168 | config: { breadth: number; depth: number; modelId: string } 169 | ) => { 170 | if (!userInput.trim() || isLoading) return; 171 | 172 | // Add user message immediately 173 | setMessages((prev) => [ 174 | ...prev, 175 | { 176 | id: crypto.randomUUID(), 177 | role: "user", 178 | content: userInput, 179 | }, 180 | ]); 181 | 182 | setIsLoading(true); 183 | 184 | console.log({ stage }); 185 | 186 | if (stage === "initial") { 187 | // Add thinking message only for initial query 188 | setMessages((prev) => [ 189 | ...prev, 190 | { 191 | id: "thinking", 192 | role: "assistant", 193 | content: "Thinking...", 194 | }, 195 | ]); 196 | 197 | // Handle the user's initial query 198 | setInitialQuery(userInput); 199 | 200 | try { 201 | const response = await fetch("/api/feedback", { 202 | method: "POST", 203 | headers: { 204 | "Content-Type": "application/json", 205 | }, 206 | body: JSON.stringify({ 207 | query: userInput, 208 | numQuestions: 3, 209 | modelId: config.modelId, 210 | }), 211 | }); 212 | const data = await response.json(); 213 | const questions: string[] = data.questions || []; 214 | setMessages((prev) => { 215 | const filtered = prev.filter((m) => m.id !== "thinking"); 216 | if (questions.length > 0) { 217 | const formattedQuestions = questions 218 | .map((q, index) => `${index + 1}. ${q}`) 219 | .join("\n\n"); 220 | return [ 221 | ...filtered, 222 | { 223 | id: crypto.randomUUID(), 224 | role: "assistant", 225 | content: `Please answer the following follow-up questions to help clarify your research:\n\n${formattedQuestions}`, 226 | }, 227 | ]; 228 | } 229 | return filtered; 230 | }); 231 | setStage("feedback"); 232 | } catch (error) { 233 | console.error("Feedback generation error:", error); 234 | setMessages((prev) => [ 235 | ...prev.filter((m) => m.id !== "thinking"), 236 | { 237 | id: crypto.randomUUID(), 238 | role: "assistant", 239 | content: "Sorry, there was an error generating feedback questions.", 240 | }, 241 | ]); 242 | } finally { 243 | setIsLoading(false); 244 | } 245 | } else if (stage === "feedback") { 246 | // In feedback stage, combine the initial query and follow-up answers 247 | const combined = `Initial Query: ${initialQuery}\nFollow-up Answers:\n${userInput}`; 248 | setStage("researching"); 249 | try { 250 | await sendResearchQuery(combined, config); 251 | } finally { 252 | setIsLoading(false); 253 | // Reset the stage so further messages will be processed 254 | setStage("initial"); 255 | // Inform the user that a new research session can be started 256 | setMessages((prev) => [ 257 | ...prev, 258 | { 259 | id: crypto.randomUUID(), 260 | role: "assistant", 261 | content: 262 | "Research session complete. You can now ask another question to begin a new research session.", 263 | }, 264 | ]); 265 | } 266 | } 267 | }; 268 | 269 | return ( 270 |
271 | {/* Main container with dynamic width */} 272 | 283 | {/* Messages Container */} 284 |
290 | {/* Welcome Message (if no research started and no messages) */} 291 | {!hasStartedResearch && messages.length === 0 && ( 292 |
293 | 309 | 321 | 334 | logo 335 | 336 | 337 |
338 | 345 | Supa Deep Research 346 | 347 | 348 | 354 | An open source alternative to OpenAI and Gemini's deep 355 | research capabilities with Supabase Edge Functions. Ask any 356 | question to generate a comprehensive report. 357 | 358 | 359 | 365 | 375 | 376 | View source code 377 | 378 | 379 |
380 |
381 |
382 | )} 383 | 384 | {/* Messages */} 385 |
386 | {messages.map((message) => ( 387 | 388 | ))} 389 |
390 | {finalReport && ( 391 |
392 | 393 |
394 | )} 395 |
396 |
397 | 398 | {/* Input - Fixed to bottom */} 399 |
400 |
401 | 412 |
413 |
414 | 415 | 416 | {/* Research Progress Panel */} 417 | 430 | 431 | 432 | 433 | {/* Mobile Toggle Button - Only show when research has started */} 434 | {hasStartedResearch && ( 435 | 455 | )} 456 |
457 | ); 458 | } 459 | -------------------------------------------------------------------------------- /components/chat/download-txt.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DownloadIcon } from 'lucide-react'; 3 | 4 | interface DownloadTxtButtonProps { 5 | reportText: string; 6 | fileName?: string; 7 | } 8 | 9 | const DownloadTxtButton: React.FC = ({ 10 | reportText, 11 | fileName = 'research_report.txt', 12 | }) => { 13 | const handleDownload = () => { 14 | // Create a blob from the report text content. 15 | const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8' }); 16 | // Create a temporary URL for the blob. 17 | const url = window.URL.createObjectURL(blob); 18 | // Create a temporary anchor element. 19 | const link = document.createElement('a'); 20 | link.href = url; 21 | link.download = fileName; 22 | // Append the link, trigger click, remove it, and revoke the URL. 23 | document.body.appendChild(link); 24 | link.click(); 25 | document.body.removeChild(link); 26 | window.URL.revokeObjectURL(url); 27 | }; 28 | 29 | return ( 30 | 64 | ); 65 | }; 66 | 67 | export default DownloadTxtButton; 68 | -------------------------------------------------------------------------------- /components/chat/input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import Image from "next/image"; 5 | import cx from "classnames"; 6 | import { 7 | ArrowUpIcon, 8 | CheckCircleIcon, 9 | ChevronDown, 10 | DownloadIcon, 11 | Loader2, 12 | XCircleIcon, 13 | } from "lucide-react"; 14 | 15 | import { Button } from "@/components/ui/button"; 16 | import { Slider } from "@/components/ui/slider"; 17 | import { 18 | availableModels, 19 | type AIModelDisplayInfo, 20 | } from "@/lib/deep-research/ai/providers"; 21 | import { ApiKeyDialog } from "@/components/chat/api-key-dialog"; 22 | 23 | type MultimodalInputProps = { 24 | onSubmit: ( 25 | input: string, 26 | config: { 27 | breadth: number; 28 | depth: number; 29 | modelId: string; 30 | } 31 | ) => void; 32 | isLoading: boolean; 33 | placeholder?: string; 34 | isAuthenticated?: boolean; 35 | onDownload?: () => void; 36 | canDownload?: boolean; 37 | }; 38 | 39 | export function MultimodalInput({ 40 | onSubmit, 41 | isLoading, 42 | placeholder = "What would you like to research?", 43 | onDownload, 44 | canDownload = false, 45 | }: MultimodalInputProps) { 46 | const [input, setInput] = useState(""); 47 | const [breadth, setBreadth] = useState(4); 48 | const [depth, setDepth] = useState(2); 49 | const [selectedModel, setSelectedModel] = useState( 50 | availableModels.find((model) => model.id === "o3-mini") || 51 | availableModels[0] 52 | ); 53 | const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); 54 | const [showApiKeyPrompt, setShowApiKeyPrompt] = useState(false); 55 | const [hasKeys, setHasKeys] = useState(false); 56 | const textareaRef = useRef(null); 57 | 58 | // Read the feature flag from environment variables. 59 | const enableApiKeys = process.env.NEXT_PUBLIC_ENABLE_API_KEYS === "true"; 60 | 61 | // When API keys are disabled via env flag, always consider keys as present. 62 | const effectiveHasKeys = enableApiKeys ? hasKeys : true; 63 | 64 | // Check for keys using the consolidated endpoint 65 | useEffect(() => { 66 | const checkKeys = async () => { 67 | const res = await fetch("/api/keys"); 68 | const data = await res.json(); 69 | setHasKeys(data.keysPresent); 70 | if (!data.keysPresent && enableApiKeys) { 71 | setShowApiKeyPrompt(true); 72 | } else { 73 | setShowApiKeyPrompt(false); 74 | } 75 | }; 76 | checkKeys(); 77 | }, [enableApiKeys]); 78 | 79 | // New: Remove API keys handler 80 | const handleRemoveKeys = async () => { 81 | if (!window.confirm("Are you sure you want to remove your API keys?")) 82 | return; 83 | try { 84 | const res = await fetch("/api/keys", { 85 | method: "DELETE", 86 | }); 87 | if (res.ok) { 88 | setHasKeys(false); 89 | } 90 | } catch (error) { 91 | console.error("Error removing keys:", error); 92 | } 93 | }; 94 | 95 | const handleSubmit = () => { 96 | if (!input.trim() || isLoading) return; 97 | if (enableApiKeys && !effectiveHasKeys) { 98 | // Re-open the API key modal if keys are missing 99 | setShowApiKeyPrompt(true); 100 | return; 101 | } 102 | onSubmit(input, { 103 | breadth, 104 | depth, 105 | modelId: selectedModel.id, 106 | }); 107 | setInput(""); 108 | }; 109 | 110 | useEffect(() => { 111 | if (textareaRef.current) { 112 | textareaRef.current.style.height = "inherit"; 113 | textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; 114 | } 115 | }, [input]); 116 | 117 | const DownloadButton = () => ( 118 | 127 | ); 128 | 129 | return ( 130 |
131 | {/* Conditionally render API key dialog only if enabled */} 132 | {enableApiKeys && ( 133 | { 137 | setShowApiKeyPrompt(false); 138 | setHasKeys(true); 139 | }} 140 | /> 141 | )} 142 | 143 |