diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..639aac5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "cSpell.words": [ + "dataforge", + "genai", + "Leetcode", + "msword", + "officedocument", + "wordprocessingml" + ] +} \ No newline at end of file diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index a8f0a55..400cf4a 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -84,7 +84,7 @@ 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-preview"; // Validate prompt if (!prompt) { diff --git a/components/dijkstra-gpt.tsx b/components/dijkstra-gpt.tsx index 7f46d70..994e58a 100644 --- a/components/dijkstra-gpt.tsx +++ b/components/dijkstra-gpt.tsx @@ -11,6 +11,10 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +// Markdown rendering +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + // Icon library (removed voice & enhance icons) import { Paperclip, @@ -29,8 +33,18 @@ import { Calendar, Check, Download, + Edit3, + MoreVertical, + Pause, } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + // API client for Gemini AI communication import { callGemini } from "@/lib/geminiClient"; @@ -76,6 +90,8 @@ export default function DijkstraGPT() { const [copiedMessageId, setCopiedMessageId] = useState(null); const [apiStatus, setApiStatus] = useState<'checking' | 'active' | 'inactive'>('checking'); const [hasMessages, setHasMessages] = useState(false); + const [editingSessionId, setEditingSessionId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); // ============================================ // REFS FOR DOM ELEMENTS @@ -85,6 +101,9 @@ export default function DijkstraGPT() { const fileInputRef = useRef(null); const messagesEndRef = useRef(null); + // cancel flag for streaming + const cancelGenerationRef = useRef(false); + // ============================================ // COMPUTED VALUES @@ -105,13 +124,11 @@ export default function DijkstraGPT() { } }, [prompt]); - // Auto-scroll to bottom when new messages arrive (scrolling parent container) + // Auto-scroll to bottom when new messages arrive useEffect(() => { if (messagesEndRef.current && hasMessages) { - // Find the scrollable parent container from page.tsx 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); @@ -122,17 +139,14 @@ export default function DijkstraGPT() { parent = parent.parentElement; } } - if (scrollContainer) { - // Use setTimeout to ensure DOM is updated setTimeout(() => { scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight, - behavior: "smooth" + behavior: "smooth", }); }, 100); } else { - // Fallback to direct scrollIntoView setTimeout(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, 100); @@ -179,6 +193,8 @@ export default function DijkstraGPT() { setCurrentSessionId(newSession.id); setPrompt(""); setUploadedFiles([]); + setIsLoading(false); + cancelGenerationRef.current = false; toast.success("New chat created"); }; @@ -203,6 +219,8 @@ export default function DijkstraGPT() { }, []); const deleteSession = (sessionId: string): void => { + if (!confirm("Are you sure you want to delete this chat?")) return; + setChatSessions((prev) => prev.filter((s) => s.id !== sessionId)); if (currentSessionId === sessionId) { @@ -213,6 +231,53 @@ export default function DijkstraGPT() { toast.success("Chat deleted"); }; + const renameSession = (sessionId: string): void => { + const session = chatSessions.find((s) => s.id === sessionId); + if (session) { + setEditingSessionId(sessionId); + setEditingTitle(session.title); + } + }; + + const saveRename = (): void => { + if (editingSessionId && editingTitle.trim()) { + setChatSessions((prev) => + prev.map((session) => + session.id === editingSessionId + ? { ...session, title: editingTitle.trim(), updatedAt: new Date() } + : session + ) + ); + toast.success("Chat renamed"); + } + setEditingSessionId(null); + setEditingTitle(""); + }; + + 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 { + if (navigator.share) { + await navigator.share({ + title: session.title, + text: shareText, + }); + toast.success("Chat shared"); + } else { + await navigator.clipboard.writeText(shareText); + toast.success("Chat copied to clipboard"); + } + } catch (error) { + console.log("Share cancelled or failed:", error); + } + }; + const downloadSession = (session: ChatSession): void => { const dataStr = JSON.stringify(session, null, 2); const dataBlob = new Blob([dataStr], { type: "application/json" }); @@ -335,7 +400,7 @@ export default function DijkstraGPT() { try { const res: any = await callGemini(promptText); - // If it's a fetch Response-like object with a ReadableStream body + // ReadableStream (true streaming) if (res && res.body && typeof res.body.getReader === "function") { const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -343,6 +408,15 @@ export default function DijkstraGPT() { let accumulated = ""; while (!done) { + if (cancelGenerationRef.current) { + updateAssistantContent( + sessionId, + assistantMessageId, + accumulated || "⚠ Generation stopped by user." + ); + return accumulated; + } + // eslint-disable-next-line no-await-in-loop const { value, done: d } = await reader.read(); if (value) { @@ -352,33 +426,46 @@ export default function DijkstraGPT() { done = !!d; } - // finalize (in case any leftovers) updateAssistantContent(sessionId, assistantMessageId, accumulated); return accumulated; } - // If callGemini returned a string, simulate typing + // Simple string 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) { + if (cancelGenerationRef.current) { + updateAssistantContent( + sessionId, + assistantMessageId, + full.slice(0, i) || "⚠ Generation stopped by user." + ); + return full.slice(0, i); + } // 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 + i += Math.ceil(Math.random() * 3); const chunk = full.slice(0, i); updateAssistantContent(sessionId, assistantMessageId, chunk); } - updateAssistantContent(sessionId, assistantMessageId, full); return full; } - // If the response shape is unknown but contains text field + // Object with `.text` if (res && typeof res === "object" && typeof res.text === "string") { const text = res.text; let i = 0; while (i <= text.length) { + if (cancelGenerationRef.current) { + updateAssistantContent( + sessionId, + assistantMessageId, + text.slice(0, i) || "⚠ Generation stopped by user." + ); + return text.slice(0, i); + } // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 12)); i += Math.ceil(Math.random() * 4); @@ -388,41 +475,70 @@ export default function DijkstraGPT() { return text; } - // Unknown shape -> convert to string and show const fallback = String(res); updateAssistantContent(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 handleRegenerate = async (assistantMessageId: string): Promise => { + const session = chatSessions.find((s) => s.id === currentSessionId); + if (!session) return; - try { - // create placeholder assistant message - const assistantMessage: Message = { - id: (Date.now() + 2).toString(), - role: "assistant", - content: "", - timestamp: new Date(), - }; + const idx = session.messages.findIndex((m) => m.id === assistantMessageId); + if (idx <= 0) return; - addMessage(assistantMessage); + const userMsg = session.messages[idx - 1]; + if (!userMsg || userMsg.role !== "user") return; - await streamGeminiResponse(userMessage.content, currentSessionId!, assistantMessage.id); + // Remove old assistant message + setChatSessions((prev) => + prev.map((s) => + s.id === currentSessionId + ? { ...s, messages: s.messages.filter((m) => m.id !== assistantMessageId) } + : s + ) + ); + cancelGenerationRef.current = false; + setIsLoading(true); + + // New assistant bubble + const newAssistantMessage: Message = { + id: (Date.now() + 2).toString(), + role: "assistant", + content: "", + timestamp: new Date(), + }; + addMessage(newAssistantMessage); + + try { + await streamGeminiResponse(userMsg.content, currentSessionId!, newAssistantMessage.id); 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); } }; + const handleStopGeneration = () => { + if (!isLoading) return; + cancelGenerationRef.current = true; + setIsLoading(false); + toast.info("Generation stopped"); + }; + const shareMessage = async (content: string): Promise => { try { if (navigator.share) { @@ -447,6 +563,8 @@ export default function DijkstraGPT() { const handleSubmit = async (): Promise => { if (!prompt.trim() && uploadedFiles.length === 0) return; + cancelGenerationRef.current = false; + // Trigger layout transition immediately setHasMessages(true); @@ -488,15 +606,17 @@ export default function DijkstraGPT() { try { await streamGeminiResponse(currentPrompt, currentSessionId!, assistantMessage.id); } 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(), + }; + // replace the placeholder content with the error + updateAssistantContent(currentSessionId, assistantMessage.id, errorMessage.content); + toast.error("Failed to get response"); + } } finally { setIsLoading(false); } @@ -536,11 +656,16 @@ export default function DijkstraGPT() { const isUser = msg.role === "user"; const isCopied = copiedMessageId === msg.id; - // Split content by code blocks (```) and preserve formatting - const parts = msg.content.split(/```/g); + const isLastMessage = i === messages.length - 1; + const shouldShowThinkingPlaceholder = + !isUser && isLastMessage && isLoading && msg.content.trim() === ""; + + const displayContent = shouldShowThinkingPlaceholder + ? "DijkstraGPT is thinking ..." + : msg.content || ""; return ( -
+
)} - {/* Message content with code block formatting */} + {/* Message content with markdown rendering */}
- {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
    ; - })} -
    - ) - )} +
    + {displayContent} +
    {/* Timestamp */}

    {msg.timestamp.toLocaleTimeString()}

    - {/* Action buttons for assistant messages - ICONS ONLY */} + {/* Action buttons for assistant messages - hover only */} {!isUser && ( -
    - {/* Copy button - Icon only */} +
    + {/* Copy button */} - {/* Regenerate button - Icon only */} + {/* Regenerate button */} - {/* Share button - Icon only */} + {/* Share button */} @@ -739,25 +834,33 @@ export default function DijkstraGPT() { }} className="h-9 w-9 p-0 rounded-xl hover:bg-muted text-muted-foreground hover:text-foreground transition-all duration-200" aria-label="Upload image" + disabled={isLoading} >
    - {/* Right side - Send button */} - + ) : ( + + Send + + )}
    @@ -834,28 +937,10 @@ export default function DijkstraGPT() { ) : ( -
    - {/* ==================== MESSAGES VIEW ==================== */} -
    - {/* Render all messages */} + // Messages view – scrollable +
    +
    {messages.map((m, i) => renderMessage(m, i))} - - {/* Loading indicator */} - {isLoading && ( -
    -
    -
    -
    -
    -
    -
    -
    -

    Thinking...

    -
    -
    -
    - )} -
    @@ -968,7 +1053,7 @@ export default function DijkstraGPT() { }} >
    -
    +
    {/* Session info */}
    -
    -

    - {session.title} -

    -

    - {session.messages.length} messages • {session.updatedAt.toLocaleDateString()} -

    +
    + {editingSessionId === session.id ? ( + setEditingTitle(e.target.value)} + onBlur={saveRename} + onKeyDown={(e) => { + if (e.key === "Enter") saveRename(); + if (e.key === "Escape") { + setEditingSessionId(null); + setEditingTitle(""); + } + }} + onClick={(e) => e.stopPropagation()} + autoFocus + className="w-full text-sm font-medium bg-background border border-border/50 rounded px-2 py-1" + /> + ) : ( + <> +

    + {session.title} +

    +

    + {session.messages.length} messages • {session.updatedAt.toLocaleDateString()} +

    + + )}
    - {/* Action buttons - Download and Delete */} -
    - {/* Download button */} - - - {/* Delete button */} - -
    + {/* Action buttons - Dropdown menu */} + + + + + + { + e.stopPropagation(); + renameSession(session.id); + }} + > + Rename + + { + e.stopPropagation(); + shareSession(session.id); + }} + > + Share + + { + e.stopPropagation(); + downloadSession(session); + }} + > + Download + + { + e.stopPropagation(); + deleteSession(session.id); + }} + > + Delete + + +
    diff --git a/package-lock.json b/package-lock.json index 35b76de..947f61b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "react-markdown": "^10.1.0", "react-tooltip": "^5.29.1", "recharts": "^3.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", @@ -9004,9 +9005,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 80bba09..7c318f3 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "react-markdown": "^10.1.0", "react-tooltip": "^5.29.1", "recharts": "^3.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7",