diff --git a/src/app/api/workspaces/generate-title/route.ts b/src/app/api/workspaces/generate-title/route.ts index 23079db6..97c5e29b 100644 --- a/src/app/api/workspaces/generate-title/route.ts +++ b/src/app/api/workspaces/generate-title/route.ts @@ -67,7 +67,7 @@ async function handlePOST(request: NextRequest) { } const result = await generateObject({ - model: google("gemini-2.5-flash-lite"), + model: google("gemini-flash-lite-latest"), schema: z.object({ title: z.string().describe("A short, concise workspace title (max 5-6 words)"), icon: z.string().describe("A HeroIcon name that represents the topic (must be one of the available icons)"), diff --git a/src/components/assistant-ui/AssistantPanel.tsx b/src/components/assistant-ui/AssistantPanel.tsx index 2f53bafa..abe50fbd 100644 --- a/src/components/assistant-ui/AssistantPanel.tsx +++ b/src/components/assistant-ui/AssistantPanel.tsx @@ -82,6 +82,11 @@ function WorkspaceContextWrapper({ isLoading={isLoading} setIsChatExpanded={setIsChatExpanded} /> + void; +}) { + const searchParams = useSearchParams(); + const router = useRouter(); + const aui = useAui(); + const hasAutoSentRef = useRef(false); + const timeoutIdsRef = useRef[]>([]); + + const action = searchParams.get("action"); + + useEffect(() => { + if (action !== "generate_study_materials" || !workspaceId || isLoading || hasAutoSentRef.current) return; + + setIsChatExpanded?.(true); + + const prompt = `First, process the PDF file in this workspace to read its content. + +Then, using that PDF content, update all three study cards: +1. Update "Summary Notes" with a comprehensive summary +2. Update "Quiz" with 5-10 relevant questions +3. Update "Flashcards" with key terms and concepts + +Process the PDF once, then use that same content for all three updates.`; + + let attempts = 0; + const maxAttempts = 12; + const intervalMs = 200; + const ids = timeoutIdsRef.current; + + const clearAll = () => { + ids.forEach((tid) => clearTimeout(tid)); + ids.length = 0; + }; + + const trySend = () => { + attempts += 1; + const composer = aui?.composer?.(); + if (composer) { + try { + composer.setText(prompt); + composer.send(); + hasAutoSentRef.current = true; + clearAll(); + const url = new URL(window.location.href); + url.searchParams.delete("action"); + router.replace(url.pathname + url.search); + return; + } catch { + // fall through to retry + } + } + if (attempts < maxAttempts) { + const id = setTimeout(trySend, intervalMs); + ids.push(id); + } + }; + + const id = setTimeout(trySend, 100); + ids.push(id); + + return () => clearAll(); + }, [action, workspaceId, isLoading, aui, router, setIsChatExpanded]); + + return null; +} + function WorkspaceContextWrapperContent({ workspaceId, setIsChatExpanded, diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index be321bee..e93cc264 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -132,7 +132,7 @@ export function HomeContent() { {/* Hero Section - Reduced height so "Recent workspaces" text peeks at bottom */}
-
+
{/* Hero Glow Effect */} diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index e7d8f8f7..9597d45e 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -4,10 +4,28 @@ import { useState, useRef, useMemo, useEffect } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { useCreateWorkspaceFromPrompt } from "@/hooks/workspace/use-create-workspace"; -import { ArrowUp, Loader2 } from "lucide-react"; +import { usePdfUpload } from "@/hooks/workspace/use-pdf-upload"; +import { ArrowUp, FileText, Loader2, Plus, Upload, X, Link as LinkIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import TypingText from "@/components/ui/typing-text"; +import { useDropzone } from "react-dropzone"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import type { PdfData } from "@/lib/workspace-state/types"; const PLACEHOLDER_OPTIONS = [ "Calc 3 double integrals", @@ -46,8 +64,6 @@ const PLACEHOLDER_OPTIONS = [ "physics kinematics problems", ]; -const baseText = "Create a workspace for "; - interface HomePromptInputProps { shouldFocus?: boolean; } @@ -55,10 +71,43 @@ interface HomePromptInputProps { export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const router = useRouter(); const [value, setValue] = useState(""); - const inputRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false); + const [urlInput, setUrlInput] = useState(""); + const [textIndent, setTextIndent] = useState(180); // Default fallback + const inputRef = useRef(null); + const prefixRef = useRef(null); const typingKeyRef = useRef(0); - + const createFromPrompt = useCreateWorkspaceFromPrompt(); + const { uploadFiles, uploadedFiles, isUploading, removeFile, clearFiles } = usePdfUpload(); + + // Setup drop zone for PDF files + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + accept: { + 'application/pdf': ['.pdf'], + }, + multiple: true, + noClick: true, // Don't open file dialog on click (only on drag) + onDrop: async (acceptedFiles) => { + if (acceptedFiles.length > 0) { + // Auto-populate input immediately + const totalFiles = uploadedFiles.length + acceptedFiles.length; + if (totalFiles === 1) { + setValue("this pdf"); + } else if (totalFiles > 1) { + setValue("these pdfs"); + } + + try { + await uploadFiles(acceptedFiles); + toast.success(`Uploaded ${acceptedFiles.length} PDF${acceptedFiles.length > 1 ? 's' : ''}`); + } catch (error) { + toast.error("Failed to upload PDFs"); + } + } + }, + }); // Shuffle options with random start for variety const shuffledOptions = useMemo(() => { @@ -79,10 +128,36 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { } }, [shouldFocus]); - // Handle user typing - stop animation - const handleInput = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setValue(newValue); + // Dynamic width measurement for perfect alignment + // using useLayoutEffect to prevent layout shift flash if possible, or useEffect + useEffect(() => { + if (prefixRef.current) { + // Measure width of static text + const width = prefixRef.current.offsetWidth; + // Add roughly one space width (approx 4-5px for typical font, but let's say 6px to be safe) + setTextIndent(width + 6); + } + }, []); + + // Handle user typing + const handleInput = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + // Auto-resize effect + useEffect(() => { + const textarea = inputRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + setIsExpanded(textarea.scrollHeight > 80); + } + }, [value]); + + const handleScroll = (e: React.UIEvent) => { + if (prefixRef.current) { + prefixRef.current.style.transform = `translateY(-${e.currentTarget.scrollTop}px)`; + } }; const handleSubmit = async (e: React.FormEvent) => { @@ -90,14 +165,111 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const prompt = value.trim(); if (!prompt || createFromPrompt.isLoading) return; + // Construct initial state with PDF cards AND empty placeholder cards if files were uploaded + let initialState = undefined; + if (uploadedFiles.length > 0) { + // Calculate total PDF height (each PDF takes 10 rows) + const pdfHeight = 10; + const totalPdfY = uploadedFiles.length * pdfHeight; + + // Create PDF card items from uploaded files (stacked vertically at top) + const pdfItems = uploadedFiles.map((file, index) => ({ + id: crypto.randomUUID(), + type: 'pdf' as const, + name: file.name, + subtitle: '', + color: '#6366F1' as const, // Indigo for PDFs + layout: { x: 0, y: index * pdfHeight, w: 4, h: pdfHeight }, + lastSource: 'user' as const, + data: { + fileUrl: file.fileUrl, + filename: file.filename, + fileSize: file.fileSize, + } as PdfData, + })); + + // Create empty placeholder cards with fixed layout and colors + const noteId = crypto.randomUUID(); + const quizId = crypto.randomUUID(); + const flashcardId = crypto.randomUUID(); + + const emptyNote = { + id: noteId, + type: 'note' as const, + name: 'Summary Notes', + subtitle: 'AI will fill this from your PDFs', + color: '#10B981' as const, // Emerald for Note + layout: { x: 0, y: totalPdfY, w: 4, h: 13 }, + lastSource: 'user' as const, + data: { + blockContent: [ + { + id: 'placeholder-block', + type: 'paragraph', + props: { backgroundColor: 'default', textColor: 'default', textAlignment: 'left' }, + content: [], + children: [], + }, + ], + field1: '', + }, + }; + + const emptyQuiz = { + id: quizId, + type: 'quiz' as const, + name: 'Quiz', + subtitle: 'AI will generate questions from your PDFs', + color: '#F59E0B' as const, // Amber for Quiz + layout: { x: 0, y: totalPdfY + 13, w: 2, h: 13 }, + lastSource: 'user' as const, + data: { + questions: [], + }, + }; + + const emptyFlashcard = { + id: flashcardId, + type: 'flashcard' as const, + name: 'Flashcards', + subtitle: 'AI will create study cards from your PDFs', + color: '#EC4899' as const, // Pink for Flashcards + layout: { x: 2, y: totalPdfY + 13, w: 2, h: 8 }, + lastSource: 'user' as const, + data: { + cards: [], + }, + }; + + const allItems = [...pdfItems, emptyNote, emptyQuiz, emptyFlashcard]; + + initialState = { + workspaceId: '', // Will be set by backend + globalTitle: '', + globalDescription: '', + items: allItems, + itemsCreated: allItems.length, + }; + } + createFromPrompt.mutate(prompt, { - template: "getting_started", // Auto-include sample content (quiz/flashcards) for home prompt (magic feeling) + template: uploadedFiles.length > 0 ? "blank" : "getting_started", // Use blank template if PDFs provided + initialState, onSuccess: (workspace) => { // Reset typing animation by changing key typingKeyRef.current += 1; - router.push( - `/workspace/${workspace.slug}?createFrom=${encodeURIComponent(prompt)}` - ); + // Clear uploaded files + clearFiles(); + const url = `/workspace/${workspace.slug}`; + const params = new URLSearchParams(); + + if (uploadedFiles.length > 0) { + params.set('action', 'generate_study_materials'); + } else { + params.set('createFrom', prompt); + } + + router.push(`${url}?${params.toString()}`); }, onError: (err) => { toast.error("Could not create workspace", { description: err.message }); @@ -105,61 +277,123 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { }); }; + const handleUrlSubmit = () => { + if (!urlInput.trim()) return; + + // Append URL to current value, adding a space if needed + const newValue = value + (value && !value.endsWith(' ') ? ' ' : '') + urlInput.trim() + ' '; + setValue(newValue); + setUrlInput(""); + setIsUrlDialogOpen(false); + + // Focus back on input + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }; + return ( -
-
+ +
+ + + {/* Drag overlay */} + {isDragActive && ( +
+
+ +

Drop PDFs here

+
+
+ )} + + {/* Uploaded files display */} + {uploadedFiles.length > 0 && ( +
+ {uploadedFiles.map((file) => ( +
+ + {file.name} + +
+ ))} +
+ )} + {/* Input container styled to look like one input */}
inputRef.current?.focus()} className={cn( - "relative flex items-center gap-0 min-h-[56px] w-full", - "bg-background/80 backdrop-blur-xl", - "border border-white/10 rounded-xl", - "shadow-[0_0_60px_-15px_rgba(255,255,255,0.1)]", - "focus-within:shadow-[0_0_80px_-10px_rgba(255,255,255,0.15)]", - "focus-within:border-white/60", - "transition-all duration-300", + "relative w-full min-h-[96px]", + isExpanded ? "rounded-[24px]" : "rounded-[32px]", + "border border-white/25", + "bg-sidebar backdrop-blur-xl", + "px-4 py-2 md:px-6 md:py-3", + "shadow-[0_24px_90px_-40px_rgba(0,0,0,0.85)]", + "focus-within:border-white/40", + "transition-[border-radius,height] duration-300 ease-in-out", "cursor-text" )} > - {/* Prefix label */} - - {baseText} - +
+ {/* Static Prefix - positioned absolutely but matched with text-indent */} + {/* Static Prefix - positioned absolutely but matched with text-indent */} + + Create a workspace on + - {/* Input field */} -
- { if (e.key === "Enter" && !e.shiftKey) { @@ -167,21 +401,19 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { handleSubmit(e); } }} + onScroll={handleScroll} /> {/* Typing placeholder - dimmer for contrast with white prefix */} {!value && (
)} +
- {/* Submit arrow */} - +
+ + + + + + open()}> + + Upload PDF + + setIsUrlDialogOpen(true)}> + + Add URL + + + + +
+ + + + + +
+ + + + + Add Link + +
+ setUrlInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleUrlSubmit(); + } + }} + autoFocus + /> +
+ + + + +
+
); } diff --git a/src/hooks/workspace/use-create-workspace.ts b/src/hooks/workspace/use-create-workspace.ts index 4f81cbd6..a0ffdbe9 100644 --- a/src/hooks/workspace/use-create-workspace.ts +++ b/src/hooks/workspace/use-create-workspace.ts @@ -140,6 +140,7 @@ export function useCreateWorkspaceFromPrompt() { prompt: string, options?: { template?: WorkspaceTemplate; + initialState?: AgentState; onSuccess?: (workspace: CreateWorkspaceResponse["workspace"]) => void; onError?: (error: Error) => void; } @@ -154,6 +155,7 @@ export function useCreateWorkspaceFromPrompt() { icon: icon || null, color: (color as CardColor) || null, template: options?.template || "getting_started", + initialState: options?.initialState, }); options?.onSuccess?.(result.workspace); diff --git a/src/hooks/workspace/use-pdf-upload.ts b/src/hooks/workspace/use-pdf-upload.ts new file mode 100644 index 00000000..244a8ba8 --- /dev/null +++ b/src/hooks/workspace/use-pdf-upload.ts @@ -0,0 +1,88 @@ +import { useState, useCallback } from "react"; +import type { PdfData } from "@/lib/workspace-state/types"; + +export interface UploadedPdfMetadata { + fileUrl: string; + filename: string; + fileSize: number; + name: string; +} + +export interface PdfUploadState { + isUploading: boolean; + error: Error | null; + uploadedFiles: UploadedPdfMetadata[]; +} + +/** + * Hook for uploading PDF files to Supabase storage + * Consolidates upload logic used across thread.tsx and WorkspaceSection.tsx + */ +export function usePdfUpload() { + const [state, setState] = useState({ + isUploading: false, + error: null, + uploadedFiles: [], + }); + + const uploadFiles = useCallback(async (files: File[]): Promise => { + setState((prev) => ({ ...prev, isUploading: true, error: null })); + + try { + const uploadPromises = files.map(async (file) => { + const formData = new FormData(); + formData.append("file", file); + + const uploadResponse = await fetch("/api/upload-file", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`Failed to upload PDF: ${uploadResponse.statusText}`); + } + + const { url: fileUrl, filename } = await uploadResponse.json(); + + return { + fileUrl, + filename: filename || file.name, + fileSize: file.size, + name: file.name.replace(/\.pdf$/i, ""), + }; + }); + + const uploadResults = await Promise.all(uploadPromises); + + setState((prev) => ({ + ...prev, + isUploading: false, + uploadedFiles: [...prev.uploadedFiles, ...uploadResults], + })); + + return uploadResults; + } catch (error) { + const err = error instanceof Error ? error : new Error("Failed to upload PDFs"); + setState((prev) => ({ ...prev, isUploading: false, error: err })); + throw err; + } + }, []); + + const clearFiles = useCallback(() => { + setState({ isUploading: false, error: null, uploadedFiles: [] }); + }, []); + + const removeFile = useCallback((fileUrl: string) => { + setState((prev) => ({ + ...prev, + uploadedFiles: prev.uploadedFiles.filter((f) => f.fileUrl !== fileUrl), + })); + }, []); + + return { + ...state, + uploadFiles, + clearFiles, + removeFile, + }; +} diff --git a/src/lib/ai/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index abf70660..2ed1e964 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -75,7 +75,7 @@ export function createWebSearchTool() { execute: async ({ query }) => { // Use a lightweight model for the internal search loop const { text, providerMetadata } = await generateText({ - model: google('gemini-2.5-flash-lite'), + model: google('gemini-flash-lite-latest'), tools: { googleSearch: google.tools.googleSearch({}), }, diff --git a/src/lib/ai/workers/quiz-worker.ts b/src/lib/ai/workers/quiz-worker.ts index 56ae251b..f7e77aeb 100644 --- a/src/lib/ai/workers/quiz-worker.ts +++ b/src/lib/ai/workers/quiz-worker.ts @@ -5,7 +5,7 @@ import { logger } from "@/lib/utils/logger"; import { QuizQuestion, QuestionType } from "@/lib/workspace-state/types"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -const DEFAULT_CHAT_MODEL_ID = "gemini-2.5-flash-lite"; +const DEFAULT_CHAT_MODEL_ID = "gemini-flash-lite-latest"; export type QuizWorkerParams = { topic?: string; // Used only if no context provided