From 09ba634d9bdc6389c219aa3f505babb9313fa078 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 03:22:37 -0500 Subject: [PATCH 1/5] changed 'for' to 'on' --- src/components/home/HomePromptInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index e7d8f8f7..c96d4260 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -46,7 +46,7 @@ const PLACEHOLDER_OPTIONS = [ "physics kinematics problems", ]; -const baseText = "Create a workspace for "; +const baseText = "Create a workspace on "; interface HomePromptInputProps { shouldFocus?: boolean; @@ -57,7 +57,7 @@ export function HomePromptInput({ shouldFocus }: HomePromptInputProps) { const [value, setValue] = useState(""); const inputRef = useRef(null); const typingKeyRef = useRef(0); - + const createFromPrompt = useCreateWorkspaceFromPrompt(); // Shuffle options with random start for variety From 398edded1855c1d92fa671f017f26d534a5081e5 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 04:24:36 -0500 Subject: [PATCH 2/5] drag and drop image --- src/app/api/upload-file/route.ts | 14 +- .../workspace-canvas/CardRenderer.tsx | 7 +- .../workspace-canvas/ImageCardContent.tsx | 37 ++++ .../WorkspaceCanvasDropzone.tsx | 159 +++++++++++++++--- .../workspace-canvas/WorkspaceCard.tsx | 25 ++- .../workspace-canvas/WorkspaceGrid.tsx | 34 ++-- .../workspace/use-workspace-operations.ts | 69 ++++++-- src/lib/workspace-state/aspect-ratios.ts | 118 +++++++++++++ .../workspace-state/grid-layout-helpers.ts | 24 ++- src/lib/workspace-state/item-helpers.ts | 4 +- src/lib/workspace-state/types.ts | 10 +- 11 files changed, 425 insertions(+), 76 deletions(-) create mode 100644 src/components/workspace-canvas/ImageCardContent.tsx create mode 100644 src/lib/workspace-state/aspect-ratios.ts diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts index 2d24d94f..10509446 100644 --- a/src/app/api/upload-file/route.ts +++ b/src/app/api/upload-file/route.ts @@ -17,7 +17,7 @@ const getStorageType = (): 'supabase' | 'local' => { // Local file storage helper async function saveFileLocally(file: File, filename: string): Promise { const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), 'uploads'); - + // Ensure uploads directory exists if (!existsSync(uploadsDir)) { await mkdir(uploadsDir, { recursive: true }); @@ -28,7 +28,7 @@ async function saveFileLocally(file: File, filename: string): Promise { const buffer = Buffer.from(bytes); await writeFile(filePath, buffer); - + // Return public URL path const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; return `${baseUrl}/api/files/${filename}`; @@ -40,14 +40,14 @@ export async function POST(request: NextRequest) { const session = await auth.api.getSession({ headers: await headers(), }); - + if (!session) { return NextResponse.json( { error: "Unauthorized" }, { status: 401 } ); } - + const userId = session.user.id; // Get file from form data @@ -77,7 +77,9 @@ export async function POST(request: NextRequest) { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 15); const originalName = file.name; - const filename = `${timestamp}-${random}-${originalName}`; + // Sanitize filename: remove spaces and special chars, keep only alphanumeric, dots, hyphens, underscores + const sanitizedName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); + const filename = `${timestamp}-${random}-${sanitizedName}`; const storageType = getStorageType(); let publicUrl: string; @@ -146,7 +148,7 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('Error in upload-file API route:', error); return NextResponse.json( - { + { error: "Failed to upload file", details: error instanceof Error ? error.message : String(error) }, diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index cef05690..b337bd8c 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -1,12 +1,13 @@ "use client"; import { useUIStore } from "@/lib/stores/ui-store"; -import type { Item, ItemData, NoteData, PdfData, FlashcardData, YouTubeData } from "@/lib/workspace-state/types"; +import type { Item, ItemData, NoteData, PdfData, FlashcardData, YouTubeData, ImageData } from "@/lib/workspace-state/types"; import { useMemo, useState } from "react"; import { DynamicBlockNoteEditor } from "@/components/editor/DynamicBlockNoteEditor"; import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEditor"; import FlashcardContent from "./FlashcardContent"; import YouTubeCardContent from "./YouTubeCardContent"; +import ImageCardContent from "./ImageCardContent"; import { QuizContent } from "./QuizContent"; @@ -100,6 +101,10 @@ export function CardRenderer(props: { ); } + if (item.type === "image") { + return ; + } + return (

diff --git a/src/components/workspace-canvas/ImageCardContent.tsx b/src/components/workspace-canvas/ImageCardContent.tsx new file mode 100644 index 00000000..3e7df2dd --- /dev/null +++ b/src/components/workspace-canvas/ImageCardContent.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; +import type { Item, ImageData } from "@/lib/workspace-state/types"; + +interface ImageCardContentProps { + item: Item; +} + +export function ImageCardContent({ item }: ImageCardContentProps) { + const imageData = item.data as ImageData; + const [isHovering, setIsHovering] = useState(false); + + return ( +

setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {imageData.altText + + {/* Optional: Caption overlay on hover */} + {imageData.caption && isHovering && ( +
+ {imageData.caption} +
+ )} +
+ ); +} + +export default ImageCardContent; diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 0786f6c2..661f504c 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -7,7 +7,8 @@ import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operatio import { FileText } from "lucide-react"; import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; -import type { PdfData } from "@/lib/workspace-state/types"; +import type { PdfData, ImageData } from "@/lib/workspace-state/types"; +import { getBestFrameForRatio, type GridFrame } from "@/lib/workspace-state/aspect-ratios"; interface WorkspaceCanvasDropzoneProps { children: React.ReactNode; @@ -15,7 +16,7 @@ interface WorkspaceCanvasDropzoneProps { /** * Dropzone component specifically for the workspace canvas area. - * Only accepts PDFs and creates PDF cards in the workspace when dropped. + * Accepts PDFs and images and creates corresponding cards in the workspace when dropped. */ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzoneProps) { const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); @@ -65,7 +66,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro // Check file count limit if (acceptedFiles.length > MAX_FILES) { - toast.error(`You can only upload up to ${MAX_FILES} PDFs at once. You dropped ${acceptedFiles.length} files.`, { + toast.error(`You can only upload up to ${MAX_FILES} files at once. You dropped ${acceptedFiles.length} files.`, { style: { color: '#fff' }, duration: 5000, }); @@ -117,7 +118,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro // Show loading toast const loadingToastId = toast.loading( - `Uploading ${validFiles.length} PDF${validFiles.length > 1 ? 's' : ''}...`, + `Uploading ${validFiles.length} file${validFiles.length > 1 ? 's' : ''}...`, { style: { color: '#fff' }, } @@ -152,27 +153,133 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro toast.dismiss(loadingToastId); if (validResults.length > 0) { - // Collect all PDF card data and create in a single batch event - const pdfCardDefinitions = validResults.map((result) => { - const pdfData: Partial = { - fileUrl: result.fileUrl, - filename: result.filename, - fileSize: result.fileSize, - }; + // Separate files by type + const pdfResults: typeof validResults = []; + const imageResults: typeof validResults = []; + + validResults.forEach((result, index) => { + const file = validFiles[index]; + if (file.type === 'application/pdf') { + pdfResults.push(result); + } else { + imageResults.push(result); + } + }); - return { - type: 'pdf' as const, - name: result.name, - initialData: pdfData, + // Create PDF cards + if (pdfResults.length > 0) { + const pdfCardDefinitions = pdfResults.map((result) => { + const pdfData: Partial = { + fileUrl: result.fileUrl, + filename: result.filename, + fileSize: result.fileSize, + }; + + return { + type: 'pdf' as const, + name: result.name, + initialData: pdfData, + }; + }); + + operations.createItems(pdfCardDefinitions); + } + + // Create image cards with aspect ratio detection + if (imageResults.length > 0) { + // Helper to get image dimensions + const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); + img.onerror = reject; + img.src = url; + }); }; - }); - // Create all PDF cards atomically in a single event - operations.createItems(pdfCardDefinitions); + // Process images to get dimensions before creating cards + const imageDefinitionsPromises = imageResults.map(async (result) => { + const imageData: Partial = { + url: result.fileUrl, + altText: result.name, + }; + + let layout = undefined; + + try { + // Get dimensions to determine aspect ratio + const { width, height } = await getImageDimensions(result.fileUrl); + // Use the small frame (w:2) for initial drops, relying on resize logic to scale up if needed + const bestFrame = getBestFrameForRatio(width, height); + + // Construct layout object + if (bestFrame) { + // Create standard RGL layout object + // Note: We only set the 'lg' breakpoint here. + // The system will handle the responsive mapping. + // However, createItems/createItem doesn't accept a layout object directly in its simplified signature. + // We might need to rely on the default size if we can't pass layout. + + // Actually, createItems takes `initialData`. + // The `useWorkspaceOperations.createItems` implementation might need adjustment to accept `layout`. + // But wait, the `Item` type has a `layout` property. + + // Let's modify the return type here to match what createItems expects. + // createItems takes `Partial[]` effectively (name, type, initialData). + // If we need to pass layout, we'll need to check if createItems supports it. + + // Looking at `use-workspace-operations.ts` (from memory): + // createItems(items: { type: CardType; name?: string; initialData?: any }[]) + // It doesn't seem to support passing layout directly in the current definition. + + // Workaround: We can't easily pass layout without modifying `createItems`. + // BUT, we defined default dimensions in `grid-layout-helpers.ts`. + // To do "adaptive" sizing per item, we really need custom layout support. + + // Let's assume for now we will modify createItems or use a workaround. + // Or actually, `createItems` might accept extra properties. + // Let's check `use-workspace-operations.ts` content later if this fails. + // For now, I will assume we can pass `layout` or `w`/`h` if I modify the type def there. + + // Actually, a safer bet is to just let them be created with defaults (4x10) + // and then immediately update them? That's glitchy. + + // Wait, I can't modify `createItems` easily right now without checking it. + // Let's look at `use-workspace-operations` again. + + // WAIT! I already checked `use-workspace-operations.ts` earlier. + // It takes `definitions: { type: CardType; name?: string; initialData?: Partial }[]`. + // It constructs the item using `createItem` logic usually. + + // I will pass `initialLayout` property in the definition and update `use-workspace-operations` to use it. + return { + type: 'image' as const, + name: result.name, + initialData: imageData, + initialLayout: { w: bestFrame.w, h: bestFrame.h } // Passing custom property + }; + } + } catch (e) { + console.error("Failed to load image for dimensions:", e); + } + + return { + type: 'image' as const, + name: result.name, + initialData: imageData, + }; + }); + + const imageCardDefinitions = await Promise.all(imageDefinitionsPromises); + + // @ts-ignore - We are passing extra 'initialLayout' that we'll handle in createItems + operations.createItems(imageCardDefinitions); + } // Show success toast + const totalCreated = validResults.length; toast.success( - `${validResults.length} PDF card${validResults.length > 1 ? 's' : ''} created successfully`, + `${totalCreated} card${totalCreated > 1 ? 's' : ''} created successfully`, { style: { color: '#fff' }, } @@ -182,7 +289,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro // Show error if some files failed to upload const failedCount = validFiles.length - validResults.length; if (failedCount > 0) { - toast.error(`Failed to upload ${failedCount} PDF${failedCount > 1 ? 's' : ''}`, { + toast.error(`Failed to upload ${failedCount} file${failedCount > 1 ? 's' : ''}`, { style: { color: '#fff' }, duration: 5000, }); @@ -215,7 +322,11 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro noKeyboard: true, // Don't trigger on keyboard disabled: !currentWorkspaceId, // Disable if no workspace is selected accept: { - 'application/pdf': ['.pdf'], // Only accept PDFs + 'application/pdf': ['.pdf'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/gif': ['.gif'], + 'image/webp': ['.webp'], }, onDragEnter: () => setIsDragging(true), onDragLeave: () => { @@ -233,7 +344,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro if (fileRejections.length > 0) { const rejectedFileNames = fileRejections.map(rejection => rejection.file.name); toast.error( - `Only PDF files can be dropped into the workspace.\nRejected: ${rejectedFileNames.join(', ')}`, + `Only PDF and image files (PNG, JPG, GIF, WebP) can be dropped.\nRejected: ${rejectedFileNames.join(', ')}`, { style: { color: '#fff' }, duration: 5000, @@ -256,10 +367,10 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro

- Create PDF Card + Create Card

- Drop PDF files here to create cards in your workspace + Drop PDF or image files here to create cards

diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 4b059dd3..e62b1e6c 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,4 +1,5 @@ import { QuizContent } from "./QuizContent"; +import { ImageCardContent } from "./ImageCardContent"; import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns } from "lucide-react"; import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react"; @@ -6,7 +7,7 @@ import { toast } from "sonner"; import { usePostHog } from 'posthog-js/react'; import ItemHeader from "@/components/workspace-canvas/ItemHeader"; import { getCardColorCSS, getCardAccentColor, getDistinctCardColor, SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; -import type { Item, NoteData, PdfData, FlashcardData, YouTubeData } from "@/lib/workspace-state/types"; +import type { Item, NoteData, PdfData, FlashcardData, YouTubeData, ImageData } from "@/lib/workspace-state/types"; import { SwatchesPicker, ColorResult } from "react-color"; import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEditor"; import { serializeBlockNote } from "@/lib/utils/serialize-blocknote"; @@ -560,14 +561,14 @@ function WorkspaceCard({ data-youtube-playing={isYouTubePlaying} data-item-type={item.type} data-has-preview={shouldShowPreview} - className={`relative rounded-md scroll-mt-4 size-full flex flex-col overflow-hidden transition-all duration-200 cursor-pointer ${item.type === 'youtube' || (item.type === 'pdf' && shouldShowPreview) + className={`relative rounded-md scroll-mt-4 size-full flex flex-col overflow-hidden transition-all duration-200 cursor-pointer ${item.type === 'youtube' || item.type === 'image' || (item.type === 'pdf' && shouldShowPreview) ? 'p-0' : 'p-4 border shadow-sm hover:border-foreground/30 hover:shadow-md focus-within:border-foreground/50' }`} style={{ - backgroundColor: item.type === 'youtube' ? 'transparent' : (item.color ? getCardColorCSS(item.color, 0.25) : 'var(--card)'), + backgroundColor: (item.type === 'youtube' || item.type === 'image') ? 'transparent' : (item.color ? getCardColorCSS(item.color, 0.25) : 'var(--card)'), borderColor: isSelected ? 'rgba(255, 255, 255, 0.8)' : (item.color ? getCardAccentColor(item.color, 0.5) : 'transparent'), - borderWidth: isSelected ? '2px' : (item.type === 'youtube' || (item.type === 'pdf' && shouldShowPreview) ? '0px' : '1px'), + borderWidth: isSelected ? '2px' : ((item.type === 'youtube' || item.type === 'image' || (item.type === 'pdf' && shouldShowPreview)) ? '0px' : '1px'), transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out, background-color 150ms ease-out' } as React.CSSProperties} onMouseDown={handleMouseDown} @@ -577,8 +578,8 @@ function WorkspaceCard({ > {/* Floating Controls Container */}
- {/* Scroll Lock/Unlock Button - Hidden for YouTube, quiz, and narrow note/PDF cards */} - {item.type !== 'youtube' && item.type !== 'quiz' && !(item.type === 'note' && !shouldShowPreview) && !(item.type === 'pdf' && !shouldShowPreview) && ( + {/* Scroll Lock/Unlock Button - Hidden for YouTube, image, quiz, and narrow note/PDF cards */} + {item.type !== 'youtube' && item.type !== 'image' && item.type !== 'quiz' && !(item.type === 'note' && !shouldShowPreview) && !(item.type === 'pdf' && !shouldShowPreview) && ( + + + + + ); +} diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 7f175bee..f5461277 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -40,6 +40,9 @@ import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; import { useAui } from "@assistant-ui/react"; import { focusComposerInput } from "@/lib/utils/composer-utils"; +import { CreateImageDialog } from "@/components/modals/CreateImageDialog"; +import { ImageIcon } from "lucide-react"; +import { getBestFrameForRatio } from "@/lib/workspace-state/aspect-ratios"; interface WorkspaceSectionProps { // Loading states @@ -182,6 +185,7 @@ export function WorkspaceSection({ // Workspace settings and share modal state const [showYouTubeDialog, setShowYouTubeDialog] = useState(false); + const [showImageDialog, setShowImageDialog] = useState(false); // Get workspace data from context const { workspaces } = useWorkspaceContext(); @@ -196,6 +200,88 @@ export function WorkspaceSection({ } }, [addItem]); + const handleImageCreate = useCallback(async (url: string, name: string) => { + if (!operations) return; + + // Attempt to load image to get dimensions for adaptive layout + let initialLayout = undefined; + try { + const img = new Image(); + const dimensionsPromise = new Promise<{ width: number, height: number }>((resolve, reject) => { + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); + img.onerror = reject; + // Handle duplicate image load + if (img.complete) { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + } + img.src = url; + }); + + // Timeout after 2 seconds to avoid hanging + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject("Timeout"), 2000)); + + const { width, height } = await Promise.race([dimensionsPromise, timeoutPromise]) as { width: number, height: number }; + const bestFrame = getBestFrameForRatio(width, height); + initialLayout = { w: bestFrame.w, h: bestFrame.h }; + } catch (e) { + console.warn("Could not detect image dimensions, using defaults", e); + } + + operations.createItems([{ + type: 'image', + name, + initialData: { url, altText: name }, + initialLayout + }]); + + toast.success("Image added to workspace"); + }, [operations]); + + // Handle smart image selection from menu + const handleImageMenuItemClick = useCallback(async () => { + try { + // Check for clipboard permissions/content + const clipboardItems = await navigator.clipboard.read(); + let imageBlob: Blob | null = null; + + for (const item of clipboardItems) { + const imageType = item.types.find(t => t.startsWith('image/')); + if (imageType) { + imageBlob = await item.getType(imageType); + break; + } + } + + if (imageBlob) { + // Found an image! Upload it directly. + const toastId = toast.loading("Pasting image from clipboard..."); + + const formData = new FormData(); + formData.append('file', imageBlob, "pasted-image.png"); // Default name + + const response = await fetch('/api/upload-file', { + method: 'POST', + body: formData, + }); + + if (!response.ok) throw new Error("Upload failed"); + + const data = await response.json(); + toast.dismiss(toastId); + + // Create the card using the new URL + await handleImageCreate(data.url, "Pasted Image"); + return; + } + } catch (e) { + // Fallback to dialog if clipboard access fails or no image found + console.debug("Clipboard read failed or empty, falling back to dialog", e); + } + + // If no image found or error, open the manual dialog + setShowImageDialog(true); + }, [handleImageCreate]); + // Handle delete request (from button or keyboard) const handleDeleteRequest = () => { if (selectedCardIds.size > 0) { @@ -540,6 +626,13 @@ export function WorkspaceSection({ YouTube + + + Image + { toast.success("Deep Research action selected"); @@ -611,6 +704,13 @@ export function WorkspaceSection({ onOpenChange={setShowYouTubeDialog} onCreate={handleYouTubeCreate} /> + + {/* Image Dialog */} +
); } From 50e4ea0070441cdb103fab0bb7e72dac85d42747 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 05:55:49 -0500 Subject: [PATCH 4/5] agent can see workspace images --- src/components/modals/CreateImageDialog.tsx | 118 ++++++++++++------ .../workspace-canvas/WorkspaceHeader.tsx | 54 +++++++- src/lib/utils/format-workspace-context.ts | 46 ++++++- 3 files changed, 178 insertions(+), 40 deletions(-) diff --git a/src/components/modals/CreateImageDialog.tsx b/src/components/modals/CreateImageDialog.tsx index ed973ac5..6e57ff4a 100644 --- a/src/components/modals/CreateImageDialog.tsx +++ b/src/components/modals/CreateImageDialog.tsx @@ -13,7 +13,9 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; -import { ImageIcon, Loader2 } from "lucide-react"; +import { ImageIcon, Loader2, UploadCloud } from "lucide-react"; +import { useDropzone } from "react-dropzone"; +import { cn } from "@/lib/utils"; interface CreateImageDialogProps { open: boolean; @@ -78,29 +80,14 @@ export function CreateImageDialog({ } }, [open]); - // Handle paste event to capture image data - const handlePaste = useCallback(async (e: React.ClipboardEvent) => { - const items = e.clipboardData.items; - let imageFile: File | null = null; - - // Find image file in clipboard items - for (let i = 0; i < items.length; i++) { - if (items[i].type.indexOf("image") !== -1) { - imageFile = items[i].getAsFile(); - break; - } - } - - if (!imageFile) return; - - // If we found an image, upload it - e.preventDefault(); // Prevent pasting the "file name" text if any + // Shared upload function + const uploadFile = useCallback(async (file: File) => { setIsUploading(true); - const toastId = toast.loading("Uploading pasted image..."); + const toastId = toast.loading("Uploading image..."); try { const formData = new FormData(); - formData.append('file', imageFile); + formData.append('file', file); const response = await fetch('/api/upload-file', { method: 'POST', @@ -114,22 +101,57 @@ export function CreateImageDialog({ const data = await response.json(); setUrl(data.url); - // Optionally auto-set name if empty if (!name) { - // Determine a nice name, e.g. "Pasted Image" or filename if available - setName("Pasted Image"); + // Use filename without extension as default name + const simpleName = file.name.split('.').slice(0, -1).join('.') || "Image"; + setName(simpleName); } toast.success("Image uploaded successfully"); } catch (error) { - console.error("Paste upload failed:", error); - toast.error("Failed to upload pasted image"); + console.error("Upload failed:", error); + toast.error("Failed to upload image"); } finally { setIsUploading(false); toast.dismiss(toastId); } }, [name]); + // Handle paste event to capture image data + const handlePaste = useCallback(async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items; + let imageFile: File | null = null; + + // Find image file in clipboard items + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image") !== -1) { + imageFile = items[i].getAsFile(); + break; + } + } + + if (!imageFile) return; + + // If we found an image, upload it + e.preventDefault(); + await uploadFile(imageFile); + }, [uploadFile]); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + uploadFile(acceptedFiles[0]); + } + }, [uploadFile]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'] + }, + maxFiles: 1, + disabled: isUploading + }); + return ( @@ -139,11 +161,43 @@ export function CreateImageDialog({ Add Image - Enter an image URL to add it to your workspace. + Drag and drop, paste from clipboard, or enter a URL. -
+
+ {/* Dropzone */} +
+ +
+ {isUploading ? : } +
+

+ {isUploading ? "Uploading..." : isDragActive ? "Drop image here" : "Click or drag image here"} +

+

+ Supports PNG, JPG, GIF, WebP +

+
+ +
+
+ +
+
+ + Or via URL + +
+
+
setUrl(e.target.value)} onPaste={handlePaste} - autoFocus disabled={isUploading} /> - {isUploading && ( -

- - Uploading image from clipboard... -

- )}
@@ -173,9 +220,6 @@ export function CreateImageDialog({ value={name} onChange={(e) => setName(e.target.value)} /> -

- Leave empty to use "Image" -

diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 893f9312..ef13cce0 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Search, X, ChevronRight, ChevronDown, FolderOpen, ChevronLeft, Plus, Upload, FileText, Folder as FolderIcon, Settings, Share2, Play, MoreHorizontal, Globe, Brain, Maximize, File } from "lucide-react"; +import { Search, X, ChevronRight, ChevronDown, FolderOpen, ChevronLeft, Plus, Upload, FileText, Folder as FolderIcon, Settings, Share2, Play, MoreHorizontal, Globe, Brain, Maximize, File, ImageIcon } from "lucide-react"; import { LuBook } from "react-icons/lu"; import { PiCardsThreeBold } from "react-icons/pi"; import { cn } from "@/lib/utils"; @@ -46,6 +46,8 @@ import type { CardType, Item } from "@/lib/workspace-state/types"; import { getFolderPath } from "@/lib/workspace-state/search"; import { useMemo } from "react"; import { CreateYouTubeDialog } from "@/components/modals/CreateYouTubeDialog"; +import { CreateImageDialog } from "@/components/modals/CreateImageDialog"; +import { getBestFrameForRatio } from "@/lib/workspace-state/aspect-ratios"; interface WorkspaceHeaderProps { titleInputRef: React.RefObject; searchQuery: string; @@ -67,7 +69,7 @@ interface WorkspaceHeaderProps { workspaceIcon?: string | null; workspaceColor?: string | null; // New button props - addItem?: (type: CardType, name?: string, initialData?: Partial) => string; + addItem?: (type: CardType, name?: string, initialData?: Partial, initialLayout?: any) => string; onPDFUpload?: (files: File[]) => Promise; setOpenModalItemId?: (id: string | null) => void; @@ -135,6 +137,7 @@ export default function WorkspaceHeader({ const [renamingTarget, setRenamingTarget] = useState<{ id: string, type: 'folder' | 'item' } | null>(null); const [renameValue, setRenameValue] = useState(""); const [showYouTubeDialog, setShowYouTubeDialog] = useState(false); + const [showImageDialog, setShowImageDialog] = useState(false); const renameInputRef = useRef(null); const searchInputRef = useRef(null); const pathname = usePathname(); @@ -377,6 +380,38 @@ export default function WorkspaceHeader({ setIsNewMenuOpen(false); }, [addItem]); + const handleImageCreate = useCallback(async (url: string, name: string) => { + if (!addItem) return; + + // Attempt to load image to get dimensions for adaptive layout + let initialLayout = undefined; + try { + const img = new window.Image(); + const dimensionsPromise = new Promise<{ width: number, height: number }>((resolve, reject) => { + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); + img.onerror = reject; + // Handle duplicate image load + if (img.complete) { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + } + img.src = url; + }); + + // Timeout after 2 seconds to avoid hanging + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject("Timeout"), 2000)); + + const { width, height } = await Promise.race([dimensionsPromise, timeoutPromise]) as { width: number, height: number }; + const bestFrame = getBestFrameForRatio(width, height); + initialLayout = { w: bestFrame.w, h: bestFrame.h }; + } catch (e) { + console.warn("Could not detect image dimensions, using defaults", e); + } + + addItem('image', name, { url, altText: name }, initialLayout); + toast.success("Image added to workspace"); + setIsNewMenuOpen(false); + }, [addItem]); + // Close popover when folder path changes useEffect(() => { setEllipsisDropdownOpen(false); @@ -943,6 +978,15 @@ export default function WorkspaceHeader({ YouTube + { + setShowImageDialog(true); + }} + className="flex items-center gap-2 cursor-pointer" + > + + Image + { toast.success("Deep Research action selected"); @@ -1027,6 +1071,12 @@ export default function WorkspaceHeader({ onOpenChange={setShowYouTubeDialog} onCreate={handleYouTubeCreate} /> + {/* Image Dialog */} +
); } diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 22849f7f..d9b3b475 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -1,4 +1,4 @@ -import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData, QuizData, QuizQuestion } from "@/lib/workspace-state/types"; +import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData, QuizData, QuizQuestion, ImageData } from "@/lib/workspace-state/types"; import { serializeBlockNote } from "./serialize-blocknote"; import { type Block } from "@/components/editor/BlockNoteEditor"; @@ -132,6 +132,9 @@ function formatItem(item: Item, index: number): string { case "flashcard": lines.push(...formatFlashcardDetails(item.data as FlashcardData)); break; + case "image": + lines.push(...formatImageDetails(item.data as ImageData)); + break; } return lines.join("\n"); @@ -161,6 +164,15 @@ function formatFlashcardDetails(data: FlashcardData): string[] { return [` - Deck contains ${cardCount} card${cardCount !== 1 ? 's' : ''}`]; } +/** + * Formats Image-specific details + */ +function formatImageDetails(data: ImageData): string[] { + const details = []; + if (data.altText) details.push(`Alt: ${data.altText}`); + return details.length > 0 ? [` - ${details.join(", ")}`] : []; +} + /** * Truncates text to specified length with ellipsis */ @@ -279,6 +291,14 @@ function extractRichContent(item: Item): RichContent { } } + // For Image cards, include the URL + if (item.type === "image") { + const imageData = item.data as ImageData; + if (imageData.url) { + richContent.images.push(imageData.url); + } + } + return richContent; } @@ -451,6 +471,9 @@ function formatSelectedCardFull(item: Item, index: number): string { case "quiz": lines.push(...formatQuizDetailsFull(item.data as QuizData)); break; + case "image": + lines.push(...formatImageDetailsFull(item.data as ImageData)); + break; } // Add Metadata Section @@ -525,6 +548,27 @@ function formatYouTubeDetailsFull(data: YouTubeData): string[] { return lines; } +/** + * Formats Image details with FULL content + */ +function formatImageDetailsFull(data: ImageData): string[] { + const lines: string[] = []; + + if (data.url) { + lines.push(` - URL: ${data.url}`); + } + + if (data.altText) { + lines.push(` - Alt Text: ${data.altText}`); + } + + if (data.caption) { + lines.push(` - Caption: ${data.caption}`); + } + + return lines; +} + /** From b93ab998791126a22a23dc8a812746ae3814b8eb Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 07:44:06 -0500 Subject: [PATCH 5/5] pics from internet (api key issue) --- .../assistant-ui/AddImageToolUI.tsx | 144 ++++++++++++ .../assistant-ui/ImageSearchToolUI.tsx | 209 ++++++++++++++++++ .../assistant-ui/WorkspaceRuntimeProvider.tsx | 7 + src/lib/ai/tools/image-tools.ts | 91 ++++++++ src/lib/ai/tools/index.ts | 5 + src/lib/ai/workers/workspace-worker.ts | 22 +- src/lib/google-images.ts | 87 ++++++++ 7 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/components/assistant-ui/AddImageToolUI.tsx create mode 100644 src/components/assistant-ui/ImageSearchToolUI.tsx create mode 100644 src/lib/ai/tools/image-tools.ts create mode 100644 src/lib/google-images.ts diff --git a/src/components/assistant-ui/AddImageToolUI.tsx b/src/components/assistant-ui/AddImageToolUI.tsx new file mode 100644 index 00000000..98eb78da --- /dev/null +++ b/src/components/assistant-ui/AddImageToolUI.tsx @@ -0,0 +1,144 @@ +"use client"; + +import type { ReactNode } from "react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { X, Eye, Image as ImageIcon } from "lucide-react"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; +import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas"; +import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; + +type AddImageArgs = { + url: string; + title: string; + altText?: string; + width?: number; + height?: number; +}; + +interface AddImageReceiptProps { + args: AddImageArgs; + result: WorkspaceResult; + status: any; +} + +const AddImageReceipt = ({ + args, + result, + status, +}: AddImageReceiptProps) => { + const navigateToItem = useNavigateToItem(); + + const handleViewCard = () => { + if (!result.itemId) return; + navigateToItem(result.itemId); + }; + + return ( +
+
+
+ {status?.type === "complete" ? ( + + ) : ( + + )} +
+
+ + {status?.type === "complete" ? args.title : "Image Addition Cancelled"} + + {status?.type === "complete" && ( + + Image card added + + )} +
+
+ +
+ {status?.type === "complete" && result.itemId && ( + + )} +
+
+ ); +}; + +export const AddImageToolUI = makeAssistantToolUI({ + toolName: "addImage", + render: function AddImageToolUI({ args, result, status }) { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + useOptimisticToolUpdate(status, result, workspaceId); + + let parsed: WorkspaceResult | null = null; + if (status.type === "complete" && result != null) { + try { + parsed = parseWorkspaceResult(result); + } catch (err) { + console.error("🖼️ [AddImageTool] Failed to parse result:", err); + parsed = null; + } + } + + let content: ReactNode = null; + + if (parsed?.success) { + content = ( + + ); + } else if (status.type === "running") { + content = ; + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + + ); + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/ImageSearchToolUI.tsx b/src/components/assistant-ui/ImageSearchToolUI.tsx new file mode 100644 index 00000000..458d79bf --- /dev/null +++ b/src/components/assistant-ui/ImageSearchToolUI.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { makeAssistantToolUI, useScrollLock } from "@assistant-ui/react"; +import { Loader2, Plus, Image as ImageIcon, Check, ChevronDownIcon, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useState, useRef, useCallback, useEffect, type FC, type PropsWithChildren } from "react"; +import { toast } from "sonner"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { initialState } from "@/lib/workspace-state/state"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import ShinyText from "@/components/ShinyText"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import type { ImageResult } from "@/lib/google-images"; + +const ANIMATION_DURATION = 200; +const SHIMMER_DURATION = 1000; + +interface SearchImagesArgs { + query: string; +} + +interface SearchImagesResult { + success: boolean; + images?: ImageResult[]; + message?: string; + error?: string; // For detailed error messages (e.g. key missing) +} + +const ImageSearchContent: FC<{ + args: SearchImagesArgs; + status: { type: string }; + result: SearchImagesResult | null; +}> = ({ args, status, result }) => { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + // Explicitly type the operations result or use generic appropriately if needed, + // but here we just rely on type inference. + // The previous YouTube tool used `useWorkspaceOperations(workspaceId, workspaceState || initialState)`. + const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); + + const [addedImages, setAddedImages] = useState>(new Set()); + const [addingImages, setAddingImages] = useState>(new Set()); + + const isRunning = status.type === "running"; + const navigateToItem = useNavigateToItem(); + const [scrollToId, setScrollToId] = useState(null); + + // Scroll to new item effect + useEffect(() => { + if (scrollToId && workspaceState?.items) { + const item = workspaceState.items.find(i => i.id === scrollToId); + if (item) { + navigateToItem(scrollToId); + setScrollToId(null); + } + } + }, [scrollToId, workspaceState?.items, navigateToItem]); + + const handleAddImage = async (image: ImageResult) => { + // Use URL as unique key for "added" set + if (addedImages.has(image.url) || addingImages.has(image.url)) return; + + try { + setAddingImages(prev => new Set(prev).add(image.url)); + + const id = operations.createItem("image", image.title, { + url: image.url, + altText: image.title, + caption: image.title + }); + + setAddedImages(prev => new Set(prev).add(image.url)); + toast.success("Image added to workspace"); + setScrollToId(id); + } catch (error) { + console.error("Failed to add image:", error); + toast.error("Failed to add image"); + } finally { + setAddingImages(prev => { + const next = new Set(prev); + next.delete(image.url); + return next; + }); + } + }; + + // Special handling for MISSING_KEYS error + if (status.type === "complete" && result && result.message === "MISSING_KEYS") { + return ( +
+
+ + Configuration Required +
+
+

Google Images Search is not configured.

+

Please add the following environment variables to your .env file:

+
+                        GOOGLE_SEARCH_API_KEY=your_key_here{"\n"}
+                        GOOGLE_SEARCH_CX=your_cx_id_here
+                    
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {isRunning ? ( + + ) : ( + + )} +
+
+ + {isRunning ? `Searching for "${args.query}"...` : "Image Search"} + + {status.type === "complete" && result && result.success && ( + + {result.images?.length || 0} images found + + )} +
+
+
+ + {/* Results Grid */} + {status.type === "complete" && result && ( +
+ {!result.success || !result.images || result.images.length === 0 ? ( +
+ No images found. + {result.message && result.message !== "MISSING_KEYS" &&

{result.message}

} +
+ ) : ( +
+ {result.images.map((image, idx) => ( +
handleAddImage(image)} + > + {/* Image */} + {image.title} + + {/* Overlay */} +
+ +
+ + {/* Caption gradient */} +
+ {image.title} +
+
+ ))} +
+ )} +
+ )} +
+ ); +}; + +export const ImageSearchToolUI = makeAssistantToolUI({ + toolName: "searchImages", + render: function ImageSearchToolUI({ args, status, result }) { + return ( + + + + ); + }, +}); diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 85caa045..4bb6901b 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -17,10 +17,15 @@ interface WorkspaceRuntimeProviderProps { import { useShallow } from "zustand/react/shallow"; +import { ImageSearchToolUI } from "@/components/assistant-ui/ImageSearchToolUI"; +import { AddImageToolUI } from "@/components/assistant-ui/AddImageToolUI"; + export function WorkspaceRuntimeProvider({ workspaceId, children }: WorkspaceRuntimeProviderProps) { + // ... existing hooks + const selectedModelId = useUIStore((state) => state.selectedModelId); const activeFolderId = useUIStore((state) => state.activeFolderId); @@ -153,6 +158,8 @@ export function WorkspaceRuntimeProvider({ {children} + + ); diff --git a/src/lib/ai/tools/image-tools.ts b/src/lib/ai/tools/image-tools.ts new file mode 100644 index 00000000..477f3c8d --- /dev/null +++ b/src/lib/ai/tools/image-tools.ts @@ -0,0 +1,91 @@ +import { tool, zodSchema } from "ai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { searchGoogleImages } from "@/lib/google-images"; +import { workspaceWorker } from "@/lib/ai/workers"; +import type { WorkspaceToolContext } from "./workspace-tools"; + +/** + * Create the searchImages tool + */ +export function createSearchImagesTool() { + return tool({ + description: "Search for images on the web (Google Images).", + inputSchema: zodSchema( + z.object({ + query: z.string().describe("The search query for images"), + }) + ), + execute: async ({ query }) => { + logger.debug("🖼️ [IMAGES] Searching for:", query); + try { + const images = await searchGoogleImages(query); + return { + success: true, + images, + }; + } catch (error: any) { + // Handle specific MISSING_KEYS error to show friendly partial UI + if (error.message === "MISSING_KEYS") { + return { + success: false, + message: "MISSING_KEYS", // Special code for UI + error: "Google Search API Key or CX is missing. Please configure them in .env" + }; + } + + logger.error("❌ [IMAGES] Search tool failed:", error); + return { + success: false, + message: error.message || "Failed to search images.", + }; + } + }, + }); +} + +/** + * Create the addImage tool + */ +export function createAddImageTool(ctx: WorkspaceToolContext) { + return tool({ + description: "Add an image to the workspace from a URL.", + inputSchema: zodSchema( + z.object({ + url: z.string().describe("The full URL of the image"), + title: z.string().describe("Title or description for the image card"), + altText: z.string().optional().describe("Accessibility alt text"), + width: z.number().optional().describe("Image width"), + height: z.number().optional().describe("Image height"), + }) + ), + execute: async ({ url, title, altText, width, height }) => { + logger.debug("🖼️ [IMAGES] Adding image:", { url, title }); + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + // Calculate initial layout if dimensions are known + // Logic similar to WorkspaceHeader adaptive calculation could be here, + // but workspaceWorker generally handles creation. + // We pass dimensions in metadata or let the card handle it. + // For now, we just pass the data. + + return await workspaceWorker("create", { + workspaceId: ctx.workspaceId, + title, + itemType: "image", + imageData: { + url, + altText: altText || title, + caption: title + }, + folderId: ctx.activeFolderId, + }); + }, + }); +} diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 2820f509..9374d9ef 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -18,6 +18,7 @@ import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-to import { createQuizTool, createUpdateQuizTool } from "./quiz-tools"; import { createDeepResearchTool } from "./deep-research"; import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; +import { createSearchImagesTool, createAddImageTool } from "./image-tools"; import { createWebSearchTool } from "./web-search"; import { logger } from "@/lib/utils/logger"; @@ -77,6 +78,10 @@ export function createChatTools(config: ChatToolsConfig): Record { searchYoutube: createSearchYoutubeTool(), addYoutubeVideo: createAddYoutubeVideoTool(ctx), + // Google Images + searchImages: createSearchImagesTool(), + addImage: createAddImageTool(ctx), + // Client tools from frontend ...frontendClientTools, }; diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 0db67c49..1f27ad56 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -73,7 +73,7 @@ export async function workspaceWorker( content?: string; // For notes itemId?: string; - itemType?: "note" | "flashcard" | "quiz" | "youtube"; // Defaults to "note" if undefined + itemType?: "note" | "flashcard" | "quiz" | "youtube" | "image"; // Defaults to "note" if undefined flashcardData?: { cards?: { front: string; back: string }[]; // For creating flashcards cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending) @@ -83,6 +83,11 @@ export async function workspaceWorker( youtubeData?: { url: string; // For creating youtube cards }; + imageData?: { + url: string; + altText?: string; + caption?: string; + }; // Optional: deep research metadata to attach to a note deepResearchData?: { prompt: string; @@ -180,6 +185,19 @@ export async function workspaceWorker( itemData = { url: params.youtubeData.url }; + } else if (itemType === "image") { + // Image type + if (!params.imageData || !params.imageData.url) { + throw new Error("Image data required for image card creation"); + } + itemData = { + url: params.imageData.url, // Main URL + // Store alt/caption in a flexible way if types allow, + // or just rely on Item name for title/caption. + // based on NoteData structure, 'data' is basically any object. + altText: params.imageData.altText, + caption: params.imageData.caption + }; } else if (itemType === "quiz") { // Quiz type if (!params.quizData) { @@ -219,7 +237,7 @@ export async function workspaceWorker( const item: Item = { id: itemId, type: itemType, - name: params.title || (itemType === "youtube" ? "YouTube Video" : itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), + name: params.title || (itemType === "youtube" ? "YouTube Video" : itemType === "image" ? "Image" : itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), subtitle: "", data: itemData, color: getRandomCardColor(), diff --git a/src/lib/google-images.ts b/src/lib/google-images.ts new file mode 100644 index 00000000..5abe66c0 --- /dev/null +++ b/src/lib/google-images.ts @@ -0,0 +1,87 @@ +import { logger } from "@/lib/utils/logger"; + +interface GoogleSearchImage { + link: string; + title: string; + image: { + contextLink: string; + height: number; + width: number; + thumbnailLink: string; + thumbnailHeight: number; + thumbnailWidth: number; + }; +} + +interface GoogleSearchResponse { + items?: GoogleSearchImage[]; + error?: { + code: number; + message: string; + status: string; + }; +} + +export interface ImageResult { + url: string; + title: string; + thumbnailUrl: string; + width: number; + height: number; + contextLink: string; +} + +/** + * Search for images using the Google Custom Search JSON API + */ +export async function searchGoogleImages(query: string, maxResults = 10): Promise { + const apiKey = process.env.GOOGLE_SEARCH_API_KEY; + const cx = process.env.GOOGLE_SEARCH_CX; + + if (!apiKey || !cx) { + logger.warn("⚠️ [GOOGLE-IMAGES] Missing API Key or CX"); + throw new Error("MISSING_KEYS"); + } + + try { + const url = new URL("https://www.googleapis.com/customsearch/v1"); + url.searchParams.append("q", query); + url.searchParams.append("cx", cx); + url.searchParams.append("key", apiKey); + url.searchParams.append("searchType", "image"); + url.searchParams.append("num", maxResults.toString()); + url.searchParams.append("safe", "active"); // SafeSearch + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Accept": "application/json", + }, + }); + + const data = (await response.json()) as GoogleSearchResponse; + + if (!response.ok) { + const errorMessage = data.error?.message || response.statusText; + logger.error(`❌ [GOOGLE-IMAGES] API Error: ${response.status}`, errorMessage); + throw new Error(`Google API request failed: ${errorMessage}`); + } + + if (!data.items) { + return []; + } + + return data.items.map((item) => ({ + url: item.link, + title: item.title, + thumbnailUrl: item.image.thumbnailLink, + width: item.image.width, + height: item.image.height, + contextLink: item.image.contextLink, + })); + + } catch (error) { + logger.error("❌ [GOOGLE-IMAGES] Search failed:", error); + throw error; + } +}