diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 57dd64e..374c984 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -6,6 +6,36 @@ import { z } from "zod"; export const maxDuration = 300; +// File upload limits (must match client-side) +const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB +const MAX_FILES = 5; + +// Helper function to validate file parts in messages +function validateFileParts(messages: any[]): { valid: boolean; error?: string } { + const lastMessage = messages[messages.length - 1]; + const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || []; + + if (fileParts.length > MAX_FILES) { + return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` }; + } + + for (const filePart of fileParts) { + // Data URLs format: data:image/png;base64, + // Base64 increases size by ~33%, so we check the decoded size + if (filePart.url && filePart.url.startsWith('data:')) { + const base64Data = filePart.url.split(',')[1]; + if (base64Data) { + const sizeInBytes = Math.ceil((base64Data.length * 3) / 4); + if (sizeInBytes > MAX_FILE_SIZE) { + return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` }; + } + } + } + } + + return { valid: true }; +} + // Helper function to check if diagram is minimal/empty function isMinimalDiagram(xml: string): boolean { const stripped = xml.replace(/\s/g, ''); @@ -33,6 +63,13 @@ function createCachedStreamResponse(xml: string): Response { async function handleChatRequest(req: Request): Promise { const { messages, xml } = await req.json(); + // === FILE VALIDATION START === + const fileValidation = validateFileParts(messages); + if (!fileValidation.valid) { + return Response.json({ error: fileValidation.error }, { status: 400 }); + } + // === FILE VALIDATION END === + // === CACHE CHECK START === const isFirstMessage = messages.length === 1; const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml); diff --git a/app/layout.tsx b/app/layout.tsx index 8a5b84e..0d42e02 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -96,7 +96,6 @@ export default function RootLayout({ className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`} > {children} - {process.env.NEXT_PUBLIC_GA_ID && ( diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 3604c6c..0525a50 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -12,12 +12,78 @@ import { Image as ImageIcon, History, Download, - Paperclip, } from "lucide-react"; +import { toast } from "sonner"; import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { FilePreviewList } from "./file-preview-list"; import { useDiagram } from "@/contexts/diagram-context"; import { HistoryDialog } from "@/components/history-dialog"; +import { ErrorToast } from "@/components/error-toast"; + +const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB +const MAX_FILES = 5; + +function formatFileSize(bytes: number): string { + const mb = bytes / 1024 / 1024; + if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`; + return `${mb.toFixed(2)}MB`; +} + +function showErrorToast(message: React.ReactNode) { + toast.custom( + (t) => toast.dismiss(t)} />, + { duration: 5000 } + ); +} + +interface ValidationResult { + validFiles: File[]; + errors: string[]; +} + +function validateFiles(newFiles: File[], existingCount: number): ValidationResult { + const errors: string[] = []; + const validFiles: File[] = []; + + const availableSlots = MAX_FILES - existingCount; + + if (availableSlots <= 0) { + errors.push(`Maximum ${MAX_FILES} files allowed`); + return { validFiles, errors }; + } + + for (const file of newFiles) { + if (validFiles.length >= availableSlots) { + errors.push(`Only ${availableSlots} more file(s) allowed`); + break; + } + if (file.size > MAX_FILE_SIZE) { + errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`); + } else { + validFiles.push(file); + } + } + + return { validFiles, errors }; +} + +function showValidationErrors(errors: string[]) { + if (errors.length === 0) return; + + if (errors.length === 1) { + showErrorToast({errors[0]}); + } else { + showErrorToast( +
+ {errors.length} files rejected: +
    + {errors.slice(0, 3).map((err, i) =>
  • {err}
  • )} + {errors.length > 3 &&
  • ...and {errors.length - 3} more
  • } +
+
+ ); + } +} interface ChatInputProps { input: string; @@ -52,11 +118,8 @@ export function ChatInput({ const [showSaveDialog, setShowSaveDialog] = useState(false); // Allow retry when there's an error (even if status is still "streaming" or "submitted") - const isDisabled = (status === "streaming" || status === "submitted") && !error; - - useEffect(() => { - console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled); - }, [status, isDisabled]); + const isDisabled = + (status === "streaming" || status === "submitted") && !error; const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current; @@ -89,23 +152,20 @@ export function ChatInput({ ); if (imageItems.length > 0) { - const imageFiles = await Promise.all( - imageItems.map(async (item) => { + const imageFiles = (await Promise.all( + imageItems.map(async (item, index) => { const file = item.getAsFile(); if (!file) return null; return new File( [file], - `pasted-image-${Date.now()}.${file.type.split("/")[1]}`, - { - type: file.type, - } + `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, + { type: file.type } ); }) - ); + )).filter((f): f is File => f !== null); - const validFiles = imageFiles.filter( - (file): file is File => file !== null - ); + const { validFiles, errors } = validateFiles(imageFiles, files.length); + showValidationErrors(errors); if (validFiles.length > 0) { onFileChange([...files, ...validFiles]); } @@ -114,7 +174,15 @@ export function ChatInput({ const handleFileChange = (e: React.ChangeEvent) => { const newFiles = Array.from(e.target.files || []); - onFileChange([...files, ...newFiles]); + const { validFiles, errors } = validateFiles(newFiles, files.length); + showValidationErrors(errors); + if (validFiles.length > 0) { + onFileChange([...files, ...validFiles]); + } + // Reset input so same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } }; const handleRemoveFile = (fileToRemove: File) => { @@ -148,13 +216,14 @@ export function ChatInput({ if (isDisabled) return; const droppedFiles = e.dataTransfer.files; - const imageFiles = Array.from(droppedFiles).filter((file) => file.type.startsWith("image/") ); - if (imageFiles.length > 0) { - onFileChange([...files, ...imageFiles]); + const { validFiles, errors } = validateFiles(imageFiles, files.length); + showValidationErrors(errors); + if (validFiles.length > 0) { + onFileChange([...files, ...validFiles]); } }; @@ -178,7 +247,10 @@ export function ChatInput({ {/* File previews */} {files.length > 0 && (
- +
)} @@ -252,8 +324,12 @@ export function ChatInput({ saveDiagramToFile(filename, format)} - defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`} + onSave={(filename, format) => + saveDiagramToFile(filename, format) + } + defaultFilename={`diagram-${new Date() + .toISOString() + .slice(0, 10)}`} /> {isDisabled ? ( @@ -299,7 +377,6 @@ export function ChatInput({ - ); } diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 60de246..0bdbbfe 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -15,6 +15,7 @@ import { ChatMessageDisplay } from "./chat-message-display"; import { useDiagram } from "@/contexts/diagram-context"; import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils"; import { ButtonWithTooltip } from "@/components/button-with-tooltip"; +import { Toaster } from "sonner"; interface ChatPanelProps { isVisible: boolean; @@ -451,7 +452,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a // Full view return ( -
+
+ {/* Header */}
diff --git a/components/error-toast.tsx b/components/error-toast.tsx new file mode 100644 index 0000000..3183121 --- /dev/null +++ b/components/error-toast.tsx @@ -0,0 +1,39 @@ +"use client"; + +import React from "react"; + +interface ErrorToastProps { + message: React.ReactNode; + onDismiss: () => void; +} + +export function ErrorToast({ message, onDismiss }: ErrorToastProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " " || e.key === "Escape") { + e.preventDefault(); + onDismiss(); + } + }; + + return ( +
+
+ +
+ {message} +
+ ); +} diff --git a/package-lock.json b/package-lock.json index ad4c50e..598398c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-drawio": "^1.0.3", "react-icons": "^5.5.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "zod": "^4.1.12" @@ -10322,6 +10323,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 4be1698..3c95afe 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-drawio": "^1.0.3", "react-icons": "^5.5.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "zod": "^4.1.12"