From fabf52fc0431eeddcb2d9e6efce088526bb74ace Mon Sep 17 00:00:00 2001 From: BuildTools Date: Thu, 11 Sep 2025 13:55:30 -0500 Subject: [PATCH 1/4] Added Chatbot page --- .env.example | 3 +- README.md | 17 +- app/(protected)/chatbot/layout.tsx | 48 +++ app/(protected)/chatbot/page.tsx | 629 ++++++++++++++++++++++++++++ app/api/chat/ask-google/route.ts | 176 ++++++++ app/api/chat/conversations/route.ts | 47 +++ app/api/chat/create/route.ts | 52 +++ app/api/chat/delete/route.ts | 55 +++ app/api/chat/route.ts | 21 +- app/api/chat/save/route.ts | 91 ++++ app/api/chat/update-title/route.ts | 59 +++ prisma/schema.prisma | 66 +-- 12 files changed, 1231 insertions(+), 33 deletions(-) create mode 100644 app/(protected)/chatbot/layout.tsx create mode 100644 app/(protected)/chatbot/page.tsx create mode 100644 app/api/chat/ask-google/route.ts create mode 100644 app/api/chat/conversations/route.ts create mode 100644 app/api/chat/create/route.ts create mode 100644 app/api/chat/delete/route.ts create mode 100644 app/api/chat/save/route.ts create mode 100644 app/api/chat/update-title/route.ts diff --git a/.env.example b/.env.example index d24af3a..2159a06 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ AI_API_KEY="your_api_key" -POSTGRES_URL="postgresql://postgres:example@localhost:5432/postgres" +DATABASE_URL="postgresql://postgres:example@localhost:5432/postgres" AUTH_GOOGLE_ID=your-google-client-id AUTH_GOOGLE_SECRET=your-google-client-secret AUTH_SECRET="your-auth-secret" +GOOGLE_AI_API_KEY="google-ai-api-key" REDIS_URL="redis://localhost:6379" diff --git a/README.md b/README.md index 6aa772f..8f82989 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,30 @@ To configure Google authentication, set the following environment variables in y | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- | | `GOOGLE_CLIENT_ID` | Your Google application's Client ID. Obtain this from the [Google Developer Console](https://console.developers.google.com/). | Yes | None | | `GOOGLE_CLIENT_SECRET` | Your Google application's Client Secret. Obtain this from the [Google Developer Console](https://console.developers.google.com/). | Yes | None | +| `GOOGLE_AI_API_KEY` | Your Google Generative Language API key. Obtain this from the Google Cloud Console as described below. | Yes | None | + **Instructions:** -1. **Obtain Google OAuth Credentials:** +1a. **Obtain Google OAuth Credentials:** - Navigate to the [Google Developer Console](https://console.developers.google.com/). - Create a new project or select an existing one. - Go to the "Credentials" section and create OAuth 2.0 credentials. - Note down the generated `Client ID` and `Client Secret`. +1b. Obtain Google Generative Language API Key + +- Go to the [Google Cloud Console](https://console.cloud.google.com/). +- Select your project or create a new one. +- **Enable billing** on your project (required to use the API). +- Enable the **Generative Language API**: + - Visit [Generative Language API library](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com). + - Click **Enable**. +- Go to the **Credentials** page. +- Click **Create Credentials** → **API Key**. +- Copy the generated API key. + 2. **Set Up Your `.env` File:** - Create a `.env` file in the root directory of your project if it doesn't exist. @@ -47,6 +61,7 @@ To configure Google authentication, set the following environment variables in y ```env GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret + GOOGLE_AI_API_KEY=your-google-generative-language-api-key ``` - Save the `.env` file. Ensure this file is **not** committed to version control by adding `.env` to your `.gitignore` file. diff --git a/app/(protected)/chatbot/layout.tsx b/app/(protected)/chatbot/layout.tsx new file mode 100644 index 0000000..dc85141 --- /dev/null +++ b/app/(protected)/chatbot/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import "../../globals.css"; +import { cn } from "@/lib/utils"; +import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/components/theme-provider"; +import { auth } from "@/auth"; +import { SessionProvider } from "next-auth/react"; +import { Poppins } from "next/font/google"; + +export const metadata: Metadata = { + title: "Chatbot - Dark Alpha Capital", + description: "AI Chatbot for Deal Sourcing", +}; + +const poppins = Poppins({ + subsets: ["latin"], + weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], + display: "swap", + variable: "--font-poppins", +}); + +export default async function ChatbotLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const userSession = await auth(); + + return ( + + + + +
+ {children} +
+
+ +
+ + + ); +} diff --git a/app/(protected)/chatbot/page.tsx b/app/(protected)/chatbot/page.tsx new file mode 100644 index 0000000..3e94666 --- /dev/null +++ b/app/(protected)/chatbot/page.tsx @@ -0,0 +1,629 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { ChatbotSidebar } from "@/components/ChatbotSidebar"; +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { Mic, MicOff, Plus, Paperclip, Image as ImageIcon, X, Sparkles, Send } from "lucide-react"; +import TextToSpeech from "@/components/TextToSpeech"; +import ReactMarkdown from "react-markdown"; + + +type ChatMessage = { + id: string; + role: "user" | "assistant"; + content: string; + createdAt: number; +}; + +type Conversation = { + id: string; + title: string; + createdAt: number; + messages: ChatMessage[]; //array of chat messages +}; + +const STORAGE_KEY = "chatbot.conversations"; +const ACTIVE_KEY = "chatbot.activeId"; + +function generateId(prefix: string = "id"): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}_${Date.now()}`; +} + +export default function ChatbotPage() { + const [conversations, setConversations] = useState([]); + const [activeId, setActiveId] = useState(null); + const [input, setInput] = useState(""); + const [isSending, setIsSending] = useState(false); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [typingMessageId, setTypingMessageId] = useState(null); + + // Attachments state + const [attachments, setAttachments] = useState([]); + const fileInputRef = useRef(null); + + // Voice state + const [isRecording, setIsRecording] = useState(false); + const recognitionRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const mediaStreamRef = useRef(null); + const animationFrameRef = useRef(null); + const canvasRef = useRef(null); + + + const starterPrompts = [ + "Summarize this product: AI CRM for sales teams", + "Give me 5 marketing ideas for a B2B fintech startup", + "Draft a polite follow-up email to a potential client", + "Explain EBITDA margin like I'm new to finance", + ]; + + // Load from DB + useEffect(() => { + const loadConversations = async () => { + try { + const res = await fetch("/api/chat/conversations"); + // Tell TypeScript what the expected shape of the data is + const data: { conversations: Conversation[] } = await res.json(); + + if (data?.conversations) { + const fixedConversations = data.conversations.map(conv => ({ + ...conv, + title: + conv.title === "New Chat" && + Array.isArray(conv.messages) && + conv.messages.length > 0 && + conv.messages[0]?.content + ? conv.messages[0].content.slice(0, 30) + : conv.title, + })); + + setConversations(fixedConversations); + setActiveId(fixedConversations[0]?.id ?? null); + } + } catch (error) { + console.error("Failed to load conversations from DB:", error); + } finally { + setIsLoading(false); + } + }; + + loadConversations(); + }, []); + + const activeConversation = useMemo(() => { + const conv = conversations.find(c => c.id === activeId); + if (!conv) return null; + const sortedMessages = Array.isArray(conv.messages) + ? [...conv.messages].sort((a, b) => a.createdAt - b.createdAt) + : []; + return { + ...conv, + messages: sortedMessages, + }; + }, [conversations, activeId]); + + async function handleNewChat() { + try { + const defaultTitle = starterPrompts[0] || "New Chat"; + + const res = await fetch("/api/chat/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: defaultTitle }), + }); + + const data = await res.json(); + + if (res.ok && data.conversation) { + setConversations(prev => { + const exists = prev.some(c => c.id === data.conversation.id); + return exists ? prev : [data.conversation, ...prev]; + }); + setActiveId(data.conversation.id); + setInput(""); + setTimeout(() => inputRef.current?.focus(), 0); + } else { + console.error("Failed to create conversation:", data.error); + } + } catch (err) { + console.error("Error creating conversation:", err); + } + } + + async function handleDeleteConversation(id: string) { + setConversations(prev => prev.filter(c => c.id !== id)); + if (activeId === id) { + const remaining = conversations.filter(c => c.id !== id); + setActiveId(remaining[0]?.id ?? null); + } + try { + await fetch(`/api/chat/delete?conversationId=${encodeURIComponent(id)}`, { + method: "DELETE", + }); + } catch (error) { + console.error("Failed to delete conversation:", error); + } + } + + async function sendMessage() { + const trimmed = input.trim(); + if (!trimmed || isSending) return; + + setIsSending(true); + setInput(""); + + const messageId = generateId("user"); + const assistantId = generateId("assistant"); + + const now = Date.now(); + + const userMessage: ChatMessage = { + id: messageId, + role: "user", + content: trimmed, + createdAt: now, + }; + + const assistantMessage: ChatMessage = { + id: assistantId, + role: "assistant", + content: "", + createdAt: now + 1, + }; + + // Optimistically add user and empty assistant message + setConversations(prev => { + const updated = prev.map(conv => { + if (conv.id === activeId) { + return { + ...conv, + messages: [...(conv.messages ?? []), userMessage, assistantMessage], + }; + } + return conv; + }); + return updated; + }); + + // Start streaming + setTypingMessageId(assistantId); + let streamedContent = ""; + const updatedConversation = await assistantReplyFromGoogle(trimmed, activeId, (chunk) => { + streamedContent += chunk; + setConversations(prev => + prev.map(conv => { + if (conv.id === activeId) { + return { + ...conv, + messages: conv.messages.map(msg => + msg.id === assistantId ? { ...msg, content: streamedContent } : msg + ), + }; + } + return conv; + }) + ); + }); + + if (updatedConversation) { + const sortedMessages = [...(updatedConversation.messages || [])].sort( + (a, b) => a.createdAt - b.createdAt + ); + const firstMessageContent = sortedMessages[0]?.content ?? "New Chat"; + const newTitle = firstMessageContent.slice(0, 30); + + setConversations(prev => { + const others = prev.filter(c => c.id !== updatedConversation.id); + const updatedConv = { ...updatedConversation, messages: sortedMessages, title: newTitle }; + return [updatedConv, ...others]; + }); + + setActiveId(updatedConversation.id); + + await fetch("/api/chat/update-title", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: updatedConversation.id, title: newTitle }), + }); + } + + setTypingMessageId(null); + setIsSending(false); + } + + const assistantReplyFromGoogle = async ( + message: string, + conversationId: string | null, + onStreamChunk?: (chunk: string) => void + ): Promise => { + try { + const res = await fetch("/api/chat/ask-google", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, conversationId }), + }); + + if (!res.body) { + throw new Error("No response body for streaming"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + let assistantMessage = ""; + let newConversation: Conversation | null = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + assistantMessage += chunk; + + // Push streamed chunk to UI + onStreamChunk?.(chunk); + } + + // Once stream is done, refetch full updated conversation + const finalRes = await fetch("/api/chat/conversations"); + const finalData: { conversations: Conversation[] } = await finalRes.json(); + + newConversation = finalData.conversations.find(c => c.id === conversationId) ?? null; + + return newConversation; + } catch (err) { + console.error("Streaming failed:", err); + return null; + } + }; + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + } + + // Attachments handlers + function onClickAddFiles() { + fileInputRef.current?.click(); + } + + function onFilesSelected(e: React.ChangeEvent) { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + setAttachments(prev => [...prev, ...files].slice(0, 10)); + e.target.value = ""; // reset so same file can be re-selected + } + + function removeAttachment(index: number) { + setAttachments(prev => prev.filter((_, i) => i !== index)); + } + + // Voice handlers + function drawWaveform() { + const canvas = canvasRef.current; + const analyser = analyserRef.current; + if (!canvas || !analyser) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + const render = () => { + const dpr = (window as any).devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const desiredWidth = Math.floor(rect.width * dpr); + const desiredHeight = Math.floor(rect.height * dpr); + if (canvas.width !== desiredWidth || canvas.height !== desiredHeight) { + canvas.width = desiredWidth; + canvas.height = desiredHeight; + } + + analyser.getByteTimeDomainData(dataArray); + const width = canvas.width / dpr; + const height = canvas.height / dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + // background: solid white + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + + // waveform + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.strokeStyle = "#374151"; // gray-700 + ctx.beginPath(); + + const sliceWidth = (width * 1.0) / bufferLength; + let x = 0; + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i] / 128.0; // 0..2 + const y = (v * height) / 2; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + x += sliceWidth; + } + ctx.stroke(); + + animationFrameRef.current = requestAnimationFrame(render); + }; + animationFrameRef.current = requestAnimationFrame(render); + } + + async function startAudioVisualization() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaStreamRef.current = stream; + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioContextRef.current = audioCtx; + const source = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyserRef.current = analyser; + source.connect(analyser); + drawWaveform(); + } catch { + // ignore + } + } + + function stopAudioVisualization() { + if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + if (audioContextRef.current) { + try { audioContextRef.current.close(); } catch {} + audioContextRef.current = null; + } + if (mediaStreamRef.current) { + mediaStreamRef.current.getTracks().forEach(t => t.stop()); + mediaStreamRef.current = null; + } + analyserRef.current = null; + } + + function toggleRecording() { + const SpeechRecognition: any = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) { + alert("Speech recognition is not supported in this browser."); + return; + } + + if (!isRecording) { + const recognition = new SpeechRecognition(); + recognition.lang = "en-US"; + recognition.interimResults = true; + recognition.continuous = true; + + recognition.onresult = (event: any) => { + let transcript = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + if (event.results[i].isFinal) { + transcript += event.results[i][0].transcript; + } + } + if (transcript.trim()) { + setInput(prev => (prev ? prev + " " : "") + transcript.trim()); + } + }; + recognition.onerror = () => { + setIsRecording(false); + }; + recognition.onend = () => { + setIsRecording(false); + }; + + recognition.start(); + recognitionRef.current = recognition; + setIsRecording(true); + // start visualizer + startAudioVisualization(); + } else { + try { + recognitionRef.current?.stop(); + } catch {} + setIsRecording(false); + stopAudioVisualization(); + } + } + + + return ( + +
+ + + {/* Main chat area */} +
+
+
+ +
Chatbot
+
+
+
+ + Model: Default +
+
+
+ + +
+ {activeConversation?.messages.length ? ( +
+ {activeConversation.messages.map(msg => ( +
+ {msg.role === "assistant" ? ( + <> + + AI + +
+
+ + {typingMessageId === msg.id ? msg.content + "▍" : msg.content} + +
+ +
+ + ) : ( + <> +
+ {msg.content} +
+ + U + + + )} +
+ ))} +
+ ) : ( +
+
+

How can I help you today?

+

Try one of these to get started

+
+
+ {starterPrompts.map((p, idx) => ( + + ))} +
+
+ )} +
+
+ +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((file, i) => ( +
+ + {file.name} + +
+ ))} +
+ )} + +
+
+ + +
+ + {isRecording ? ( +
+ +
+ ) : ( +