@@ -79,33 +145,44 @@ export default function MessageContentRenderer({ content }: MessageContentProps)
{imageContent.length > 0 && (
{imageContent.map((item, index) => {
- const imageUrl = item.image_url?.url;
- const statusKey = `${index}-${imageUrl ?? 'no-url'}`;
+ const storageId = item.image_url?.storageId;
+ // Use direct URL if available, otherwise try blob URL from storageId
+ const imageUrl =
+ item.image_url?.url ||
+ (storageId ? blobUrls[storageId] : undefined);
+
+ const statusKey = `${index}-${imageUrl ?? "no-url"}`;
const loaded = isImageLoaded(statusKey);
const errored = isImageError(statusKey);
return (
{imageUrl && (

setImageStatus(statusKey, 'loaded')}
- onError={() => setImageStatus(statusKey, 'error')}
- className={`block max-w-[320px] w-full h-full max-h-[360px] object-contain bg-black/40 transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
+ onLoad={() => setImageStatus(statusKey, "loaded")}
+ onError={() => setImageStatus(statusKey, "error")}
+ className={`block max-w-[320px] w-full h-full max-h-[360px] object-contain bg-black/40 transition-opacity duration-300 ${
+ loaded ? "opacity-100" : "opacity-0"
+ }`}
/>
)}
{errored && (
@@ -117,7 +194,11 @@ export default function MessageContentRenderer({ content }: MessageContentProps)
type="button"
disabled={!loaded || !imageUrl}
onClick={() => imageUrl && downloadImageFromSrc(imageUrl)}
- className={`absolute top-3 right-3 transition-opacity bg-black/60 hover:bg-black/80 text-white text-xs rounded-md px-2 py-1 border border-white/20 ${loaded ? 'opacity-100 md:opacity-0 md:group-hover:opacity-100' : 'opacity-0 pointer-events-none'}`}
+ className={`absolute top-3 right-3 transition-opacity bg-black/60 hover:bg-black/80 text-white text-xs rounded-md px-2 py-1 border border-white/20 ${
+ loaded
+ ? "opacity-100 md:opacity-0 md:group-hover:opacity-100"
+ : "opacity-0 pointer-events-none"
+ }`}
aria-label="Download image"
>
Download
@@ -129,4 +210,4 @@ export default function MessageContentRenderer({ content }: MessageContentProps)
)}
);
-}
+}
diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx
index faabc9df..0ae71741 100644
--- a/components/chat/ChatInput.tsx
+++ b/components/chat/ChatInput.tsx
@@ -1,14 +1,28 @@
-import { useRef, useEffect, useState } from 'react';
-import { FileText, Loader2, Paperclip, Send, X } from 'lucide-react';
-import { useChat } from '@/context/ChatProvider';
-import { MessageAttachment } from '@/types/chat';
-import { extractTextFromPdf } from '@/utils/pdfUtils';
+import { useRef, useEffect, useState } from "react";
+import { FileText, Loader2, Paperclip, Send, X } from "lucide-react";
+import { useChat } from "@/context/ChatProvider";
+import { MessageAttachment } from "@/types/chat";
+import { extractTextFromPdf } from "@/utils/pdfUtils";
+import { saveFile } from "@/utils/indexedDb";
+
+// File upload constants
+const MAX_FILE_SIZE_MB = 10;
+const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
+const ACCEPTED_FILE_TYPES = ["application/pdf"];
+const ACCEPTED_IMAGE_TYPES = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+];
interface ChatInputProps {
inputMessage: string;
setInputMessage: (message: string) => void;
uploadedAttachments: MessageAttachment[];
- setUploadedAttachments: React.Dispatch
>;
+ setUploadedAttachments: React.Dispatch<
+ React.SetStateAction
+ >;
sendMessage: () => void;
isLoading: boolean;
isAuthenticated: boolean;
@@ -31,7 +45,7 @@ export default function ChatInput({
setTextareaHeight,
isSidebarCollapsed,
isMobile,
- hasMessages
+ hasMessages,
}: ChatInputProps) {
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
@@ -40,7 +54,8 @@ export default function ChatInput({
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const { isSidebarOpen } = useChat();
- const unifiedBgClass = isMobile && isSidebarOpen ? 'bg-[#181818]' : 'bg-[#181818]';
+ const unifiedBgClass =
+ isMobile && isSidebarOpen ? "bg-[#181818]" : "bg-[#181818]";
const maxTextareaHeight = isMobile ? 176 : 240;
// Handle centering when messages change from external updates
@@ -59,28 +74,33 @@ export default function ChatInput({
if (!textareaRef.current) return;
const textarea = textareaRef.current;
let textareaOnlyHeight = 48;
-
- if (inputMessage === '') {
- textarea.style.height = '48px';
+
+ if (inputMessage === "") {
+ textarea.style.height = "48px";
textareaOnlyHeight = 48;
} else {
- textarea.style.height = 'auto';
+ textarea.style.height = "auto";
textareaOnlyHeight = Math.min(textarea.scrollHeight, maxTextareaHeight);
- textarea.style.height = textareaOnlyHeight + 'px';
+ textarea.style.height = textareaOnlyHeight + "px";
}
-
+
// Calculate total input container height including attachments
// Attachment row adds ~88px (64px height + 12px top padding + 8px bottom padding + 4px bottom margin)
const attachmentHeight = uploadedAttachments.length > 0 ? 88 : 0;
const totalHeight = textareaOnlyHeight + attachmentHeight;
setTextareaHeight(totalHeight);
- }, [inputMessage, maxTextareaHeight, setTextareaHeight, uploadedAttachments.length]);
+ }, [
+ inputMessage,
+ maxTextareaHeight,
+ setTextareaHeight,
+ uploadedAttachments.length,
+ ]);
const handleSendMessage = () => {
if (isLoading) {
return;
}
-
+
if (isCentered) {
// Don't trigger multiple animations - let the useEffect handle it
sendMessage();
@@ -90,77 +110,120 @@ export default function ChatInput({
};
const createAttachmentId = () => {
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
return crypto.randomUUID();
}
return `attachment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
const getAttachmentLabel = (mimeType: string) => {
- if (mimeType === 'application/pdf') return 'PDF';
- if (mimeType.startsWith('image/')) {
- return mimeType.replace('image/', '').toUpperCase();
+ if (mimeType === "application/pdf") return "PDF";
+ if (mimeType.startsWith("image/")) {
+ return mimeType.replace("image/", "").toUpperCase();
}
return mimeType.toUpperCase();
};
- const handleFileUpload = async (event: React.ChangeEvent) => {
+ const handleFileUpload = async (
+ event: React.ChangeEvent
+ ) => {
const files = event.target.files;
if (!files) return;
- const acceptedMimeTypes = ['application/pdf'];
- const attachmentsToAdd: { attachment: MessageAttachment; file: File }[] = [];
+ const attachmentsToAdd: { attachment: MessageAttachment; file: File }[] =
+ [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
- const isImage = file.type.startsWith('image/');
- const isAcceptedFile = acceptedMimeTypes.includes(file.type);
+ const isImage = file.type.startsWith("image/");
+ const isAcceptedFile = ACCEPTED_FILE_TYPES.includes(file.type);
+
+ // Validate file type
+ if (!isImage && !isAcceptedFile) {
+ alert(
+ `File type "${file.type}" is not supported. Please upload images or PDF files.`
+ );
+ continue;
+ }
- if (!isImage && !isAcceptedFile) continue;
+ // Validate file size
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ alert(
+ `File "${file.name}" is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.`
+ );
+ continue;
+ }
try {
const dataUrl = await convertFileToBase64(file);
+
+ // Save to IndexedDB
+ let storageId: string | undefined;
+ try {
+ storageId = await saveFile(file);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ if (errorMessage.includes("quota")) {
+ alert(
+ "Storage is full. Your file will be available in this session but may not be saved in history."
+ );
+ } else {
+ console.warn("Failed to save file to storage:", errorMessage);
+ }
+ // Continue without storageId (will rely on base64 in memory)
+ }
+
const attachment: MessageAttachment = {
id: createAttachmentId(),
name: file.name,
mimeType: file.type,
size: file.size,
dataUrl,
- type: isImage ? 'image' : 'file'
+ type: isImage ? "image" : "file",
+ storageId,
};
attachmentsToAdd.push({ attachment, file });
} catch (error) {
- console.error('Error converting file to base64:', error);
+ console.error("Error converting file to base64:", error);
}
}
if (attachmentsToAdd.length > 0) {
setUploadedAttachments((prev) => [
...prev,
- ...attachmentsToAdd.map(item => item.attachment)
+ ...attachmentsToAdd.map((item) => item.attachment),
]);
attachmentsToAdd.forEach(({ attachment, file }) => {
- if (attachment.mimeType === 'application/pdf') {
+ if (attachment.mimeType === "application/pdf") {
extractTextFromPdf(file)
- .then(text => {
+ .then((text) => {
if (!text.trim()) return;
- setUploadedAttachments(prev =>
- prev.map(item =>
- item.id === attachment.id ? { ...item, textContent: text } : item
+ setUploadedAttachments((prev) =>
+ prev.map((item) =>
+ item.id === attachment.id
+ ? { ...item, textContent: text }
+ : item
)
);
})
- .catch(error => {
- console.warn('Failed to extract text from PDF attachment, continuing without text content.', error);
+ .catch((error) => {
+ console.warn(
+ "Failed to extract text from PDF attachment, continuing without text content.",
+ error
+ );
});
}
});
}
if (event.target) {
- event.target.value = '';
+ event.target.value = "";
}
};
@@ -169,7 +232,7 @@ export default function ChatInput({
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
- reader.onerror = error => reject(error);
+ reader.onerror = (error) => reject(error);
});
};
@@ -177,15 +240,17 @@ export default function ChatInput({
setUploadedAttachments((prev) => prev.filter((item) => item.id !== id));
};
- const handlePaste = async (event: React.ClipboardEvent) => {
+ const handlePaste = async (
+ event: React.ClipboardEvent
+ ) => {
const items = event.clipboardData?.items;
if (!items) return;
const imageItems: DataTransferItem[] = [];
-
+
// Collect all image items from clipboard
for (let i = 0; i < items.length; i++) {
- if (items[i].type.startsWith('image/')) {
+ if (items[i].type.startsWith("image/")) {
imageItems.push(items[i]);
}
}
@@ -195,33 +260,61 @@ export default function ChatInput({
// Prevent default paste behavior for images
event.preventDefault();
- const attachmentsToAdd: { attachment: MessageAttachment; file: File }[] = [];
+ const attachmentsToAdd: { attachment: MessageAttachment; file: File }[] =
+ [];
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;
+ // Validate file size
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ alert(
+ `Pasted image is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.`
+ );
+ continue;
+ }
+
try {
const dataUrl = await convertFileToBase64(file);
+
+ // Save to IndexedDB
+ let storageId: string | undefined;
+ try {
+ storageId = await saveFile(file);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ if (errorMessage.includes("quota")) {
+ alert(
+ "Storage is full. Your image will be available in this session but may not be saved in history."
+ );
+ }
+ // Continue without storageId
+ }
+
const attachment: MessageAttachment = {
id: createAttachmentId(),
- name: file.name || `pasted-image-${Date.now()}.${file.type.split('/')[1]}`,
+ name:
+ file.name ||
+ `pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
mimeType: file.type,
size: file.size,
dataUrl,
- type: 'image'
+ type: "image",
+ storageId,
};
attachmentsToAdd.push({ attachment, file });
} catch (error) {
- console.error('Error converting pasted image to base64:', error);
+ console.error("Error converting pasted image to base64:", error);
}
}
if (attachmentsToAdd.length > 0) {
setUploadedAttachments((prev) => [
...prev,
- ...attachmentsToAdd.map(item => item.attachment)
+ ...attachmentsToAdd.map((item) => item.attachment),
]);
}
};
@@ -259,7 +352,7 @@ export default function ChatInput({
// Validate file type - accept images and PDFs
const isImage = file.type.startsWith("image/");
const isPdf = file.type === "application/pdf";
-
+
if (!isImage && !isPdf) {
alert("Please select an image or PDF file");
return;
@@ -271,21 +364,40 @@ export default function ChatInput({
return;
}
- // Validate file size (max 10MB)
- if (file.size > 10 * 1024 * 1024) {
- alert("File size must be less than 10MB");
+ // Validate file size
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ alert(
+ `File "${file.name}" is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.`
+ );
return;
}
try {
const dataUrl = await convertFileToBase64(file);
+
+ // Save to IndexedDB
+ let storageId: string | undefined;
+ try {
+ storageId = await saveFile(file);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ if (errorMessage.includes("quota")) {
+ alert(
+ "Storage is full. Your file will be available in this session but may not be saved in history."
+ );
+ }
+ // Continue without storageId
+ }
+
const attachment: MessageAttachment = {
id: createAttachmentId(),
name: file.name,
mimeType: file.type,
size: file.size,
dataUrl,
- type: isImage ? 'image' : 'file'
+ type: isImage ? "image" : "file",
+ storageId,
};
setUploadedAttachments((prev) => [...prev, attachment]);
@@ -293,20 +405,25 @@ export default function ChatInput({
// Extract text from PDF if applicable
if (isPdf) {
extractTextFromPdf(file)
- .then(text => {
+ .then((text) => {
if (!text.trim()) return;
- setUploadedAttachments(prev =>
- prev.map(item =>
- item.id === attachment.id ? { ...item, textContent: text } : item
+ setUploadedAttachments((prev) =>
+ prev.map((item) =>
+ item.id === attachment.id
+ ? { ...item, textContent: text }
+ : item
)
);
})
- .catch(error => {
- console.warn('Failed to extract text from PDF attachment, continuing without text content.', error);
+ .catch((error) => {
+ console.warn(
+ "Failed to extract text from PDF attachment, continuing without text content.",
+ error
+ );
});
}
} catch (error) {
- console.error('Error processing file:', error);
+ console.error("Error processing file:", error);
}
};
@@ -329,13 +446,19 @@ export default function ChatInput({
<>
{/* Greeting message when centered */}
{isCentered && (
-
@@ -347,31 +470,49 @@ export default function ChatInput({
)}
{/* Chat Input Container */}
-
-
+
{/* Unified Input Container with Attachment Preview Inside */}
-
0 && (
{uploadedAttachments.map((attachment, index) => (
-
- {attachment.type === 'image' ? (
+ {attachment.type === "image" ? (

) : (
-
+
-
+
{attachment.name}
-
{getAttachmentLabel(attachment.mimeType)}
+
+ {getAttachmentLabel(attachment.mimeType)}
+
)}