financial-data-analyst/app/finance/page.tsx (685 lines of code) (raw):
// app/finance/page.tsx
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Send,
ChevronDown,
Paperclip,
ChartLine,
ChartArea,
FileInput,
MessageCircleQuestion,
ChartColumnBig,
} from "lucide-react";
import FilePreview from "@/components/FilePreview";
import { ChartRenderer } from "@/components/ChartRenderer";
import { toast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { ChartData } from "@/types/chart";
import TopNavBar from "@/components/TopNavBar";
import {
readFileAsText,
readFileAsBase64,
readFileAsPDFText,
} from "@/utils/fileHandling";
// Types
interface Message {
id: string;
role: string;
content: string;
hasToolUse?: boolean;
file?: {
base64: string;
fileName: string;
mediaType: string;
isText?: boolean;
};
chartData?: ChartData;
}
type Model = {
id: string;
name: string;
};
interface FileUpload {
base64: string;
fileName: string;
mediaType: string;
isText?: boolean;
fileSize?: number;
}
const models: Model[] = [
{ id: "claude-3-haiku-20240307", name: "Claude 3 Haiku" },
{ id: "claude-3-5-sonnet-20240620", name: "Claude 3.5 Sonnet" },
];
// Updated APIResponse interface
interface APIResponse {
content: string;
hasToolUse: boolean;
toolUse?: {
type: "tool_use";
id: string;
name: string;
input: ChartData;
};
chartData?: ChartData;
}
interface MessageComponentProps {
message: Message;
}
const SafeChartRenderer: React.FC<{ data: ChartData }> = ({ data }) => {
try {
return (
<div className="w-full h-full p-6 flex flex-col">
<div className="w-[90%] flex-1 mx-auto">
<ChartRenderer data={data} />
</div>
</div>
);
} catch (error) {
console.error("Chart rendering error:", error);
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return (
<div className="text-red-500">Error rendering chart: {errorMessage}</div>
);
}
};
const MessageComponent: React.FC<MessageComponentProps> = ({ message }) => {
console.log("Message with chart data:", message); // Add this line for debugging
return (
<div className="flex items-start gap-2">
{message.role === "assistant" && (
<Avatar className="w-8 h-8 border">
<AvatarImage src="/ant-logo.svg" alt="AI Assistant Avatar" />
<AvatarFallback>AI</AvatarFallback>
</Avatar>
)}
<div
className={`flex flex-col max-w-[75%] ${
message.role === "user" ? "ml-auto" : ""
}`}
>
<div
className={`p-3 rounded-md text-base ${
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted border"
}`}
>
{message.content === "thinking" ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
{message.hasToolUse ? (
<div className="flex flex-col gap-2">
<Badge variant="secondary" className="inline-flex">
<ChartLine className="w-4 h-4 mr-1" /> Generated Chart
</Badge>
<span>Thinking...</span>
</div>
) : (
<span>Thinking...</span>
)}
</div>
) : message.role === "assistant" ? (
<div className="flex flex-col gap-2">
{message.hasToolUse && (
<Badge variant="secondary" className="inline-flex px-0">
<ChartLine className="w-4 h-4 mr-1" /> Generated Chart
</Badge>
)}
<span>{message.content}</span>
</div>
) : (
<span>{message.content}</span>
)}
</div>
{message.file && (
<div className="mt-1.5">
<FilePreview file={message.file} size="small" />
</div>
)}
</div>
</div>
);
};
const ChartPagination = ({
total,
current,
onDotClick,
}: {
total: number;
current: number;
onDotClick: (index: number) => void;
}) => (
<div className="fixed right-12 top-1/2 -translate-y-1/2 flex flex-col gap-2">
{Array.from({ length: total }).map((_, i) => (
<button
key={i}
onClick={() => onDotClick(i)}
className={`w-2 h-2 rounded-full transition-all ${
i === current
? "bg-primary scale-125"
: "bg-muted hover:bg-primary/50"
}`}
/>
))}
</div>
);
export default function AIChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState(
"claude-3-5-sonnet-20240620",
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const chartEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentUpload, setCurrentUpload] = useState<FileUpload | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [currentChartIndex, setCurrentChartIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const [isScrollLocked, setIsScrollLocked] = useState(false);
useEffect(() => {
const scrollToBottom = () => {
if (!messagesEndRef.current) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
};
// Scroll when messages change or when loading state changes
const timeoutId = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timeoutId);
}, [messages, isLoading]); // Add isLoading to dependencies
useEffect(() => {
if (!messagesEndRef.current) return;
const observer = new ResizeObserver(() => {
if (!isScrollLocked) {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}
});
observer.observe(messagesEndRef.current);
return () => observer.disconnect();
}, [isScrollLocked]);
const handleChartScroll = useCallback(() => {
if (!contentRef.current) return;
const { scrollTop, clientHeight } = contentRef.current;
const newIndex = Math.round(scrollTop / clientHeight);
setCurrentChartIndex(newIndex);
}, []);
const scrollToChart = (index: number) => {
if (!contentRef.current) return;
const targetScroll = index * contentRef.current.clientHeight;
contentRef.current.scrollTo({
top: targetScroll,
behavior: "smooth",
});
};
useEffect(() => {
const scrollToNewestChart = () => {
const chartsCount = messages.filter((m) => m.chartData).length;
if (chartsCount > 0) {
setCurrentChartIndex(chartsCount - 1);
scrollToChart(chartsCount - 1);
}
};
const lastChartIndex = messages.findLastIndex((m) => m.chartData);
if (lastChartIndex !== -1) {
setTimeout(scrollToNewestChart, 100);
}
}, [messages]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
// Create a ref to store the toast handlers
let loadingToastRef: { dismiss: () => void } | undefined;
if (file.type === "application/pdf") {
loadingToastRef = toast({
title: "Processing PDF",
description: "Extracting text content...",
duration: Infinity, // This will keep the toast until we dismiss it
});
}
try {
const isImage = file.type.startsWith("image/");
const isPDF = file.type === "application/pdf";
let base64Data = "";
let isText = false;
if (isImage) {
base64Data = await readFileAsBase64(file);
isText = false;
} else if (isPDF) {
try {
const pdfText = await readFileAsPDFText(file);
base64Data = btoa(encodeURIComponent(pdfText));
isText = true;
} catch (error) {
console.error("Failed to parse PDF:", error);
toast({
title: "PDF parsing failed",
description: "Unable to extract text from the PDF",
variant: "destructive",
});
return;
}
} else {
try {
const textContent = await readFileAsText(file);
base64Data = btoa(encodeURIComponent(textContent));
isText = true;
} catch (error) {
console.error("Failed to read as text:", error);
toast({
title: "Invalid file type",
description: "File must be readable as text, PDF, or be an image",
variant: "destructive",
});
return;
}
}
setCurrentUpload({
base64: base64Data,
fileName: file.name,
mediaType: isText ? "text/plain" : file.type,
isText,
});
toast({
title: "File uploaded",
description: `${file.name} ready to analyze`,
});
} catch (error) {
console.error("Error processing file:", error);
toast({
title: "Upload failed",
description: "Failed to process the file",
variant: "destructive",
});
} finally {
setIsUploading(false);
if (loadingToastRef) {
loadingToastRef.dismiss(); // Use the dismiss method from the toast ref
// Show success toast for PDF
if (file.type === "application/pdf") {
toast({
title: "PDF Processed",
description: "Text extracted successfully",
});
}
}
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!input.trim() && !currentUpload) return;
if (isLoading) return;
setIsScrollLocked(true);
const userMessage: Message = {
id: crypto.randomUUID(),
role: "user",
content: input,
file: currentUpload || undefined,
};
const thinkingMessage: Message = {
id: crypto.randomUUID(),
role: "assistant",
content: "thinking",
};
// Update messages in a single state update
setMessages((prev) => [...prev, userMessage, thinkingMessage]);
setInput("");
setIsLoading(true);
// Prepare all messages for the API request
const apiMessages = [...messages, userMessage].map((msg) => {
if (msg.file) {
if (msg.file.isText) {
// For text files, decode the content before sending
const decodedText = decodeURIComponent(atob(msg.file.base64));
return {
role: msg.role,
content: `File contents of ${msg.file.fileName}:\n\n${decodedText}\n\n${msg.content}`,
};
} else {
// Handle images as before
return {
role: msg.role,
content: [
{
type: "image",
source: {
type: "base64",
media_type: msg.file.mediaType,
data: msg.file.base64,
},
},
{
type: "text",
text: msg.content,
},
],
};
}
}
// Handle text-only messages
return {
role: msg.role,
content: msg.content,
};
});
const requestBody = {
messages: apiMessages,
model: selectedModel,
};
try {
const response = await fetch("/api/finance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: APIResponse = await response.json();
setMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = {
id: crypto.randomUUID(),
role: "assistant",
content: data.content,
hasToolUse: data.hasToolUse || !!data.toolUse,
chartData:
data.chartData || (data.toolUse?.input as ChartData) || null,
};
return newMessages;
});
setCurrentUpload(null);
} catch (error) {
console.error("Submit Error:", error);
setMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = {
id: crypto.randomUUID(),
role: "assistant",
content: "I apologize, but I encountered an error. Please try again.",
};
return newMessages;
});
} finally {
setIsLoading(false);
setIsScrollLocked(false);
// Force a final scroll after state updates
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() || currentUpload) {
const form = e.currentTarget.form;
if (form) {
const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
});
form.dispatchEvent(submitEvent);
}
}
}
};
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = event.target;
setInput(textarea.value);
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 300)}px`;
};
return (
<div className="flex flex-col h-screen">
<TopNavBar
features={{
showDomainSelector: false,
showViewModeSelector: false,
showPromptCaching: false,
}}
/>
<div className="flex-1 flex bg-background p-4 pt-0 gap-4 h-[calc(100vh-4rem)]">
{/* Chat Sidebar */}
<Card className="w-1/3 flex flex-col h-full">
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{messages.length > 0 && (
<>
<Avatar className="w-8 h-8 border">
<AvatarImage
src="/ant-logo.svg"
alt="AI Assistant Avatar"
/>
<AvatarFallback>AI</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg">
Financial Assistant
</CardTitle>
<CardDescription className="text-xs">
Powered by Claude
</CardDescription>
</div>
</>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-8 text-sm">
{models.find((m) => m.id === selectedModel)?.name}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{models.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => setSelectedModel(model.id)}
>
{model.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-4 scroll-smooth snap-y snap-mandatory">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up max-w-[95%] mx-auto">
<Avatar className="w-10 h-10 mb-4 border">
<AvatarImage
src="/ant-logo.svg"
alt="AI Assistant Avatar"
width={40}
height={40}
/>
</Avatar>
<h2 className="text-xl font-semibold mb-2">
Financial Assistant
</h2>
<div className="space-y-4 text-base">
<div className="flex items-center gap-3">
<ChartArea className="text-muted-foreground w-6 h-6" />
<p className="text-muted-foreground">
I can analyze financial data and create visualizations
from your files.
</p>
</div>
<div className="flex items-center gap-3">
<FileInput className="text-muted-foreground w-6 h-6" />
<p className="text-muted-foreground">
Upload CSVs, PDFs, or images and I'll help you
understand the data.
</p>
</div>
<div className="flex items-center gap-3">
<MessageCircleQuestion className="text-muted-foreground w-6 h-6" />
<p className="text-muted-foreground">
Ask questions about your financial data and I'll
create insightful charts.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-4 min-h-full">
{messages.map((message) => (
<div
key={message.id}
className={`animate-fade-in-up ${
message.content === "thinking" ? "animate-pulse" : ""
}`}
>
<MessageComponent message={message} />
</div>
))}
<div ref={messagesEndRef} className="h-4" />{" "}
{/* Add height to ensure scroll space */}
</div>
)}
</CardContent>
<CardFooter className="p-4 border-t">
<form onSubmit={handleSubmit} className="w-full">
<div className="flex flex-col space-y-2">
{currentUpload && (
<FilePreview
file={currentUpload}
onRemove={() => setCurrentUpload(null)}
/>
)}
<div className="flex items-end space-x-2">
<div className="flex-1 relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading || isUploading}
className="absolute left-2 top-1/2 -translate-y-1/2 h-8 w-8"
>
<Paperclip className="h-5 w-5" />
</Button>
<Textarea
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
disabled={isLoading}
className="min-h-[44px] h-[44px] resize-none pl-12 py-3 flex items-center"
rows={1}
/>
</div>
<Button
type="submit"
disabled={isLoading || (!input.trim() && !currentUpload)}
className="h-[44px]"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileSelect}
/>
</form>
</CardFooter>
</Card>
{/* Content Area */}
<Card className="flex-1 flex flex-col h-full overflow-hidden">
{messages.some((m) => m.chartData) && (
<CardHeader className="py-3 px-4 shrink-0">
<CardTitle className="text-lg">
Analysis & Visualizations
</CardTitle>
</CardHeader>
)}
<CardContent
ref={contentRef}
className="flex-1 overflow-y-auto min-h-0 snap-y snap-mandatory"
onScroll={handleChartScroll}
>
{messages.some((m) => m.chartData) ? (
<div className="min-h-full flex flex-col">
{messages.map(
(message, index) =>
message.chartData && (
<div
key={`chart-${index}`}
className="w-full min-h-full flex-shrink-0 snap-start snap-always"
ref={
index ===
messages.filter((m) => m.chartData).length - 1
? chartEndRef
: null
}
>
<SafeChartRenderer data={message.chartData} />
</div>
),
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center gap-4 -translate-y-8">
<ChartColumnBig className="w-8 h-8 text-muted-foreground" />
<div className="space-y-2">
<CardTitle className="text-lg">
Analysis & Visualizations
</CardTitle>
<CardDescription className="text-base">
Charts and detailed analysis will appear here as you chat
</CardDescription>
<div className="flex flex-wrap justify-center gap-2 mt-4">
<Badge variant="outline">Bar Charts</Badge>
<Badge variant="outline">Area Charts</Badge>
<Badge variant="outline">Linear Charts</Badge>
<Badge variant="outline">Pie Charts</Badge>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{messages.some((m) => m.chartData) && (
<ChartPagination
total={messages.filter((m) => m.chartData).length}
current={currentChartIndex}
onDotClick={scrollToChart}
/>
)}
</div>
);
}