diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..cf0c07b --- /dev/null +++ b/.hintrc @@ -0,0 +1,15 @@ +{ + "extends": [ + "development" + ], + "hints": { + "compat-api/css": [ + "default", + { + "ignore": [ + "scrollbar-width" + ] + } + ] + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..531bdd6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "cSpell.words": [ + "dataforge", + "genai", + "Leetcode", + "msword", + "officedocument", + "supabase", + "wordprocessingml" + ] +} \ No newline at end of file diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index a8f0a55..4a3646c 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -2,6 +2,7 @@ /** * API Route for Gemini AI Generation * Handles POST requests to generate AI responses + * Using @google/genai package */ export const runtime = "nodejs"; @@ -9,11 +10,6 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { GoogleGenAI } from "@google/genai"; -// Initialize Gemini AI client -const ai = new GoogleGenAI({ - apiKey: process.env.GEMINI_API_KEY!, -}); - // ============================================ // TYPE DEFINITIONS // ============================================ @@ -42,11 +38,19 @@ function asRecord(x: unknown): AnyRecord { function extractTextFromResponse(response: unknown): string | null { const r = asRecord(response); - // v0.2+ returns response.text directly + // Check for text property directly if (typeof r["text"] === "string") { return r["text"]; } + // Check for response.text() method result + if (r["response"]) { + const resp = asRecord(r["response"]); + if (typeof resp["text"] === "string") { + return resp["text"]; + } + } + // Fallback: check candidates → content → parts structure const candidates = r["candidates"]; if (Array.isArray(candidates) && candidates.length > 0) { @@ -76,6 +80,20 @@ function extractTextFromResponse(response: unknown): string | null { */ export async function POST(request: Request): Promise { try { + // Get API key from request header (sent by client) or fallback to env variable + const apiKey = request.headers.get("X-Gemini-Key") || process.env.GEMINI_API_KEY; + + // Validate API key exists + if (!apiKey) { + return NextResponse.json( + { error: "Gemini API key not configured. Please add it in Settings > Developer Settings." }, + { status: 401 } + ); + } + + // Initialize Gemini AI client with API key (per-request, not module level) + const ai = new GoogleGenAI({ apiKey }); + // Parse request body safely const body = await request.json().catch(() => ({})); const bRec = asRecord(body); @@ -84,7 +102,10 @@ export async function POST(request: Request): Promise { const prompt = typeof bRec["prompt"] === "string" ? bRec["prompt"] : null; const model = typeof bRec["model"] === "string" ? bRec["model"] - : "gemini-2.0-flash-exp"; + : "gemini-2.0-flash-lite"; + + // ✅ NEW: optional parts (for files/images) coming from the frontend + const rawParts = Array.isArray(bRec["parts"]) ? (bRec["parts"] as unknown[]) : null; // Validate prompt if (!prompt) { @@ -94,12 +115,24 @@ export async function POST(request: Request): Promise { ); } + // ✅ Build parts array: always include text prompt, then any extra parts + const userParts: AnyRecord[] = [{ text: prompt }]; + + if (rawParts && rawParts.length > 0) { + for (const p of rawParts) { + if (typeof p === "object" && p !== null) { + // These are the inlineData objects you build on the client + userParts.push(p as AnyRecord); + } + } + } + // Call Gemini API with correct format const response = await ai.models.generateContent({ model, contents: [{ role: "user", - parts: [{ text: prompt }] + parts: userParts, }], }); @@ -109,7 +142,7 @@ export async function POST(request: Request): Promise { // Return successful response return NextResponse.json({ text, - raw: response, // Include for debugging (optional) + raw: response, // Include for debugging (remove in production) }); } catch (err: unknown) { @@ -125,4 +158,4 @@ export async function POST(request: Request): Promise { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/app/globals.css b/app/globals.css index a4cb226..f879f8c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -377,4 +377,23 @@ .landing-scroll *::-webkit-scrollbar-thumb:hover { background-color: var(--tertiary); -} */ \ No newline at end of file +} */ + +/* Make code-block scrollbars thin & subtle */ +.code-scroll { + scrollbar-width: thin; /* Firefox */ +} + +/* WebKit (Chrome / Edge / Safari) */ +.code-scroll::-webkit-scrollbar { + height: 4px; /* 👈 controls that fat green bar thickness */ +} + +.code-scroll::-webkit-scrollbar-track { + background: transparent; /* no big black strip */ +} + +.code-scroll::-webkit-scrollbar-thumb { + background-color: #048304; /* or whatever color you like */ + border-radius: 9999px; +} diff --git a/components/dijkstra-gpt.tsx b/components/dijkstra-gpt.tsx index 7f46d70..ebad7df 100644 --- a/components/dijkstra-gpt.tsx +++ b/components/dijkstra-gpt.tsx @@ -1,71 +1,133 @@ "use client"; -import React from "react"; -import { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect } from "react"; -// UI Components from shadcn/ui library +// UI Components import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -// Icon library (removed voice & enhance icons) +// Markdown +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// Icons import { - Paperclip, - Image, FileText, X, ArrowUp, Copy, RefreshCw, Share2, - MessageSquare, Trash2, ChevronLeft, - ChevronRight, Search, - Calendar, Check, Download, + Edit3, + MoreVertical, + Plus, + Square, } from "lucide-react"; -// API client for Gemini AI communication +// API & helpers import { callGemini } from "@/lib/geminiClient"; - -// Toast notification system import { toast } from "sonner"; +import { uploadFileToSupabase } from "@/lib/storageHelpers"; +import { loadChats, saveChat, updateChatMessages, deleteChat } from "@/lib/dbHelpers"; +import { useSettingsStore } from "@/lib/Zustand/settings-store"; // ============================================ -// TYPE DEFINITIONS +// TYPES // ============================================ +type MessageFileMeta = { + name: string; + url: string; + size?: number; + mime?: string; + base64?: string; +}; type Message = { id: string; role: "user" | "assistant"; content: string; timestamp: Date; - files?: File[]; + files?: File[] | MessageFileMeta[]; }; type ChatSession = { id: string; + session_id?: string; title: string; messages: Message[]; createdAt: Date; updatedAt: Date; }; +type PreviewFile = { + src: string; + name: string; + mime?: string; +}; + // ============================================ -// MAIN COMPONENT +// FILE HELPERS // ============================================ +const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result as string; + const base64Data = base64.split(",")[1]; + resolve(base64Data); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + +const getGeminiMimeType = (file: File): string => { + const mimeMap: Record = { + "image/jpeg": "image/jpeg", + "image/jpg": "image/jpeg", + "image/png": "image/png", + "image/gif": "image/gif", + "image/webp": "image/webp", + "application/pdf": "application/pdf", + "text/plain": "text/plain", + }; + return mimeMap[file.type] || file.type; +}; -export default function DijkstraGPT() { - // ============================================ - // STATE MANAGEMENT - // ============================================ +const isImageMeta = (file: any): boolean => { + const meta = file as MessageFileMeta; + const mime = meta.mime || ""; + return ( + mime.startsWith("image/") || + (meta.name && meta.name.match(/\.(png|jpe?g|gif|webp)$/i) != null) + ); +}; +const buildPreviewSrcFromMeta = (meta: MessageFileMeta): string | null => { + if (meta.url && meta.url.length > 0) return meta.url; + if (meta.base64) { + return `data:${meta.mime || "application/octet-stream"};base64,${meta.base64}`; + } + return null; +}; + +// ============================================ +// MAIN COMPONENT +// ============================================ +export default function DijkstraGPT() { + // STATE const [prompt, setPrompt] = useState(""); const [uploadedFiles, setUploadedFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -74,30 +136,38 @@ export default function DijkstraGPT() { const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [copiedMessageId, setCopiedMessageId] = useState(null); - const [apiStatus, setApiStatus] = useState<'checking' | 'active' | 'inactive'>('checking'); - const [hasMessages, setHasMessages] = useState(false); - - // ============================================ - // REFS FOR DOM ELEMENTS - // ============================================ + const [, setApiStatus] = useState<"checking" | "active" | "inactive">("checking"); + const [editingSessionId, setEditingSessionId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); + const [previewFile, setPreviewFile] = useState(null); + // REFS const textareaRef = useRef(null); const fileInputRef = useRef(null); const messagesEndRef = useRef(null); - + const cancelGenerationRef = useRef(false); + + const currentSession = chatSessions.find((s) => s.id === currentSessionId); + const messages = currentSession?.messages || []; + const hasMessages = messages.length > 0; // ============================================ - // COMPUTED VALUES + // UTIL / VALIDATION // ============================================ - - const currentSession = chatSessions.find((session) => session.id === currentSessionId); - const messages = currentSession?.messages || []; + const validateCredentials = (): boolean => { + const { geminiKey, supabaseUrl, supabaseKey } = useSettingsStore.getState(); + if (!geminiKey || !supabaseUrl || !supabaseKey) { + toast.error("Please configure your API credentials in Settings > Developer Settings", { + duration: 5000, + }); + return false; + } + return true; + }; // ============================================ // EFFECTS // ============================================ - - // Auto-resize textarea based on content useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -105,193 +175,320 @@ export default function DijkstraGPT() { } }, [prompt]); - // Auto-scroll to bottom when new messages arrive (scrolling parent container) useEffect(() => { if (messagesEndRef.current && hasMessages) { - // Find the scrollable parent container from page.tsx - let scrollContainer = messagesEndRef.current.closest('.overflow-y-auto'); + let scrollContainer = messagesEndRef.current.closest(".overflow-y-auto"); if (!scrollContainer) { - // Try to find the parent scroll container by traversing up let parent = messagesEndRef.current.parentElement; while (parent && parent !== document.body) { const style = window.getComputedStyle(parent); - if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + if (style.overflowY === "auto" || style.overflowY === "scroll") { scrollContainer = parent; break; } parent = parent.parentElement; } } - - if (scrollContainer) { - // Use setTimeout to ensure DOM is updated - setTimeout(() => { - scrollContainer?.scrollTo({ + const doScroll = () => { + if (scrollContainer) { + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, - behavior: "smooth" + behavior: "smooth", }); - }, 100); - } else { - // Fallback to direct scrollIntoView - setTimeout(() => { + } else { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - } + } + }; + setTimeout(doScroll, 80); } }, [messages, isLoading, hasMessages]); - // Track if there are messages to determine layout state - // Note: hasMessages is set immediately on submit, so we only update if it's false and messages exist useEffect(() => { - if (!hasMessages && messages.length > 0) { - setHasMessages(true); - } - }, [messages, hasMessages]); + async function init() { + if (!validateCredentials()) { + const fallback: ChatSession = { + id: Date.now().toString(), + session_id: Date.now().toString(), + title: "New Chat", + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + setChatSessions([fallback]); + setCurrentSessionId(fallback.id); + return; + } - // Initialize with a default session on mount - useEffect(() => { - const initialSession: ChatSession = { - id: Date.now().toString(), - title: "New Chat", - messages: [], - createdAt: new Date(), - updatedAt: new Date(), - }; + try { + const chats = await loadChats(); + if (!chats || chats.length === 0) { + const initial: ChatSession = { + id: Date.now().toString(), + session_id: Date.now().toString(), + title: "New Chat", + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + setChatSessions([initial]); + setCurrentSessionId(initial.id); + await saveChat({ + id: initial.id, + session_id: initial.session_id!, + title: initial.title, + messages: [], + }); + return; + } + + const mapped = chats.map((c: any) => { + const mappedMessages: Message[] = (c.messages ?? []).map((m: any) => ({ + id: m.id, + role: m.role, + content: m.content ?? "", + timestamp: m.timestamp ? new Date(m.timestamp) : new Date(), + files: m.files ?? undefined, + })); + return { + id: c.id, + session_id: c.session_id, + title: c.title, + messages: mappedMessages, + createdAt: new Date(c.created_at), + updatedAt: new Date(c.updated_at), + } as ChatSession; + }); + + setChatSessions(mapped); + setCurrentSessionId(mapped[0]?.id ?? null); + } catch (err) { + console.error("Failed to load chats:", err); + toast.error("Failed to load chats. Please check your Supabase credentials."); + + const fallback: ChatSession = { + id: Date.now().toString(), + session_id: Date.now().toString(), + title: "New Chat", + messages: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + setChatSessions([fallback]); + setCurrentSessionId(fallback.id); + } - setChatSessions([initialSession]); - setCurrentSessionId(initialSession.id); - }, []); + void checkApiStatus(); + } + + void init(); + }, []); // ============================================ - // SESSION MANAGEMENT FUNCTIONS + // SESSION MANAGEMENT // ============================================ - - const createNewChat = (): void => { - const newSession: ChatSession = { + const createNewChat = async (): Promise => { + const s: ChatSession = { id: Date.now().toString(), + session_id: Date.now().toString(), title: "New Chat", messages: [], createdAt: new Date(), updatedAt: new Date(), }; - setChatSessions((prev) => [newSession, ...prev]); - setCurrentSessionId(newSession.id); + setChatSessions((prev) => [s, ...prev]); + setCurrentSessionId(s.id); setPrompt(""); setUploadedFiles([]); - toast.success("New chat created"); + cancelGenerationRef.current = false; + + if (!validateCredentials()) return; + + try { + await saveChat({ id: s.id, session_id: s.session_id!, title: s.title, messages: [] }); + } catch (err) { + console.error("saveChat failed:", err); + toast.error("Could not save new chat to DB"); + } }; - const updateSessionTitle = (sessionId: string, firstMessage: string): void => { + const updateSessionTitle = async (sessionId: string, firstMessage: string): Promise => { + const newTitle = firstMessage.slice(0, 50) + (firstMessage.length > 50 ? "..." : ""); setChatSessions((prev) => - prev.map((session) => { - if (session.id === sessionId && session.title === "New Chat") { - return { - ...session, - title: firstMessage.slice(0, 50) + (firstMessage.length > 50 ? "..." : ""), - updatedAt: new Date(), - }; - } - return session; - }) + prev.map((s) => + s.id === sessionId && s.title === "New Chat" + ? { ...s, title: newTitle, updatedAt: new Date() } + : s + ) ); - }; - // Check API status on mount - useEffect(() => { - checkApiStatus(); - }, []); + const session = chatSessions.find((s) => s.id === sessionId); + if (session && session.title === "New Chat") { + try { + await saveChat({ + id: session.id, + session_id: session.session_id!, + title: newTitle, + messages: session.messages, + }); + } catch (err) { + console.error("Failed to update title in DB:", err); + } + } + }; - const deleteSession = (sessionId: string): void => { - setChatSessions((prev) => prev.filter((s) => s.id !== sessionId)); + const deleteSession = async (sessionId: string): Promise => { + if (!confirm("Are you sure you want to delete this chat?")) return; - if (currentSessionId === sessionId) { - const remaining = chatSessions.filter((s) => s.id !== sessionId); - setCurrentSessionId(remaining.length > 0 ? remaining[0].id : null); + try { + await deleteChat(sessionId); + setChatSessions((prev) => prev.filter((s) => s.id !== sessionId)); + if (currentSessionId === sessionId) { + const rest = chatSessions.filter((s) => s.id !== sessionId); + setCurrentSessionId(rest[0]?.id ?? null); + } + toast.success("Chat deleted"); + } catch (err) { + console.error("deleteChat failed:", err); + toast.error("Failed to delete chat from DB"); } - - toast.success("Chat deleted"); }; const downloadSession = (session: ChatSession): void => { - const dataStr = JSON.stringify(session, null, 2); - const dataBlob = new Blob([dataStr], { type: "application/json" }); + const lines = session.messages.map((m) => { + const roleLabel = m.role === "user" ? "User" : "Assistant"; + const time = m.timestamp.toLocaleString(); + const files = (m.files as any[]) || []; + const fileText = + files.length > 0 + ? `\nAttachments: ${files + .map((f: any) => f.name || "") + .filter(Boolean) + .join(", ")}` + : ""; + return `### ${roleLabel} (${time})\n\n${m.content || ""}${fileText}`; + }); + const md = `# ${session.title}\n\n${lines.join("\n\n---\n\n")}\n`; + const dataBlob = new Blob([md], { type: "text/markdown" }); const url = URL.createObjectURL(dataBlob); const link = document.createElement("a"); link.href = url; - link.download = `${session.title}_${Date.now()}.json`; + link.download = `${session.title.replace(/[^\w\-]+/g, "_") || "chat"}_${Date.now()}.md`; link.click(); - URL.revokeObjectURL(url); toast.success("Chat downloaded"); }; - const addMessage = (message: Message): void => { + const renameSession = (sessionId: string): void => { + const session = chatSessions.find((s) => s.id === sessionId); + if (session) { + setEditingSessionId(sessionId); + setEditingTitle(session.title); + } + }; + + const saveRename = async (): Promise => { + if (!editingSessionId || !editingTitle.trim()) { + setEditingSessionId(null); + setEditingTitle(""); + return; + } + setChatSessions((prev) => - prev.map((session) => { - if (session.id === currentSessionId) { - return { - ...session, - messages: [...session.messages, message], - updatedAt: new Date(), - }; - } - return session; - }) + prev.map((s) => + s.id === editingSessionId + ? { ...s, title: editingTitle.trim(), updatedAt: new Date() } + : s + ) ); + + const chatToSave = chatSessions.find((c) => c.id === editingSessionId); + try { + if (chatToSave) { + await saveChat({ + id: chatToSave.id, + session_id: chatToSave.session_id!, + title: editingTitle.trim(), + messages: chatToSave.messages, + }); + toast.success("Chat renamed"); + } + } catch (err) { + console.error("saveChat (rename) failed:", err); + toast.error("Failed to rename chat in DB"); + } finally { + setEditingSessionId(null); + setEditingTitle(""); + } }; - // ============================================ - // API STATUS CHECK - // ============================================ - const checkApiStatus = async (): Promise => { + const shareSession = async (sessionId: string): Promise => { + const session = chatSessions.find((s) => s.id === sessionId); + if (!session) return; + + const shareText = `${session.title}\n\n${session.messages + .map((m) => `${m.role === "user" ? "You" : "Assistant"}: ${m.content}`) + .join("\n\n")}`; + try { - const response = await callGemini("test"); - if (response) { - setApiStatus('active'); - console.log('✅ API key is active'); + if (navigator.share) { + await navigator.share({ title: session.title, text: shareText }); + toast.success("Chat shared"); } else { - setApiStatus('inactive'); - console.log('❌ API key is not configured or invalid'); + await navigator.clipboard.writeText(shareText); + toast.success("Chat copied to clipboard"); } } catch (error) { - setApiStatus('inactive'); - console.error('❌ API check failed:', error); + console.log("Share cancelled or failed:", error); } }; // ============================================ - // FILE HANDLING FUNCTIONS + // API STATUS // ============================================ + const checkApiStatus = async (): Promise => { + try { + const { geminiKey, supabaseUrl, supabaseKey } = useSettingsStore.getState(); + if (!geminiKey || !supabaseUrl || !supabaseKey) { + setApiStatus("inactive"); + return; + } + const response = await callGemini("test", []); + if (response) setApiStatus("active"); + else setApiStatus("inactive"); + } catch (error) { + setApiStatus("inactive"); + console.error("API check failed:", error); + } + }; + + // ============================================ + // FILE HANDLING + // ============================================ const handleFileUpload = (event: React.ChangeEvent): void => { const files = Array.from(event.target.files || []); - const validTypes = [ "application/pdf", "image/jpeg", "image/jpg", "image/png", + "image/gif", + "image/webp", "text/plain", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ]; const validFiles = files.filter((file) => { - const isValid = validTypes.includes(file.type) || file.name.match(/\.(jpg|jpeg|png|pdf|txt|doc|docx)$/i); - - if (!isValid) { - toast.error(`File type not supported: ${file.name}`); - } + const isValid = + validTypes.includes(file.type) || + file.name.match(/\.(jpg|jpeg|png|gif|webp|pdf|txt)$/i); + if (!isValid) toast.error(`File type not supported: ${file.name}`); return isValid; }); setUploadedFiles((prev) => [...prev, ...validFiles]); - - if (validFiles.length > 0) { - toast.success(`${validFiles.length} file(s) uploaded`); - } + if (validFiles.length > 0) toast.success(`${validFiles.length} file(s) added`); }; const removeFile = (index: number): void => { @@ -299,125 +496,244 @@ export default function DijkstraGPT() { }; // ============================================ - // MESSAGE ACTION FUNCTIONS + // MESSAGE ACTIONS // ============================================ - const copyMessage = async (content: string, messageId: string): Promise => { try { await navigator.clipboard.writeText(content); setCopiedMessageId(messageId); - toast.success("Copied to clipboard"); - - setTimeout(() => setCopiedMessageId(null), 2000); - } catch (error) { + toast.success("Copied"); + setTimeout(() => setCopiedMessageId(null), 1500); + } catch { toast.error("Failed to copy"); } }; - // Helper to update assistant message content progressively (streaming) - const updateAssistantContent = (sessionId: string | null, messageId: string, newContent: string) => { + const updateAssistantContent = ( + sessionId: string | null, + messageId: string, + newContent: string + ) => { if (!sessionId) return; setChatSessions((prev) => - prev.map((session) => { - if (session.id !== sessionId) return session; - return { - ...session, - messages: session.messages.map((m) => (m.id === messageId ? { ...m, content: newContent } : m)), - updatedAt: new Date(), - }; - }) + prev.map((session) => + session.id === sessionId + ? { + ...session, + messages: session.messages.map((m) => + m.id === messageId ? { ...m, content: newContent } : m + ), + updatedAt: new Date(), + } + : session + ) ); }; - // Try to handle streaming from callGemini. If callGemini returns a Response-like object with a body stream, - // we read it. Otherwise, if it returns a string, we simulate a typewriter streaming effect. - const streamGeminiResponse = async (promptText: string, sessionId: string, assistantMessageId: string) => { - try { - const res: any = await callGemini(promptText); - - // If it's a fetch Response-like object with a ReadableStream body - if (res && res.body && typeof res.body.getReader === "function") { - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let done = false; - let accumulated = ""; - - while (!done) { - // eslint-disable-next-line no-await-in-loop - const { value, done: d } = await reader.read(); - if (value) { - accumulated += decoder.decode(value, { stream: true }); - updateAssistantContent(sessionId, assistantMessageId, accumulated); + const addMessage = async (message: Message): Promise => { + let filesToSave: MessageFileMeta[] | undefined; + + if (message.files && message.files.length > 0 && (message.files as any)[0] instanceof File) { + filesToSave = []; + for (const f of message.files as File[]) { + let base64Data: string | undefined; + let publicUrl = ""; + + try { + base64Data = await fileToBase64(f); + } catch (err) { + console.error("Base64 conversion failed:", err); + toast.error(`Failed to read ${f.name}`); + } + + try { + const uploaded = await uploadFileToSupabase(f); + if (uploaded && typeof uploaded.publicUrl === "string") { + publicUrl = uploaded.publicUrl; } - done = !!d; + } catch (err) { + console.error("Supabase upload failed:", err); } - // finalize (in case any leftovers) - updateAssistantContent(sessionId, assistantMessageId, accumulated); - return accumulated; + if (base64Data) { + filesToSave.push({ + name: f.name, + url: publicUrl, + size: f.size, + mime: f.type || getGeminiMimeType(f), + base64: base64Data, + }); + } } + } else if (message.files) { + filesToSave = message.files as MessageFileMeta[]; + } - // If callGemini returned a string, simulate typing - if (typeof res === "string") { - const full = res; - let i = 0; - // A small delay between characters produces a smooth typing effect. Adjust as needed. - while (i <= full.length) { - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => setTimeout(r, 12)); - i += Math.ceil(Math.random() * 3); // append a few chars at a time to feel natural - const chunk = full.slice(0, i); - updateAssistantContent(sessionId, assistantMessageId, chunk); + const msgToSave: Message = { + ...message, + files: filesToSave, + timestamp: message.timestamp ?? new Date(), + }; + + let newMessagesForDb: Message[] | null = null; + + setChatSessions((prev) => + prev.map((s) => { + if (s.id === currentSessionId) { + const updatedMessages = [...s.messages, msgToSave]; + newMessagesForDb = updatedMessages; + return { ...s, messages: updatedMessages, updatedAt: new Date() }; } + return s; + }) + ); - updateAssistantContent(sessionId, assistantMessageId, full); - return full; + if (currentSessionId && newMessagesForDb) { + try { + await updateChatMessages(currentSessionId, newMessagesForDb); + } catch (err) { + console.error("updateChatMessages failed:", err); } + } + + return filesToSave; + }; - // If the response shape is unknown but contains text field - if (res && typeof res === "object" && typeof res.text === "string") { - const text = res.text; + // ============================================ + // STREAMING + // ============================================ + const streamGeminiResponse = async ( + promptText: string, + sessionId: string, + assistantMessageId: string, + files?: MessageFileMeta[] + ) => { + try { + const fileParts = + files && files.length > 0 + ? files.map((file) => ({ + inlineData: { + mimeType: file.mime || "image/jpeg", + data: file.base64 || "", + }, + })) + : []; + + const res: any = await callGemini(promptText, fileParts); + + const handleStreaming = async (full: string) => { let i = 0; - while (i <= text.length) { - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => setTimeout(r, 12)); - i += Math.ceil(Math.random() * 4); - updateAssistantContent(sessionId, assistantMessageId, text.slice(0, i)); + while (i <= full.length) { + if (cancelGenerationRef.current) { + const partial = full.slice(0, i) || "⚠ Generation stopped by user."; + updateAssistantContent(sessionId, assistantMessageId, partial); + await saveStreamedMessage(sessionId, assistantMessageId, partial); + return partial; + } + await new Promise((r) => setTimeout(r, 14)); + i += Math.ceil(Math.random() * 3); + updateAssistantContent(sessionId, assistantMessageId, full.slice(0, i)); } - updateAssistantContent(sessionId, assistantMessageId, text); - return text; + updateAssistantContent(sessionId, assistantMessageId, full); + await saveStreamedMessage(sessionId, assistantMessageId, full); + return full; + }; + + if (typeof res === "string") return await handleStreaming(res); + if (res && typeof res === "object" && typeof (res as any).text === "string") { + return await handleStreaming((res as any).text); } - // Unknown shape -> convert to string and show const fallback = String(res); updateAssistantContent(sessionId, assistantMessageId, fallback); + await saveStreamedMessage(sessionId, assistantMessageId, fallback); return fallback; } catch (error) { + if (cancelGenerationRef.current) { + updateAssistantContent(sessionId, assistantMessageId, "⚠ Generation stopped by user."); + return; + } const errStr = "⚠ Error: " + String(error); updateAssistantContent(sessionId, assistantMessageId, errStr); throw error; } }; - const regenerateMessage = async (userMessage: Message): Promise => { - setIsLoading(true); + const saveStreamedMessage = async ( + sessionId: string, + messageId: string, + content: string + ): Promise => { + const session = chatSessions.find((s) => s.id === sessionId); + if (!session) return; + const updatedMessages = session.messages.map((m) => + m.id === messageId ? { ...m, content } : m + ); + try { + await updateChatMessages(sessionId, updatedMessages); + } catch (err) { + console.error("Failed to save streamed message to DB:", err); + } + }; + + // ============================================ + // REGENERATE + // ============================================ + const handleRegenerate = async (assistantMessageId: string): Promise => { + const session = chatSessions.find((s) => s.id === currentSessionId); + if (!session) return; + const idx = session.messages.findIndex((m) => m.id === assistantMessageId); + if (idx <= 0) return; + + const userMsg = session.messages[idx - 1]; + if (!userMsg || userMsg.role !== "user") return; + + setChatSessions((prev) => + prev.map((s) => + s.id === currentSessionId + ? { ...s, messages: s.messages.filter((m) => m.id !== assistantMessageId) } + : s + ) + ); + const updatedMessages = session.messages.filter((m) => m.id !== assistantMessageId); try { - // create placeholder assistant message - const assistantMessage: Message = { - id: (Date.now() + 2).toString(), - role: "assistant", - content: "", - timestamp: new Date(), - }; + await updateChatMessages(currentSessionId!, updatedMessages); + } catch (err) { + console.error("Failed to update DB after regenerate:", err); + } - addMessage(assistantMessage); + cancelGenerationRef.current = false; + setIsLoading(true); - await streamGeminiResponse(userMessage.content, currentSessionId!, assistantMessage.id); + const newAssistantMessage: Message = { + id: (Date.now() + 2).toString(), + role: "assistant", + content: "", + timestamp: new Date(), + }; + + setChatSessions((prev) => + prev.map((s) => + s.id === currentSessionId + ? { ...s, messages: [...s.messages, newAssistantMessage], updatedAt: new Date() } + : s + ) + ); + try { + const userFiles = userMsg.files as MessageFileMeta[] | undefined; + await streamGeminiResponse( + userMsg.content || "Please analyze the attached file.", + currentSessionId!, + newAssistantMessage.id, + userFiles + ); toast.success("Response regenerated"); } catch (error) { - toast.error("Failed to regenerate: " + String(error)); + if (!cancelGenerationRef.current) { + toast.error("Failed to regenerate: " + String(error)); + } } finally { setIsLoading(false); } @@ -426,14 +742,11 @@ export default function DijkstraGPT() { const shareMessage = async (content: string): Promise => { try { if (navigator.share) { - await navigator.share({ - title: "DijkstraGPT Message", - text: content, - }); - toast.success("Shared successfully"); + await navigator.share({ title: "DijkstraGPT Message", text: content }); + toast.success("Shared"); } else { await navigator.clipboard.writeText(content); - toast.success("Copied to clipboard (Share not available)"); + toast.success("Copied (share not available)"); } } catch (error) { console.log("Share cancelled or failed:", error); @@ -441,22 +754,24 @@ export default function DijkstraGPT() { }; // ============================================ - // INPUT HANDLING FUNCTIONS + // INPUT HANDLERS // ============================================ - const handleSubmit = async (): Promise => { if (!prompt.trim() && uploadedFiles.length === 0) return; + if (!validateCredentials()) return; - // Trigger layout transition immediately - setHasMessages(true); + cancelGenerationRef.current = false; - if (!currentSessionId) { - createNewChat(); - } + if (!currentSessionId) await createNewChat(); const currentPrompt = prompt; const currentFiles = [...uploadedFiles]; + const hasText = currentPrompt.trim().length > 0; + const effectivePrompt = hasText + ? currentPrompt + : "Please analyze the attached file(s) and give me a helpful response."; + const userMessage: Message = { id: Date.now().toString(), role: "user", @@ -465,17 +780,17 @@ export default function DijkstraGPT() { files: currentFiles.length > 0 ? currentFiles : undefined, }; - addMessage(userMessage); + const processedFiles = await addMessage(userMessage); setPrompt(""); setUploadedFiles([]); setIsLoading(true); if (currentSession && currentSession.messages.length === 0) { - updateSessionTitle(currentSessionId!, currentPrompt); + const titleSource = hasText ? currentPrompt : currentFiles[0]?.name || effectivePrompt; + await updateSessionTitle(currentSessionId!, titleSource); } - // Create an assistant placeholder message (will be updated progressively) const assistantMessage: Message = { id: (Date.now() + 1).toString(), role: "assistant", @@ -483,20 +798,33 @@ export default function DijkstraGPT() { timestamp: new Date(), }; - addMessage(assistantMessage); + setChatSessions((prev) => + prev.map((s) => + s.id === currentSessionId + ? { ...s, messages: [...s.messages, assistantMessage], updatedAt: new Date() } + : s + ) + ); try { - await streamGeminiResponse(currentPrompt, currentSessionId!, assistantMessage.id); + await streamGeminiResponse( + effectivePrompt, + currentSessionId!, + assistantMessage.id, + processedFiles + ); } catch (error) { - const errorMessage: Message = { - id: (Date.now() + 3).toString(), - role: "assistant", - content: "⚠ Error: " + String(error), - timestamp: new Date(), - }; - // replace the placeholder content with the error - updateAssistantContent(currentSessionId, assistantMessage.id, errorMessage.content); - toast.error("Failed to get response"); + if (!cancelGenerationRef.current) { + const errorMessage: Message = { + id: (Date.now() + 3).toString(), + role: "assistant", + content: "⚠ Error: " + String(error), + timestamp: new Date(), + }; + updateAssistantContent(currentSessionId, assistantMessage.id, errorMessage.content); + await saveStreamedMessage(currentSessionId!, assistantMessage.id, errorMessage.content); + toast.error("Failed to get response"); + } } finally { setIsLoading(false); } @@ -509,139 +837,208 @@ export default function DijkstraGPT() { } }; + const handleStopGeneration = () => { + if (!isLoading) return; + cancelGenerationRef.current = true; + setIsLoading(false); + toast.info("Generation stopped"); + }; + // ============================================ - // SEARCH & FILTER + // SEARCH // ============================================ - const filteredSessions = chatSessions.filter( (session) => session.title.toLowerCase().includes(searchQuery.toLowerCase()) || - session.messages.some((msg) => msg.content.toLowerCase().includes(searchQuery.toLowerCase())) + session.messages.some((msg) => + msg.content.toLowerCase().includes(searchQuery.toLowerCase()) + ) ); - const groupedSessions = filteredSessions.reduce((groups, session) => { - const date = session.updatedAt.toDateString(); - if (!groups[date]) { - groups[date] = []; - } - groups[date].push(session); - return groups; - }, {} as Record); - // ============================================ - // RENDER MESSAGE FUNCTION + // MESSAGE RENDER // ============================================ + const renderFilePreview = (file: any, idx: number) => { + const meta = file as MessageFileMeta; + const mime = meta.mime || ""; + const isImage = + mime.startsWith("image/") || + (meta.name && meta.name.match(/\.(png|jpe?g|gif|webp)$/i) != null); + + const src = buildPreviewSrcFromMeta(meta); + + const openPreview = () => { + if (!src) return; + setPreviewFile({ + src, + name: meta.name || "File", + mime: mime || undefined, + }); + }; + + if (isImage && src) { + return ( + + ); + } - const renderMessage = (msg: Message, i: number): React.JSX.Element => { + return ( + + ); + }; + + const renderMessage = (msg: Message, i: number): JSX.Element => { const isUser = msg.role === "user"; const isCopied = copiedMessageId === msg.id; + const isLastMessage = i === messages.length - 1; + const isStreamingAssistant = !isUser && isLastMessage && isLoading; - // Split content by code blocks (```) and preserve formatting - const parts = msg.content.split(/```/g); + const hasFiles = msg.files && (msg.files as any).length > 0; + const filesArray = (msg.files as any[]) || []; - return ( -
+ const allImagesOnly = + hasFiles && + filesArray.length > 0 && + filesArray.every((f) => isImageMeta(f)) && + (msg.content || "").trim().length === 0; + + const contentToRender = msg.content || ""; + + const displayContent = + isStreamingAssistant && !contentToRender.trim().length + ? "_DijkstraGPT is thinking..._" + : contentToRender; + + if (allImagesOnly) { + return (
-
- {/* File attachments display for user messages */} - {isUser && msg.files && msg.files.length > 0 && ( -
- {msg.files.map((file, idx) => ( - - - {file.name} - - ))} -
- )} - - {/* Message content with code block formatting */} -
- {parts.map((part, idx) => - idx % 2 === 1 ? ( - // Code block (odd indices) -
-
{part.trim()}
-
- ) : ( - // Regular text with list formatting (even indices) -
- {part.split("\n").map((line, li) => { - if (line.trim().match(/^[-*]\s+/)) { - return ( -
  • - {line.replace(/^[-*]\s+/, "")} -
  • - ); - } else if (line.trim().match(/^\d+\.\s+/)) { - return ( -
  • - {line.replace(/^\d+\.\s+/, "")} -
  • - ); - } else if (line.trim()) { - return

    {line}

    ; - } - return
    ; - })} -
    - ) - )} +
    +
    + {filesArray.map((file, idx) => renderFilePreview(file, idx))}
    - - {/* Timestamp */} -

    {msg.timestamp.toLocaleTimeString()}

    +

    + {msg.timestamp.toLocaleTimeString()} +

    +
    + ); + } - {/* Action buttons for assistant messages - ICONS ONLY */} - {!isUser && ( -
    - {/* Copy button - Icon only */} - - - {/* Regenerate button - Icon only */} - - - {/* Share button - Icon only */} - + {displayContent} + +
    + )} + + {!isStreamingAssistant && ( +
    + {msg.timestamp.toLocaleTimeString()} + + {!isUser && ( + <> + +
    + + + +
    + + )}
    )}
    @@ -652,7 +1049,6 @@ export default function DijkstraGPT() { // ============================================ // EXAMPLE PROMPTS // ============================================ - const examplePrompts = [ "I'm lost. How do I get started with coding to get a job in tech?", "What are the steps I can take to become a Computer Science Engineer?", @@ -663,106 +1059,141 @@ export default function DijkstraGPT() { ]; // ============================================ - // RENDER INPUT AREA + // INPUT AREA (ChatGPT-style) // ============================================ - const renderInputArea = (isCentered: boolean = false) => ( -
    - {/* Uploaded files preview */} - {uploadedFiles.length > 0 && ( -
    -
    - {uploadedFiles.map((file, index) => ( - - - {file.name} - - - ))} -
    -
    - )} +
    +
    +
    + {uploadedFiles.length > 0 && ( +
    + {uploadedFiles.map((file, index) => { + const isImageFile = + (file.type && file.type.startsWith("image/")) || + file.name.match(/\.(png|jpe?g|gif|webp)$/i); + + const objectUrl = + typeof window !== "undefined" ? URL.createObjectURL(file) : ""; + + if (isImageFile) { + return ( + + + ); + } + + return ( +
    + + +
    + ); + })} +
    + )} - {/* Input container */} -
    -