mirror of
https://github.com/Zippland/NanoComic.git
synced 2026-03-02 16:23:26 +08:00
init
This commit is contained in:
184
frontend/src/App.tsx
Normal file
184
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/ActivityTimeline.tsx
Normal file
146
frontend/src/components/ActivityTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
frontend/src/components/ChatMessagesView.tsx
Normal file
321
frontend/src/components/ChatMessagesView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
frontend/src/components/InputForm.tsx
Normal file
180
frontend/src/components/InputForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
frontend/src/components/WelcomeScreen.tsx
Normal file
39
frontend/src/components/WelcomeScreen.tsx
Normal 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>
|
||||
);
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
59
frontend/src/components/ui/button.tsx
Normal file
59
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
183
frontend/src/components/ui/select.tsx
Normal file
183
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
64
frontend/src/components/ui/tabs.tsx
Normal file
64
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal 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
154
frontend/src/global.css
Normal 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 */
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
13
frontend/src/main.tsx
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user