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..0a21a83
--- /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 = "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 */}
+
+
+
+
+
+ {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) => (
+
setInput(p)}
+ className="group rounded-2xl border p-6 text-left text-lg transition-colors hover:bg-accent/60"
+ >
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((file, i) => (
+
+
+
{file.name}
+
removeAttachment(i)} aria-label="Remove attachment">
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {isRecording ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/api/chat/ask-google/route.ts b/app/api/chat/ask-google/route.ts
new file mode 100644
index 0000000..73b75a4
--- /dev/null
+++ b/app/api/chat/ask-google/route.ts
@@ -0,0 +1,152 @@
+import { NextRequest } from "next/server";
+import prisma from "@/lib/prisma";
+import { auth } from "@/auth";
+import { rateLimit } from "@/lib/redis";
+import { GoogleGenerativeAI } from "@google/generative-ai";
+
+export const runtime = "nodejs";
+
+export async function POST(req: NextRequest) {
+ const userSession = await auth();
+
+ if (!userSession?.user?.email) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(`api:chat-post:${ip}`, 10, 60_000);
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: userSession.user.email },
+ });
+
+ if (!user) {
+ return new Response("User not found", { status: 404 });
+ }
+
+ const { conversationId, message: userMessage } = await req.json();
+
+ if (!userMessage || userMessage.trim() === "") {
+ return new Response("Message is required", { status: 400 });
+ }
+
+ const API_KEY = process.env.GOOGLE_AI_API_KEY;
+ if (!API_KEY) {
+ console.error("❌ Missing GOOGLE_AI_API_KEY");
+ return new Response("Missing Google API key", { status: 500 });
+ }
+
+ // --- Fetch prior messages from DB
+ let previousMessages: { author: string; content: string }[] = [];
+
+ if (conversationId) {
+ const conversation = await prisma.chatConversation.findUnique({
+ where: { id: conversationId },
+ include: { messages: { orderBy: { createdAt: "asc" } } },
+ });
+
+ if (!conversation) return new Response("Conversation not found", { status: 404 });
+ if (conversation.userId !== user.id) return new Response("Forbidden", { status: 403 });
+
+ previousMessages = conversation.messages.map(msg => ({
+ author: msg.role === "user" ? "user" : "assistant",
+ content: msg.content,
+ }));
+ }
+
+ const promptMessages = [
+ ...previousMessages,
+ { author: "user", content: userMessage },
+ ];
+
+ // --- Set up SDK and stream
+ const genAI = new GoogleGenerativeAI(API_KEY);
+ const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
+
+ const encoder = new TextEncoder();
+
+ const stream = await model.generateContentStream({
+ contents: promptMessages.map((msg) => ({
+ role: msg.author,
+ parts: [{ text: msg.content }],
+ })),
+ });
+
+ // Buffer for final message storage
+ let fullResponse = "";
+ const now = new Date();
+ const streamOut = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of stream.stream) {
+ const text = chunk.text();
+ fullResponse += text;
+ controller.enqueue(encoder.encode(text));
+ }
+ controller.close();
+
+ // Save to DB (non-blocking)
+ const userMessageData = {
+ role: "user",
+ content: userMessage,
+ createdAt: now,
+ };
+
+ const assistantMessageData = {
+ role: "assistant",
+ content: fullResponse,
+ createdAt: new Date(now.getTime() + 1),
+ };
+
+ (async () => {
+ try {
+ if (!conversationId) {
+ await prisma.chatConversation.create({
+ data: {
+ userId: user.id,
+ title: "New Chat",
+ messages: {
+ create: [userMessageData, assistantMessageData],
+ },
+ },
+ });
+ } else {
+ await prisma.chatConversation.update({
+ where: { id: conversationId },
+ data: {
+ messages: {
+ create: [userMessageData, assistantMessageData],
+ },
+ },
+ });
+ }
+ } catch (err) {
+ console.error("❌ Failed to save messages to DB:", err);
+ }
+ })();
+ } catch (err) {
+ console.error("❌ Stream error:", err);
+ controller.error(err);
+ }
+ },
+ });
+
+ return new Response(streamOut, {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "no-cache",
+ },
+ });
+}
diff --git a/app/api/chat/conversations/route.ts b/app/api/chat/conversations/route.ts
new file mode 100644
index 0000000..7aef20d
--- /dev/null
+++ b/app/api/chat/conversations/route.ts
@@ -0,0 +1,47 @@
+import { auth } from "@/auth";
+import prisma from "@/lib/prisma";
+import { NextResponse, NextRequest } from "next/server";
+import { rateLimit } from "@/lib/redis";
+
+export async function GET(req: NextRequest) {
+ const session = await auth();
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(
+ `api:get-conversations:${ip}`,
+ 10, // max 10 requests
+ 60_000 // per 1 minute
+ );
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ ChatConversation: {
+ orderBy: { createdAt: "desc" },
+ include: {
+ messages: {
+ orderBy: { createdAt: "asc" },
+ },
+ },
+ },
+ },
+ });
+
+ return NextResponse.json({ conversations: user?.ChatConversation ?? [] });
+}
diff --git a/app/api/chat/create/route.ts b/app/api/chat/create/route.ts
new file mode 100644
index 0000000..0d3203a
--- /dev/null
+++ b/app/api/chat/create/route.ts
@@ -0,0 +1,52 @@
+import { auth } from "@/auth";
+import prisma from "@/lib/prisma";
+import { NextResponse } from "next/server";
+import { NextRequest } from "next/server";
+import { rateLimit } from "@/lib/redis";
+
+export async function POST(req: NextRequest) {
+ const userSession = await auth();
+
+ if (!userSession?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(
+ `api:get-conversations:${ip}`,
+ 10, // max 10 requests
+ 60_000 // per 1 minute
+ );
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: userSession.user.email },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const { title } = await req.json();
+
+ const conversation = await prisma.chatConversation.create({
+ data: {
+ userId: user.id,
+ title: title || "New chat", // Use passed title or fallback
+ },
+ });
+
+ return NextResponse.json({ conversation });
+}
diff --git a/app/api/chat/delete/route.ts b/app/api/chat/delete/route.ts
new file mode 100644
index 0000000..cad4075
--- /dev/null
+++ b/app/api/chat/delete/route.ts
@@ -0,0 +1,55 @@
+import { auth } from "@/auth";
+import prisma from "@/lib/prisma";
+import { NextResponse } from "next/server";
+import { rateLimit } from "@/lib/redis";
+
+export async function DELETE(req: Request) {
+ const userSession = await auth();
+
+ if (!userSession?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(
+ `api:get-conversations:${ip}`,
+ 10, // max 10 requests
+ 60_000 // per 1 minute
+ );
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const url = new URL(req.url);
+ const conversationId = url.searchParams.get("conversationId");
+
+ if (!conversationId) {
+ return NextResponse.json({ error: "Missing conversationId" }, { status: 400 });
+ }
+
+ // Ensure the conversation belongs to the user
+ const conversation = await prisma.chatConversation.findUnique({
+ where: { id: conversationId },
+ });
+
+ if (!conversation) {
+ return NextResponse.json({ error: "Conversation not found or access denied" }, { status: 404 });
+ }
+
+ // Delete the conversation and related messages
+ await prisma.chatConversation.delete({
+ where: { id: conversationId },
+ });
+
+ return NextResponse.json({ success: true });
+}
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 07db53b..141f6b5 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -1,9 +1,20 @@
-import { openai } from "@/lib/ai/available-models";
-import { weatherTool } from "@/lib/ai/tools/weather";
-import { stockTool } from "@/lib/ai/tools/stock";
-import { streamText } from "ai";
import { NextResponse } from "next/server";
+type InMessage = { role: "user" | "assistant"; content: string };
+
export async function POST(request: Request) {
- return NextResponse.json({ message: "Hello, world!" });
+ try {
+ const body = await request.json().catch(() => ({}));
+ const messages: InMessage[] = Array.isArray(body?.messages)
+ ? body.messages
+ : [];
+ const lastUser = [...messages].reverse().find(m => m.role === "user");
+ const content = lastUser?.content?.trim() || "";
+ const reply = content
+ ? `You said: ${content}`
+ : "Hello! Ask me anything.";
+ return NextResponse.json({ message: reply });
+ } catch (e) {
+ return NextResponse.json({ message: "Server error." }, { status: 500 });
+ }
}
diff --git a/app/api/chat/save/route.ts b/app/api/chat/save/route.ts
new file mode 100644
index 0000000..2cfb387
--- /dev/null
+++ b/app/api/chat/save/route.ts
@@ -0,0 +1,91 @@
+import { auth } from "@/auth";
+import prisma from "@/lib/prisma";
+import { NextResponse } from "next/server";
+import { rateLimit } from "@/lib/redis";
+
+export async function POST(req: Request) {
+ const userSession = await auth();
+
+ if (!userSession?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(
+ `api:get-conversations:${ip}`,
+ 10, // max 10 requests
+ 60_000 // per 1 minute
+ );
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: userSession.user.email },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const { conversationId, title, messages } = await req.json();
+
+ let conversation;
+
+ if (!conversationId) {
+ // Create new conversation
+ conversation = await prisma.chatConversation.create({
+ data: {
+ userId: user.id,
+ title: title || "New Chat",
+ messages: {
+ create: messages.map((msg: any) => ({
+ role: msg.role,
+ content: msg.content,
+ })),
+ },
+ },
+ include: {
+ messages: true,
+ },
+ });
+ } else {
+ const existingConv = await prisma.chatConversation.findUnique({
+ where: { id: conversationId },
+ });
+
+ if (!existingConv) {
+ return NextResponse.json({ error: "Conversation not found" }, { status: 404 });
+ }
+
+ // Append to existing conversation
+ conversation = await prisma.chatConversation.update({
+ where: { id: conversationId },
+ data: {
+ title: title || "New Chat",
+ messages: {
+ create: messages.map((msg: any) => ({
+ role: msg.role,
+ content: msg.content,
+ })),
+ },
+ },
+ include: {
+ messages: true,
+ },
+ });
+ }
+
+ return NextResponse.json({ success: true, conversation });
+}
+
diff --git a/app/api/chat/update-title/route.ts b/app/api/chat/update-title/route.ts
new file mode 100644
index 0000000..330a85e
--- /dev/null
+++ b/app/api/chat/update-title/route.ts
@@ -0,0 +1,59 @@
+import { auth } from "@/auth";
+import prisma from "@/lib/prisma";
+import { NextResponse } from "next/server";
+import { rateLimit } from "@/lib/redis";
+
+export async function POST(req: Request) {
+ const userSession = await auth();
+
+ if (!userSession?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "anon";
+
+ const { ok, remaining, reset } = await rateLimit(
+ `api:get-conversations:${ip}`,
+ 10, // max 10 requests
+ 60_000 // per 1 minute
+ );
+
+ if (!ok) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: {
+ "RateLimit-Limit": "10",
+ "RateLimit-Remaining": String(remaining),
+ "RateLimit-Reset": String(Math.ceil((reset - Date.now()) / 1000)),
+ },
+ });
+ }
+
+ const { conversationId, title } = await req.json();
+
+ if (!conversationId || typeof title !== "string") {
+ return NextResponse.json({ error: "Invalid input" }, { status: 400 });
+ }
+
+ // Verify user owns the conversation before updating
+ const conversation = await prisma.chatConversation.findUnique({
+ where: { id: conversationId },
+ });
+
+ if (!conversation) {
+ return NextResponse.json({ error: "Conversation not found" }, { status: 404 });
+ }
+
+ if (conversation.userId !== userSession.user.id) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Update the title
+ const updatedConversation = await prisma.chatConversation.update({
+ where: { id: conversationId },
+ data: { title },
+ });
+
+ return NextResponse.json({ conversation: updatedConversation });
+}
diff --git a/components/ChatbotSidebar.tsx b/components/ChatbotSidebar.tsx
new file mode 100644
index 0000000..acc67d7
--- /dev/null
+++ b/components/ChatbotSidebar.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Plus, Trash2, MessageSquare, ChevronDown } from "lucide-react"
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarMenuAction,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import useCurrentUser from "@/hooks/use-current-user"
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+import { MoreHorizontal, LogOut } from "lucide-react"
+import { signOut } from "next-auth/react"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+
+type Conversation = {
+ id: string;
+ title: string;
+ createdAt: number;
+ messages: any[];
+};
+
+type ChatbotSidebarProps = {
+ conversations: Conversation[];
+ activeId: string | null;
+ onNewChat: () => void;
+ onSelectConversation: (id: string) => void;
+ onDeleteConversation: (id: string) => void;
+}
+
+export function ChatbotSidebar({
+ conversations,
+ activeId,
+ onNewChat,
+ onSelectConversation,
+ onDeleteConversation
+}: ChatbotSidebarProps) {
+ const user = useCurrentUser();
+ return (
+
+
+
+
Conversations
+
+
+
+
+
+
+
+
+
+
+ Recent Chats
+
+
+
+
+
+
+
+ {conversations.length === 0 ? (
+
+
+
No conversations yet
+
Start a new chat to begin
+
+ ) : (
+ conversations.map((conv) => (
+
+
+
+ onSelectConversation(conv.id)}
+ className="text-left w-full"
+ >
+
+ {conv.title || "Untitled"}
+
+
+
+
onDeleteConversation(conv.id)}
+ aria-label="Delete conversation"
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+ {user ? (
+
+
+
+ window.location.href = `/profile/${user?.id}`} className="flex items-center gap-3 w-full text-left">
+
+ {user?.image ? (
+
+ ) : null}
+ {(user?.name?.[0] || user?.email?.[0] || "U").toUpperCase()}
+
+
+ {user?.name || "Signed in user"}
+ {user?.email || ""}
+
+
+
+
+
+
+
+
+
+
+
+ signOut({ callbackUrl: "/auth/login" })} className="gap-2">
+
+ Sign out
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ Sign in
+ Access your account
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/components/Header.tsx b/components/Header.tsx
index da08969..6ad3c4c 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -30,6 +30,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import NotificationPopover from "./NotificationPopover";
import { FaScrewdriver } from "react-icons/fa";
+import { FaRobot } from "react-icons/fa";
type HeaderProps = {
className?: string;
@@ -42,11 +43,12 @@ type NavLinkType = {
icon: any;
}[];
-export const NavLinks: NavLinkType = [
+export const NavLinks: NavLinkType = [
{ navlink: "/new-deal", navlabel: "New", icon: FiPlus },
{ navlink: "/raw-deals", navlabel: "Raw", icon: FiList },
{ navlink: "/published-deals", navlabel: "Published", icon: FiCheckSquare },
{ navlink: "/screeners", navlabel: "Screener", icon: FaScrewdriver },
+ { navlink: "/chatbot", navlabel: "Chatbot", icon: FaRobot},
];
const Header = ({ className, session }: HeaderProps) => {
diff --git a/components/NotificationPopover.tsx b/components/NotificationPopover.tsx
index 9d086da..ed7db3c 100644
--- a/components/NotificationPopover.tsx
+++ b/components/NotificationPopover.tsx
@@ -73,6 +73,9 @@ const NotificationPopover = ({ userId }: { userId: string }) => {
return `$${ebitda.toLocaleString()}`;
};
+ // Create a ref to store the connect function to avoid dependency issues
+ const connectWebSocketRef = useRef<() => void>();
+
const connectWebSocket = useCallback(() => {
const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080";
diff --git a/components/TextToSpeech.tsx b/components/TextToSpeech.tsx
new file mode 100644
index 0000000..b4f79c9
--- /dev/null
+++ b/components/TextToSpeech.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { Button } from "./ui/button";
+import { Play, Square } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+import { Slider } from "./ui/slider";
+import { Label } from "./ui/label";
+
+interface TextToSpeechProps {
+ text: string;
+ className?: string;
+ showControls?: boolean;
+}
+
+const TextToSpeech = ({ text, className = "", showControls = false }: TextToSpeechProps) => {
+ const [voices, setVoices] = useState([]);
+ const [selectedVoice, setSelectedVoice] = useState("");
+ const [rate, setRate] = useState(1);
+ const [pitch, setPitch] = useState(1);
+ const [isPlaying, setIsPlaying] = useState(false);
+
+ useEffect(() => {
+ const populateVoiceList = () => {
+ const availableVoices = speechSynthesis.getVoices();
+ setVoices(availableVoices);
+ if (availableVoices.length > 0) {
+ // Find a default voice or fallback to the first one
+ const defaultVoice =
+ availableVoices.find((voice) => voice.default) || availableVoices[0];
+ if (defaultVoice) {
+ setSelectedVoice(defaultVoice.name);
+ }
+ }
+ };
+
+ // The 'voiceschanged' event fires when the voice list is ready
+ speechSynthesis.onvoiceschanged = populateVoiceList;
+ populateVoiceList(); // Initial call for browsers that might have them ready
+
+ // Cleanup the event listener on component unmount
+ return () => {
+ speechSynthesis.onvoiceschanged = null;
+ };
+ }, []);
+
+ const handleSpeak = () => {
+ if (speechSynthesis.speaking) {
+ speechSynthesis.cancel();
+ setIsPlaying(false);
+ return;
+ }
+
+ if (text.trim() !== "") {
+ const utterance = new SpeechSynthesisUtterance(text);
+ const voice = voices.find((v) => v.name === selectedVoice);
+
+ if (voice) {
+ utterance.voice = voice;
+ }
+ utterance.pitch = pitch;
+ utterance.rate = rate;
+
+ utterance.onstart = () => {
+ setIsPlaying(true);
+ };
+
+ utterance.onend = () => {
+ setIsPlaying(false);
+ };
+
+ utterance.onerror = (event) => {
+ console.error("SpeechSynthesisUtterance.onerror", event);
+ setIsPlaying(false);
+ };
+
+ speechSynthesis.speak(utterance);
+ }
+ };
+
+ if (showControls) {
+ return (
+
+
+ Voice
+ setSelectedVoice(value)}
+ >
+
+
+
+
+ {voices.map((voice) => (
+
+ {`${voice.name} (${voice.lang})`}
+
+ ))}
+
+
+
+
+
+
+ Rate: {rate}
+
+ setRate(value[0] ?? 1)}
+ />
+
+
+
+
+ Pitch: {pitch}
+
+ setPitch(value[0] ?? 1)}
+ />
+
+
+
+ {isPlaying ? : }
+ {isPlaying ? "Stop" : "Play"}
+
+
+ );
+ }
+
+ return (
+
+ {isPlaying ? : }
+
+ );
+};
+
+export default TextToSpeech;
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 5a8445b..33613f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@auth/prisma-adapter": "^2.7.4",
"@clerk/nextjs": "^6.5.1",
"@google-cloud/pubsub": "^5.2.0",
+ "@google/generative-ai": "^0.24.1",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.1.0",
"@radix-ui/react-accordion": "^1.2.1",
@@ -1318,6 +1319,15 @@
"node": ">=18"
}
},
+ "node_modules/@google/generative-ai": {
+ "version": "0.24.1",
+ "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
+ "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@grpc/grpc-js": {
"version": "1.9.15",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz",
diff --git a/package.json b/package.json
index eaf5a42..1045a87 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@auth/prisma-adapter": "^2.7.4",
"@clerk/nextjs": "^6.5.1",
"@google-cloud/pubsub": "^5.2.0",
+ "@google/generative-ai": "^0.24.1",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.1.0",
"@radix-ui/react-accordion": "^1.2.1",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 645e9e8..dd6f4c9 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -13,18 +13,19 @@ enum UserRole {
}
model User {
- id String @id @default(cuid())
- name String?
- email String @unique
- emailVerified DateTime?
- image String?
- accounts Account[]
- role UserRole @default(USER)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- isBlocked Boolean @default(false)
- UserActionLog UserActionLog[]
- Deal Deal[]
+ id String @id @default(cuid())
+ name String?
+ email String @unique
+ emailVerified DateTime?
+ image String?
+ accounts Account[]
+ role UserRole @default(USER)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ isBlocked Boolean @default(false)
+ UserActionLog UserActionLog[]
+ Deal Deal[]
+ ChatConversation ChatConversation[]
}
model Account {
@@ -171,14 +172,12 @@ enum SIMStatus {
}
model Screener {
- id String @id @default(cuid())
- name String
- description String?
- content String
- fileUrl String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- AiScreening AiScreening[]
+ id String @id @default(cuid())
+ name String
+ content String
+ fileUrl String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
model AiScreening {
@@ -190,12 +189,8 @@ model AiScreening {
score Int?
content String?
sentiment Sentiment @default(NEUTRAL)
-
- screenerId String?
- screener Screener? @relation(fields: [screenerId], references: [id], onDelete: Cascade)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
enum Sentiment {
@@ -221,3 +216,22 @@ model Employee {
Deal Deal? @relation(fields: [dealId], references: [id])
dealId String?
}
+
+model ChatConversation {
+ id String @id @default(cuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ title String
+ messages ChatMessage[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model ChatMessage {
+ id String @id @default(cuid())
+ conversationId String
+ conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
+ role String
+ content String
+ createdAt DateTime @default(now())
+}