mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat: add image upload validation with 2MB limit and max 5 files (#101)
- Add 2MB file size limit with client and server-side validation - Add max 5 files limit per upload - Add sonner toast library for better error notifications - Create ErrorToast component with keyboard accessibility - Batch multiple validation errors into single toast - Validate file size in all upload methods (input, paste, drag-drop) - Add server-side validation in /api/chat endpoint
This commit is contained in:
@@ -6,6 +6,36 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const maxDuration = 300;
|
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,<data>
|
||||||
|
// 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
|
// Helper function to check if diagram is minimal/empty
|
||||||
function isMinimalDiagram(xml: string): boolean {
|
function isMinimalDiagram(xml: string): boolean {
|
||||||
const stripped = xml.replace(/\s/g, '');
|
const stripped = xml.replace(/\s/g, '');
|
||||||
@@ -33,6 +63,13 @@ function createCachedStreamResponse(xml: string): Response {
|
|||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
const { messages, xml } = await req.json();
|
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 ===
|
// === CACHE CHECK START ===
|
||||||
const isFirstMessage = messages.length === 1;
|
const isFirstMessage = messages.length === 1;
|
||||||
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
|
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export default function RootLayout({
|
|||||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||||
|
|||||||
@@ -12,12 +12,78 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
History,
|
History,
|
||||||
Download,
|
Download,
|
||||||
Paperclip,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { FilePreviewList } from "./file-preview-list";
|
import { FilePreviewList } from "./file-preview-list";
|
||||||
import { useDiagram } from "@/contexts/diagram-context";
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { HistoryDialog } from "@/components/history-dialog";
|
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) => <ErrorToast message={message} onDismiss={() => 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(<span className="text-muted-foreground">{errors[0]}</span>);
|
||||||
|
} else {
|
||||||
|
showErrorToast(
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium">{errors.length} files rejected:</span>
|
||||||
|
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||||
|
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
|
||||||
|
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string;
|
input: string;
|
||||||
@@ -52,11 +118,8 @@ export function ChatInput({
|
|||||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
const isDisabled = (status === "streaming" || status === "submitted") && !error;
|
const isDisabled =
|
||||||
|
(status === "streaming" || status === "submitted") && !error;
|
||||||
useEffect(() => {
|
|
||||||
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
|
||||||
}, [status, isDisabled]);
|
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
@@ -89,23 +152,20 @@ export function ChatInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = await Promise.all(
|
const imageFiles = (await Promise.all(
|
||||||
imageItems.map(async (item) => {
|
imageItems.map(async (item, index) => {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
return new File(
|
return new File(
|
||||||
[file],
|
[file],
|
||||||
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
{
|
{ type: file.type }
|
||||||
type: file.type,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
)).filter((f): f is File => f !== null);
|
||||||
|
|
||||||
const validFiles = imageFiles.filter(
|
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||||
(file): file is File => file !== null
|
showValidationErrors(errors);
|
||||||
);
|
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
onFileChange([...files, ...validFiles]);
|
onFileChange([...files, ...validFiles]);
|
||||||
}
|
}
|
||||||
@@ -114,7 +174,15 @@ export function ChatInput({
|
|||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newFiles = Array.from(e.target.files || []);
|
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) => {
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
@@ -148,13 +216,14 @@ export function ChatInput({
|
|||||||
if (isDisabled) return;
|
if (isDisabled) return;
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files;
|
const droppedFiles = e.dataTransfer.files;
|
||||||
|
|
||||||
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
file.type.startsWith("image/")
|
file.type.startsWith("image/")
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||||
onFileChange([...files, ...imageFiles]);
|
showValidationErrors(errors);
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,7 +247,10 @@ export function ChatInput({
|
|||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
<FilePreviewList
|
||||||
|
files={files}
|
||||||
|
onRemoveFile={handleRemoveFile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -252,8 +324,12 @@ export function ChatInput({
|
|||||||
<SaveDialog
|
<SaveDialog
|
||||||
open={showSaveDialog}
|
open={showSaveDialog}
|
||||||
onOpenChange={setShowSaveDialog}
|
onOpenChange={setShowSaveDialog}
|
||||||
onSave={(filename, format) => saveDiagramToFile(filename, format)}
|
onSave={(filename, format) =>
|
||||||
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
saveDiagramToFile(filename, format)
|
||||||
|
}
|
||||||
|
defaultFilename={`diagram-${new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -285,7 +361,9 @@ export function ChatInput({
|
|||||||
disabled={isDisabled || !input.trim()}
|
disabled={isDisabled || !input.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
aria-label={isDisabled ? "Sending..." : "Send message"}
|
aria-label={
|
||||||
|
isDisabled ? "Sending..." : "Send message"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isDisabled ? (
|
{isDisabled ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -299,7 +377,6 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ChatMessageDisplay } from "./chat-message-display";
|
|||||||
import { useDiagram } from "@/contexts/diagram-context";
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -451,7 +452,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
|
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
||||||
|
<Toaster position="bottom-center" richColors style={{ position: "absolute" }} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="px-5 py-4 border-b border-border/50">
|
<header className="px-5 py-4 border-b border-border/50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
39
components/error-toast.tsx
Normal file
39
components/error-toast.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onDismiss}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-foreground">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
@@ -10322,6 +10323,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user