From 47b5060aa662e97318cff308f108bee14d994ee2 Mon Sep 17 00:00:00 2001 From: MohamedElsayed002 Date: Sun, 31 May 2026 01:31:56 +0300 Subject: [PATCH] feat: implement multiple AI chat interface with session management, model selection, and message history sidebar --- client/app/api/multiple-ai/route.ts | 79 +++ client/app/multiple-ai/[chatId]/page.tsx | 53 ++ client/app/multiple-ai/actions.ts | 102 ++++ .../multiple-ai/components/ChatContainer.tsx | 465 ++++++++++++++++++ .../app/multiple-ai/components/Markdown.tsx | 156 ++++++ client/app/multiple-ai/components/Sidebar.tsx | 179 +++++++ client/app/multiple-ai/layout.tsx | 37 ++ client/app/multiple-ai/page.tsx | 231 +++++++++ client/app/page.tsx | 16 - client/constants/models.ts | 91 ++++ client/constants/modes.ts | 60 +++ client/lib/ai/models.ts | 22 + client/lib/ai/modes.ts | 20 + client/lib/ai/prompt.ts | 28 ++ client/lib/ai/providers.ts | 55 +++ client/lib/ai/schemas.ts | 17 + client/lib/ai/stream.ts | 43 ++ client/lib/ai/tools.ts | 39 ++ client/lib/ai/types.ts | 27 + client/lib/utils.ts | 24 + client/package-lock.json | 138 +++++- client/package.json | 4 + client/types/chat.ts | 11 + client/types/model.ts | 22 + 24 files changed, 1899 insertions(+), 20 deletions(-) create mode 100644 client/app/api/multiple-ai/route.ts create mode 100644 client/app/multiple-ai/[chatId]/page.tsx create mode 100644 client/app/multiple-ai/actions.ts create mode 100644 client/app/multiple-ai/components/ChatContainer.tsx create mode 100644 client/app/multiple-ai/components/Markdown.tsx create mode 100644 client/app/multiple-ai/components/Sidebar.tsx create mode 100644 client/app/multiple-ai/layout.tsx create mode 100644 client/app/multiple-ai/page.tsx create mode 100644 client/constants/models.ts create mode 100644 client/constants/modes.ts create mode 100644 client/lib/ai/models.ts create mode 100644 client/lib/ai/modes.ts create mode 100644 client/lib/ai/prompt.ts create mode 100644 client/lib/ai/providers.ts create mode 100644 client/lib/ai/schemas.ts create mode 100644 client/lib/ai/stream.ts create mode 100644 client/lib/ai/tools.ts create mode 100644 client/lib/ai/types.ts create mode 100644 client/types/chat.ts create mode 100644 client/types/model.ts diff --git a/client/app/api/multiple-ai/route.ts b/client/app/api/multiple-ai/route.ts new file mode 100644 index 0000000..fcbfd25 --- /dev/null +++ b/client/app/api/multiple-ai/route.ts @@ -0,0 +1,79 @@ +import { streamChat } from "@/lib/ai/stream"; +import { prisma } from "@/lib/db"; +import { ChatRequestSchema } from "@/lib/ai/schemas"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const body = await req.json(); + + // Validate request schema + const parseResult = ChatRequestSchema.safeParse(body); + if (!parseResult.success) { + return new Response(JSON.stringify({ error: "Invalid request payload", details: parseResult.error.format() }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { chatId, messages, modelId, mode } = parseResult.data; + + // Call streamChat helper from client/lib/ai/stream.ts + // We pass our custom onFinish handler to persist the assistant response + const startTime = Date.now(); + const result = streamChat({ + chatId, + messages, + modelId, + mode, + onFinish: async ({ text, usage }: any) => { + try { + const durationMs = Date.now() - startTime; + await prisma.message.create({ + data: { + role: "assistant", + content: text, + model: modelId, + mode: mode, + promptTokens: usage?.promptTokens, + completionTokens: usage?.completionTokens, + durationMs, + chatId, + }, + }); + + // Automatically update the chat title if it's "New Chat" + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + select: { title: true, messages: { take: 1, orderBy: { createdAt: "asc" } } }, + }); + + if (chat && chat.title === "New Chat") { + const firstUserMsg = chat.messages[0]?.content || text; + const generatedTitle = firstUserMsg.trim().slice(0, 40) + (firstUserMsg.length > 40 ? "..." : ""); + await prisma.chat.update({ + where: { id: chatId }, + data: { title: generatedTitle || "New Chat" }, + }); + } + } catch (dbError) { + console.error("Failed to save assistant message to database:", dbError); + } + } + }); + + return result.toTextStreamResponse(); + } catch (error) { + console.error("Stream route error:", error); + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : "An error occurred during streaming", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/client/app/multiple-ai/[chatId]/page.tsx b/client/app/multiple-ai/[chatId]/page.tsx new file mode 100644 index 0000000..c48ade1 --- /dev/null +++ b/client/app/multiple-ai/[chatId]/page.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { notFound, redirect } from "next/navigation"; +import { getChat } from "../actions"; +import { ChatContainer } from "../components/ChatContainer"; +import { ChatMode } from "@/constants/modes"; + +interface ChatPageProps { + params: Promise<{ chatId: string }>; +} + +export default async function ChatSessionPage({ params }: ChatPageProps) { + const { chatId } = await params; + const chat = await getChat(chatId); + + if (!chat) { + redirect("/multiple-ai"); + } + + // Formatting messages for type compatibility + const initialMessages = chat.messages.map((m) => ({ + id: m.id, + role: m.role as any, + content: m.content, + model: m.model, + mode: m.mode, + promptTokens: m.promptTokens, + completionTokens: m.completionTokens, + durationMs: m.durationMs, + createdAt: m.createdAt, + })); + + // Resolve initial model/mode based on the last assistant message, or default to chat mode settings + let initialModelId = "gemini-2.0-flash"; + let initialMode: ChatMode = (chat.mode as ChatMode) || "chat"; + + // Search backwards to find the last configured model and mode + for (let i = chat.messages.length - 1; i >= 0; i--) { + const msg = chat.messages[i]; + if (msg.model) { + initialModelId = msg.model; + break; + } + } + + return ( + + ); +} diff --git a/client/app/multiple-ai/actions.ts b/client/app/multiple-ai/actions.ts new file mode 100644 index 0000000..987bd65 --- /dev/null +++ b/client/app/multiple-ai/actions.ts @@ -0,0 +1,102 @@ +"use server"; + +import { prisma } from "@/lib/db"; +import { ChatMode, MessageRole } from "@/lib/generated/prisma/enums"; +import { revalidatePath } from "next/cache"; + +export async function getChats() { + try { + return await prisma.chat.findMany({ + orderBy: { + createdAt: "desc", + }, + include: { + messages: { + take: 1, + orderBy: { + createdAt: "asc", + }, + }, + }, + }); + } catch (error) { + console.error("Error fetching chats:", error); + return []; + } +} + +export async function getChat(id: string) { + try { + return await prisma.chat.findUnique({ + where: { id }, + include: { + messages: { + orderBy: { + createdAt: "asc", + }, + }, + }, + }); + } catch (error) { + console.error(`Error fetching chat ${id}:`, error); + return null; + } +} + +export async function createChat(mode: ChatMode = "chat") { + try { + const chat = await prisma.chat.create({ + data: { + title: "New Chat", + mode, + }, + }); + revalidatePath("/multiple-ai"); + return chat; + } catch (error) { + console.error("Error creating chat:", error); + throw new Error("Could not create chat session."); + } +} + +export async function deleteChat(id: string) { + try { + await prisma.chat.delete({ + where: { id }, + }); + revalidatePath("/multiple-ai"); + return { success: true }; + } catch (error) { + console.error(`Error deleting chat ${id}:`, error); + throw new Error("Could not delete chat session."); + } +} + +export async function saveUserMessage({ + chatId, + content, + modelId, + mode, +}: { + chatId: string; + content: string; + modelId: string; + mode: ChatMode; +}) { + try { + const message = await prisma.message.create({ + data: { + role: "user" as MessageRole, + content, + model: modelId, + mode, + chatId, + }, + }); + revalidatePath(`/multiple-ai/${chatId}`); + return message; + } catch (error) { + console.error("Error saving user message:", error); + throw new Error("Could not save message."); + } +} diff --git a/client/app/multiple-ai/components/ChatContainer.tsx b/client/app/multiple-ai/components/ChatContainer.tsx new file mode 100644 index 0000000..52b0405 --- /dev/null +++ b/client/app/multiple-ai/components/ChatContainer.tsx @@ -0,0 +1,465 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { Send, Sparkles, MessageSquare, Bot, Code2, Search, Zap, Cpu, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card } from "@/components/ui/card"; +import { MODELS } from "@/constants/models"; +import { CHAT_MODES, ChatMode } from "@/constants/modes"; +import { saveUserMessage } from "../actions"; +import { Markdown } from "./Markdown"; +import { cn } from "@/lib/utils"; + +interface Message { + id: string; + role: "user" | "assistant" | "system" | "tool"; + content: string; + model?: string | null; + mode?: ChatMode | string | null; + promptTokens?: number | null; + completionTokens?: number | null; + durationMs?: number | null; + createdAt: Date; +} + +interface ChatContainerProps { + chatId: string; + initialMessages: Message[]; + initialModelId?: string; + initialMode?: ChatMode; +} + +export function ChatContainer({ + chatId, + initialMessages, + initialModelId = "gemini-2.0-flash", + initialMode = "chat", +}: ChatContainerProps) { + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(""); + const [selectedModel, setSelectedModel] = useState(initialModelId); + const [selectedMode, setSelectedMode] = useState(initialMode); + const [isGenerating, setIsGenerating] = useState(false); + + const scrollRef = useRef(null); + const abortControllerRef = useRef(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, isGenerating]); + + // Sync messages when active chatId changes + useEffect(() => { + setMessages(initialMessages); + setSelectedModel(initialModelId); + setSelectedMode(initialMode); + }, [chatId, initialMessages, initialModelId, initialMode]); + + // Parse Vercel AI SDK text stream chunk + const parseChunk = (chunk: string): string => { + let text = ""; + // SDK streams data in lines like: 0:"content"\n + const lines = chunk.split("\n"); + for (const line of lines) { + if (line.startsWith('0:')) { + try { + const parsed = JSON.parse(line.slice(2)); + if (typeof parsed === "string") { + text += parsed; + } + } catch { + // Fallback parsing if JSON parsing fails on partial chunk + const match = line.match(/^0:"(.*)"$/); + if (match) { + text += match[1]; + } + } + } + } + return text; + }; + + const handleSend = async (textToSend: string) => { + if (!textToSend.trim() || isGenerating) return; + + setIsGenerating(true); + setInput(""); + + // 1. Save user message to database + let savedUserMsg: Message; + try { + const dbMsg = await saveUserMessage({ + chatId, + content: textToSend, + modelId: selectedModel, + mode: selectedMode, + }); + savedUserMsg = { + ...dbMsg, + role: dbMsg.role as any, + createdAt: new Date(dbMsg.createdAt), + }; + + // Append user message to state + setMessages((prev) => [...prev, savedUserMsg]); + } catch (err) { + console.error("Failed to save user message:", err); + setIsGenerating(false); + return; + } + + // 2. Setup streaming + const controller = new AbortController(); + abortControllerRef.current = controller; + + // Create assistant message placeholder + const assistantPlaceholderId = "streaming-assistant-placeholder"; + setMessages((prev) => [ + ...prev, + { + id: assistantPlaceholderId, + role: "assistant", + content: "", + model: selectedModel, + mode: selectedMode, + createdAt: new Date(), + }, + ]); + + try { + const response = await fetch("/api/multiple-ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chatId, + messages: [ + ...messages.map((m) => ({ role: m.role, content: m.content })), + { role: "user", content: textToSend }, + ], + modelId: selectedModel, + mode: selectedMode, + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to initiate stream: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) throw new Error("No response body reader available"); + + let streamedText = ""; + let done = false; + + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + if (value) { + const chunk = decoder.decode(value, { stream: !done }); + streamedText += parseChunk(chunk); + + setMessages((prev) => + prev.map((msg) => + msg.id === assistantPlaceholderId + ? { ...msg, content: streamedText } + : msg + ) + ); + } + } + + // Convert placeholder to a formal message + setMessages((prev) => + prev.map((msg) => + msg.id === assistantPlaceholderId + ? { ...msg, id: `msg-assistant-${Date.now()}` } + : msg + ) + ); + } catch (err: any) { + if (err.name === "AbortError") { + console.log("Stream generation aborted by user."); + } else { + console.error("Error during streaming:", err); + setMessages((prev) => + prev.map((msg) => + msg.id === assistantPlaceholderId + ? { ...msg, content: msg.content + "\n\n*Error: Connection lost or generation failed.*" } + : msg + ) + ); + } + } finally { + setIsGenerating(false); + abortControllerRef.current = null; + } + }; + + const handleStop = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + + const renderProviderIcon = (provider: string) => { + switch (provider) { + case "openai": + return ; + case "anthropic": + return ; + default: + return ; + } + }; + + const renderModeIcon = (mode: string) => { + switch (mode) { + case "build": + return ; + case "research": + return ; + case "agent": + return ; + default: + return ; + } + }; + + const starters = [ + { + title: "Write a React Hook", + desc: "Implement debounce logic with clean Typescript types", + prompt: "Write a complete custom React hook `useDebounce` in TypeScript, including a code execution example showing how it is used.", + mode: "build", + model: "gemini-2.0-flash", + }, + { + title: "SQL vs NoSQL Design", + desc: "Compare transactional speed vs flexibility", + prompt: "Explain the architecture differences between Postgres (SQL) and MongoDB (NoSQL). Design a database schema for an e-commerce catalog in both, comparing pros and cons.", + mode: "research", + model: "gemini-2.5-pro", + }, + { + title: "Code review helper", + desc: "Analyze safety issues in a JWT sign method", + prompt: "Can you analyze standard JWT token security risks and write a secure Node.js JWT signing & verification middleware implementation?", + mode: "chat", + model: "gpt-4o-mini", + }, + { + title: "AI Agent Workflow", + desc: "Explain multi-agent state machines", + prompt: "Explain the concept of an AI Agent. Describe how LangGraph or state machines are used to control autonomous agent workflows step by step.", + mode: "agent", + model: "gemini-2.0-flash", + }, + ]; + + return ( +
+ {/* Top Header Bar */} +
+
+ Active Model +
+ +
+ {/* Model Selector */} +
+ +
+ + {/* Mode Selector */} +
+ +
+
+
+ + {/* Message History area */} +
+ {messages.length === 0 ? ( +
+
+
+ +
+

Configure & Query

+

+ Select your LLM engine, choose the task mode, and send a message. Every conversation is automatically persisted. +

+
+ +
+ {starters.map((starter, idx) => ( + { + setSelectedMode(starter.mode as ChatMode); + setSelectedModel(starter.model); + handleSend(starter.prompt); + }} + className="p-5 bg-slate-950/40 border-slate-850/60 hover:border-indigo-500/50 hover:bg-slate-950/80 cursor-pointer rounded-2xl transition-all duration-300 group shadow-md shadow-black/10" + > +
+

+ {starter.title} +

+ +
+

{starter.desc}

+
+ ))} +
+
+ ) : ( +
+ {messages.map((msg, index) => { + const isUser = msg.role === "user"; + const isStreamingPlaceholder = msg.id === "streaming-assistant-placeholder"; + + return ( +
+ {/* Bubble Content */} + {isUser ? ( +
{msg.content}
+ ) : ( +
+ + {isStreamingPlaceholder && isGenerating && ( + + )} +
+ )} + + {/* Metadata labels */} + {!isUser && msg.model && ( +
+
+ + {renderProviderIcon(msg.model.split("-")[0])} + {MODELS.find((m) => m.id === msg.model)?.label || msg.model} + + + + {renderModeIcon(msg.mode || "chat")} + {msg.mode} + +
+ {msg.durationMs && ( + + {msg.promptTokens && `In: ${msg.promptTokens} · Out: ${msg.completionTokens} · `} + {(msg.durationMs / 1000).toFixed(1)}s + + )} +
+ )} +
+ ); + })} +
+
+ )} +
+ + {/* Input controls area */} +
+
+