This commit is contained in:
philschmid
2025-05-29 15:46:39 -07:00
parent abd4403858
commit 09971ff55e
48 changed files with 9638 additions and 0 deletions

184
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { useState, useEffect, useRef, useCallback } from "react";
import { ProcessedEvent } from "@/components/ActivityTimeline";
import { WelcomeScreen } from "@/components/WelcomeScreen";
import { ChatMessagesView } from "@/components/ChatMessagesView";
export default function App() {
const [processedEventsTimeline, setProcessedEventsTimeline] = useState<
ProcessedEvent[]
>([]);
const [historicalActivities, setHistoricalActivities] = useState<
Record<string, ProcessedEvent[]>
>({});
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasFinalizeEventOccurredRef = useRef(false);
const thread = useStream<{
messages: Message[];
initial_search_query_count: number;
max_research_loops: number;
reasoning_model: string;
}>({
apiUrl: import.meta.env.DEV
? "http://localhost:2024"
: "http://localhost:8123",
assistantId: "agent",
messagesKey: "messages",
onFinish: (event: any) => {
console.log(event);
},
onUpdateEvent: (event: any) => {
let processedEvent: ProcessedEvent | null = null;
if (event.generate_query) {
processedEvent = {
title: "Generating Search Queries",
data: event.generate_query.query_list.join(", "),
};
} else if (event.web_research) {
const sources = event.web_research.sources_gathered || [];
const numSources = sources.length;
const uniqueLabels = [
...new Set(sources.map((s: any) => s.label).filter(Boolean)),
];
const exampleLabels = uniqueLabels.slice(0, 3).join(", ");
processedEvent = {
title: "Web Research",
data: `Gathered ${numSources} sources. Related to: ${
exampleLabels || "N/A"
}.`,
};
} else if (event.reflection) {
processedEvent = {
title: "Reflection",
data: event.reflection.is_sufficient
? "Search successful, generating final answer."
: `Need more information, searching for ${event.reflection.follow_up_queries.join(
", "
)}`,
};
} else if (event.finalize_answer) {
processedEvent = {
title: "Finalizing Answer",
data: "Composing and presenting the final answer.",
};
hasFinalizeEventOccurredRef.current = true;
}
if (processedEvent) {
setProcessedEventsTimeline((prevEvents) => [
...prevEvents,
processedEvent!,
]);
}
},
});
useEffect(() => {
if (scrollAreaRef.current) {
const scrollViewport = scrollAreaRef.current.querySelector(
"[data-radix-scroll-area-viewport]"
);
if (scrollViewport) {
scrollViewport.scrollTop = scrollViewport.scrollHeight;
}
}
}, [thread.messages]);
useEffect(() => {
if (
hasFinalizeEventOccurredRef.current &&
!thread.isLoading &&
thread.messages.length > 0
) {
const lastMessage = thread.messages[thread.messages.length - 1];
if (lastMessage && lastMessage.type === "ai" && lastMessage.id) {
setHistoricalActivities((prev) => ({
...prev,
[lastMessage.id!]: [...processedEventsTimeline],
}));
}
hasFinalizeEventOccurredRef.current = false;
}
}, [thread.messages, thread.isLoading, processedEventsTimeline]);
const handleSubmit = useCallback(
(submittedInputValue: string, effort: string, model: string) => {
if (!submittedInputValue.trim()) return;
setProcessedEventsTimeline([]);
hasFinalizeEventOccurredRef.current = false;
// convert effort to, initial_search_query_count and max_research_loops
// low means max 1 loop and 1 query
// medium means max 3 loops and 3 queries
// high means max 10 loops and 5 queries
let initial_search_query_count = 0;
let max_research_loops = 0;
switch (effort) {
case "low":
initial_search_query_count = 1;
max_research_loops = 1;
break;
case "medium":
initial_search_query_count = 3;
max_research_loops = 3;
break;
case "high":
initial_search_query_count = 5;
max_research_loops = 10;
break;
}
const newMessages: Message[] = [
...(thread.messages || []),
{
type: "human",
content: submittedInputValue,
id: Date.now().toString(),
},
];
thread.submit({
messages: newMessages,
initial_search_query_count: initial_search_query_count,
max_research_loops: max_research_loops,
reasoning_model: model,
});
},
[thread]
);
const handleCancel = useCallback(() => {
thread.stop();
window.location.reload();
}, [thread]);
return (
<div className="flex h-screen bg-neutral-800 text-neutral-100 font-sans antialiased">
<main className="flex-1 flex flex-col overflow-hidden max-w-4xl mx-auto w-full">
<div
className={`flex-1 overflow-y-auto ${
thread.messages.length === 0 ? "flex" : ""
}`}
>
{thread.messages.length === 0 ? (
<WelcomeScreen
handleSubmit={handleSubmit}
isLoading={thread.isLoading}
onCancel={handleCancel}
/>
) : (
<ChatMessagesView
messages={thread.messages}
isLoading={thread.isLoading}
scrollAreaRef={scrollAreaRef}
onSubmit={handleSubmit}
onCancel={handleCancel}
liveActivityEvents={processedEventsTimeline}
historicalActivities={historicalActivities}
/>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Loader2,
Activity,
Info,
Search,
TextSearch,
Brain,
Pen,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { useEffect, useState } from "react";
export interface ProcessedEvent {
title: string;
data: any;
}
interface ActivityTimelineProps {
processedEvents: ProcessedEvent[];
isLoading: boolean;
}
export function ActivityTimeline({
processedEvents,
isLoading,
}: ActivityTimelineProps) {
const [isTimelineCollapsed, setIsTimelineCollapsed] =
useState<boolean>(false);
const getEventIcon = (title: string, index: number) => {
if (index === 0 && isLoading && processedEvents.length === 0) {
return <Loader2 className="h-4 w-4 text-neutral-400 animate-spin" />;
}
if (title.toLowerCase().includes("generating")) {
return <TextSearch className="h-4 w-4 text-neutral-400" />;
} else if (title.toLowerCase().includes("thinking")) {
return <Loader2 className="h-4 w-4 text-neutral-400 animate-spin" />;
} else if (title.toLowerCase().includes("reflection")) {
return <Brain className="h-4 w-4 text-neutral-400" />;
} else if (title.toLowerCase().includes("research")) {
return <Search className="h-4 w-4 text-neutral-400" />;
} else if (title.toLowerCase().includes("finalizing")) {
return <Pen className="h-4 w-4 text-neutral-400" />;
}
return <Activity className="h-4 w-4 text-neutral-400" />;
};
useEffect(() => {
if (!isLoading && processedEvents.length !== 0) {
setIsTimelineCollapsed(true);
}
}, [isLoading, processedEvents]);
return (
<Card className="border-none rounded-lg bg-neutral-700 max-h-96">
<CardHeader>
<CardDescription className="flex items-center justify-between">
<div
className="flex items-center justify-start text-sm w-full cursor-pointer gap-2 text-neutral-100"
onClick={() => setIsTimelineCollapsed(!isTimelineCollapsed)}
>
Research
{isTimelineCollapsed ? (
<ChevronDown className="h-4 w-4 mr-2" />
) : (
<ChevronUp className="h-4 w-4 mr-2" />
)}
</div>
</CardDescription>
</CardHeader>
{!isTimelineCollapsed && (
<ScrollArea className="max-h-96 overflow-y-auto">
<CardContent>
{isLoading && processedEvents.length === 0 && (
<div className="relative pl-8 pb-4">
<div className="absolute left-3 top-3.5 h-full w-0.5 bg-neutral-800" />
<div className="absolute left-0.5 top-2 h-5 w-5 rounded-full bg-neutral-800 flex items-center justify-center ring-4 ring-neutral-900">
<Loader2 className="h-3 w-3 text-neutral-400 animate-spin" />
</div>
<div>
<p className="text-sm text-neutral-300 font-medium">
Searching...
</p>
</div>
</div>
)}
{processedEvents.length > 0 ? (
<div className="space-y-0">
{processedEvents.map((eventItem, index) => (
<div key={index} className="relative pl-8 pb-4">
{index < processedEvents.length - 1 ||
(isLoading && index === processedEvents.length - 1) ? (
<div className="absolute left-3 top-3.5 h-full w-0.5 bg-neutral-600" />
) : null}
<div className="absolute left-0.5 top-2 h-6 w-6 rounded-full bg-neutral-600 flex items-center justify-center ring-4 ring-neutral-700">
{getEventIcon(eventItem.title, index)}
</div>
<div>
<p className="text-sm text-neutral-200 font-medium mb-0.5">
{eventItem.title}
</p>
<p className="text-xs text-neutral-300 leading-relaxed">
{typeof eventItem.data === "string"
? eventItem.data
: Array.isArray(eventItem.data)
? (eventItem.data as string[]).join(", ")
: JSON.stringify(eventItem.data)}
</p>
</div>
</div>
))}
{isLoading && processedEvents.length > 0 && (
<div className="relative pl-8 pb-4">
<div className="absolute left-0.5 top-2 h-5 w-5 rounded-full bg-neutral-600 flex items-center justify-center ring-4 ring-neutral-700">
<Loader2 className="h-3 w-3 text-neutral-400 animate-spin" />
</div>
<div>
<p className="text-sm text-neutral-300 font-medium">
Searching...
</p>
</div>
</div>
)}
</div>
) : !isLoading ? ( // Only show "No activity" if not loading and no events
<div className="flex flex-col items-center justify-center h-full text-neutral-500 pt-10">
<Info className="h-6 w-6 mb-3" />
<p className="text-sm">No activity to display.</p>
<p className="text-xs text-neutral-600 mt-1">
Timeline will update during processing.
</p>
</div>
) : null}
</CardContent>
</ScrollArea>
)}
</Card>
);
}

View File

@@ -0,0 +1,321 @@
import type React from "react";
import type { Message } from "@langchain/langgraph-sdk";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Copy, CopyCheck } from "lucide-react";
import { InputForm } from "@/components/InputForm";
import { Button } from "@/components/ui/button";
import { useState, ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import {
ActivityTimeline,
ProcessedEvent,
} from "@/components/ActivityTimeline"; // Assuming ActivityTimeline is in the same dir or adjust path
// Markdown component props type from former ReportView
type MdComponentProps = {
className?: string;
children?: ReactNode;
[key: string]: any;
};
// Markdown components (from former ReportView.tsx)
const mdComponents = {
h1: ({ className, children, ...props }: MdComponentProps) => (
<h1 className={cn("text-2xl font-bold mt-4 mb-2", className)} {...props}>
{children}
</h1>
),
h2: ({ className, children, ...props }: MdComponentProps) => (
<h2 className={cn("text-xl font-bold mt-3 mb-2", className)} {...props}>
{children}
</h2>
),
h3: ({ className, children, ...props }: MdComponentProps) => (
<h3 className={cn("text-lg font-bold mt-3 mb-1", className)} {...props}>
{children}
</h3>
),
p: ({ className, children, ...props }: MdComponentProps) => (
<p className={cn("mb-3 leading-7", className)} {...props}>
{children}
</p>
),
a: ({ className, children, href, ...props }: MdComponentProps) => (
<Badge className="text-xs mx-0.5">
<a
className={cn("text-blue-400 hover:text-blue-300 text-xs", className)}
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
</Badge>
),
ul: ({ className, children, ...props }: MdComponentProps) => (
<ul className={cn("list-disc pl-6 mb-3", className)} {...props}>
{children}
</ul>
),
ol: ({ className, children, ...props }: MdComponentProps) => (
<ol className={cn("list-decimal pl-6 mb-3", className)} {...props}>
{children}
</ol>
),
li: ({ className, children, ...props }: MdComponentProps) => (
<li className={cn("mb-1", className)} {...props}>
{children}
</li>
),
blockquote: ({ className, children, ...props }: MdComponentProps) => (
<blockquote
className={cn(
"border-l-4 border-neutral-600 pl-4 italic my-3 text-sm",
className
)}
{...props}
>
{children}
</blockquote>
),
code: ({ className, children, ...props }: MdComponentProps) => (
<code
className={cn(
"bg-neutral-900 rounded px-1 py-0.5 font-mono text-xs",
className
)}
{...props}
>
{children}
</code>
),
pre: ({ className, children, ...props }: MdComponentProps) => (
<pre
className={cn(
"bg-neutral-900 p-3 rounded-lg overflow-x-auto font-mono text-xs my-3",
className
)}
{...props}
>
{children}
</pre>
),
hr: ({ className, ...props }: MdComponentProps) => (
<hr className={cn("border-neutral-600 my-4", className)} {...props} />
),
table: ({ className, children, ...props }: MdComponentProps) => (
<div className="my-3 overflow-x-auto">
<table className={cn("border-collapse w-full", className)} {...props}>
{children}
</table>
</div>
),
th: ({ className, children, ...props }: MdComponentProps) => (
<th
className={cn(
"border border-neutral-600 px-3 py-2 text-left font-bold",
className
)}
{...props}
>
{children}
</th>
),
td: ({ className, children, ...props }: MdComponentProps) => (
<td
className={cn("border border-neutral-600 px-3 py-2", className)}
{...props}
>
{children}
</td>
),
};
// Props for HumanMessageBubble
interface HumanMessageBubbleProps {
message: Message;
mdComponents: typeof mdComponents;
}
// HumanMessageBubble Component
const HumanMessageBubble: React.FC<HumanMessageBubbleProps> = ({
message,
mdComponents,
}) => {
return (
<div
className={`text-white rounded-3xl break-words min-h-7 bg-neutral-700 max-w-[100%] sm:max-w-[90%] px-4 pt-3 rounded-br-lg`}
>
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
</div>
);
};
// Props for AiMessageBubble
interface AiMessageBubbleProps {
message: Message;
historicalActivity: ProcessedEvent[] | undefined;
liveActivity: ProcessedEvent[] | undefined;
isLastMessage: boolean;
isOverallLoading: boolean;
mdComponents: typeof mdComponents;
handleCopy: (text: string, messageId: string) => void;
copiedMessageId: string | null;
}
// AiMessageBubble Component
const AiMessageBubble: React.FC<AiMessageBubbleProps> = ({
message,
historicalActivity,
liveActivity,
isLastMessage,
isOverallLoading,
mdComponents,
handleCopy,
copiedMessageId,
}) => {
// Determine which activity events to show and if it's for a live loading message
const activityForThisBubble =
isLastMessage && isOverallLoading ? liveActivity : historicalActivity;
const isLiveActivityForThisBubble = isLastMessage && isOverallLoading;
return (
<div className={`relative break-words flex flex-col`}>
{activityForThisBubble && activityForThisBubble.length > 0 && (
<div className="mb-3 border-b border-neutral-700 pb-3 text-xs">
<ActivityTimeline
processedEvents={activityForThisBubble}
isLoading={isLiveActivityForThisBubble}
/>
</div>
)}
<ReactMarkdown components={mdComponents}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</ReactMarkdown>
<Button
variant="default"
className="cursor-pointer bg-neutral-700 border-neutral-600 text-neutral-300 self-end"
onClick={() =>
handleCopy(
typeof message.content === "string"
? message.content
: JSON.stringify(message.content),
message.id!
)
}
>
{copiedMessageId === message.id ? "Copied" : "Copy"}
{copiedMessageId === message.id ? <CopyCheck /> : <Copy />}
</Button>
</div>
);
};
interface ChatMessagesViewProps {
messages: Message[];
isLoading: boolean;
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
onSubmit: (inputValue: string, effort: string, model: string) => void;
onCancel: () => void;
liveActivityEvents: ProcessedEvent[];
historicalActivities: Record<string, ProcessedEvent[]>;
}
export function ChatMessagesView({
messages,
isLoading,
scrollAreaRef,
onSubmit,
onCancel,
liveActivityEvents,
historicalActivities,
}: ChatMessagesViewProps) {
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const handleCopy = async (text: string, messageId: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedMessageId(messageId);
setTimeout(() => setCopiedMessageId(null), 2000); // Reset after 2 seconds
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<div className="flex flex-col h-full">
<ScrollArea className="flex-grow" ref={scrollAreaRef}>
<div className="p-4 md:p-6 space-y-2 max-w-4xl mx-auto pt-16">
{messages.map((message, index) => {
const isLast = index === messages.length - 1;
return (
<div key={message.id || `msg-${index}`} className="space-y-3">
<div
className={`flex items-start gap-3 ${
message.type === "human" ? "justify-end" : ""
}`}
>
{message.type === "human" ? (
<HumanMessageBubble
message={message}
mdComponents={mdComponents}
/>
) : (
<AiMessageBubble
message={message}
historicalActivity={historicalActivities[message.id!]}
liveActivity={liveActivityEvents} // Pass global live events
isLastMessage={isLast}
isOverallLoading={isLoading} // Pass global loading state
mdComponents={mdComponents}
handleCopy={handleCopy}
copiedMessageId={copiedMessageId}
/>
)}
</div>
</div>
);
})}
{isLoading &&
(messages.length === 0 ||
messages[messages.length - 1].type === "human") && (
<div className="flex items-start gap-3 mt-3">
{" "}
{/* AI message row structure */}
<div className="relative group max-w-[85%] md:max-w-[80%] rounded-xl p-3 shadow-sm break-words bg-neutral-800 text-neutral-100 rounded-bl-none w-full min-h-[56px]">
{liveActivityEvents.length > 0 ? (
<div className="text-xs">
<ActivityTimeline
processedEvents={liveActivityEvents}
isLoading={true}
/>
</div>
) : (
<div className="flex items-center justify-start h-full">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400 mr-2" />
<span>Processing...</span>
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
<InputForm
onSubmit={onSubmit}
isLoading={isLoading}
onCancel={onCancel}
hasHistory={messages.length > 0}
/>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { SquarePen, Brain, Send, StopCircle, Zap, Cpu } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Updated InputFormProps
interface InputFormProps {
onSubmit: (inputValue: string, effort: string, model: string) => void;
onCancel: () => void;
isLoading: boolean;
hasHistory: boolean;
}
export const InputForm: React.FC<InputFormProps> = ({
onSubmit,
onCancel,
isLoading,
hasHistory,
}) => {
const [internalInputValue, setInternalInputValue] = useState("");
const [effort, setEffort] = useState("medium");
const [model, setModel] = useState("gemini-2.5-flash-preview-04-17");
const handleInternalSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!internalInputValue.trim()) return;
onSubmit(internalInputValue, effort, model);
setInternalInputValue("");
};
const handleInternalKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInternalSubmit();
}
};
const isSubmitDisabled = !internalInputValue.trim() || isLoading;
return (
<form
onSubmit={handleInternalSubmit}
className={`flex flex-col gap-2 p-3 `}
>
<div
className={`flex flex-row items-center justify-between text-white rounded-3xl rounded-bl-sm ${
hasHistory ? "rounded-br-sm" : ""
} break-words min-h-7 bg-neutral-700 px-4 pt-3 `}
>
<Textarea
value={internalInputValue}
onChange={(e) => setInternalInputValue(e.target.value)}
onKeyDown={handleInternalKeyDown}
placeholder="Who won the Euro 2024 and scored the most goals?"
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
md:text-base min-h-[56px] max-h-[200px]`}
rows={1}
/>
<div className="-mt-3">
{isLoading ? (
<Button
type="button"
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-400 hover:bg-red-500/10 p-2 cursor-pointer rounded-full transition-all duration-200"
onClick={onCancel}
>
<StopCircle className="h-5 w-5" />
</Button>
) : (
<Button
type="submit"
variant="ghost"
className={`${
isSubmitDisabled
? "text-neutral-500"
: "text-blue-500 hover:text-blue-400 hover:bg-blue-500/10"
} p-2 cursor-pointer rounded-full transition-all duration-200 text-base`}
disabled={isSubmitDisabled}
>
Search
<Send className="h-5 w-5" />
</Button>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-row gap-2">
<div className="flex flex-row gap-2 bg-neutral-700 border-neutral-600 text-neutral-300 focus:ring-neutral-500 rounded-xl rounded-t-sm pl-2 max-w-[100%] sm:max-w-[90%]">
<div className="flex flex-row items-center text-sm">
<Brain className="h-4 w-4 mr-2" />
Effort
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-[120px] bg-transparent border-none cursor-pointer">
<SelectValue placeholder="Effort" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem
value="low"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
Low
</SelectItem>
<SelectItem
value="medium"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
Medium
</SelectItem>
<SelectItem
value="high"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
High
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row gap-2 bg-neutral-700 border-neutral-600 text-neutral-300 focus:ring-neutral-500 rounded-xl rounded-t-sm pl-2 max-w-[100%] sm:max-w-[90%]">
<div className="flex flex-row items-center text-sm ml-2">
<Cpu className="h-4 w-4 mr-2" />
Model
</div>
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
<SelectItem
value="gemini-2.0-flash"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-yellow-400" /> 2.0 Flash
</div>
</SelectItem>
<SelectItem
value="gemini-2.5-flash-preview-04-17"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Zap className="h-4 w-4 mr-2 text-orange-400" /> 2.5 Flash
</div>
</SelectItem>
<SelectItem
value="gemini-2.5-pro-preview-05-06"
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
>
<div className="flex items-center">
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 2.5 Pro
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasHistory && (
<Button
className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer rounded-xl rounded-t-sm pl-2 "
variant="default"
onClick={() => window.location.reload()}
>
<SquarePen size={16} />
New Search
</Button>
)}
</div>
</form>
);
};

View File

@@ -0,0 +1,39 @@
import { InputForm } from "./InputForm";
interface WelcomeScreenProps {
handleSubmit: (
submittedInputValue: string,
effort: string,
model: string
) => void;
onCancel: () => void;
isLoading: boolean;
}
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
handleSubmit,
onCancel,
isLoading,
}) => (
<div className="flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4">
<div>
<h1 className="text-5xl md:text-6xl font-semibold text-neutral-100 mb-3">
Welcome to a new Search.
</h1>
<p className="text-xl md:text-2xl text-neutral-400">
How can I help you today?
</p>
</div>
<div className="w-full mt-4">
<InputForm
onSubmit={handleSubmit}
isLoading={isLoading}
onCancel={onCancel}
hasHistory={false}
/>
</div>
<p className="text-xs text-neutral-500">
Powered by Google Gemini and LangChain LangGraph.
</p>
</div>
);

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

154
frontend/src/global.css Normal file
View File

@@ -0,0 +1,154 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Animation Delays */
.animation-delay-200 { animation-delay: 0.2s; }
.animation-delay-400 { animation-delay: 0.4s; }
.animation-delay-600 { animation-delay: 0.6s; }
.animation-delay-800 { animation-delay: 0.8s; }
/* Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUpSmooth {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Animation Classes */
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
.animate-fadeInUp {
animation: fadeInUp 0.5s ease-out forwards;
}
.animate-fadeInUpSmooth {
animation: fadeInUpSmooth 0.3s ease-out forwards;
}
/* Ensure your body or html has a dark background if not already set, e.g.: */
/* body { background-color: #0c0c0d; } */ /* This is similar to neutral-950 */

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./global.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />