feat(playground): add theme provider and toggle, implement chat input and loading components

This commit is contained in:
ngc2207 2025-01-07 18:53:17 +08:00
parent 265f49a669
commit 41eca2305c
26 changed files with 1245 additions and 27 deletions

View File

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.13",
"@auth/prisma-adapter": "^2.7.4",
"@monaco-editor/react": "^4.6.0",
"@prisma/client": "^6.1.0",
@ -19,6 +20,8 @@
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.1",
"ai": "^4.0.27",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"devicons-react": "^1.4.0",
@ -29,9 +32,11 @@
"monaco-languageclient": "5.0.1",
"next": "15.1.3",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.4",
"normalize-url": "~8.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^2.1.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vscode-languageclient": "~8.1.0",

6
public/ai.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fa7185"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ghost">
<path d="M9 10h.01" />
<path d="M15 10h.01" />
<path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@ -1,9 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0fb880"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bird">
<path d="M16 7h.01" />
<path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20" />
<path d="m20 7 2 .5-2 .5" />
<path d="M10 18v3" />
<path d="M14 17.75V21" />
<path d="M7 18a6 6 0 0 0 3.84-10.61" />
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#37bdf7"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ghost">
<path d="M9 10h.01" />
<path d="M15 10h.01" />
<path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z" />
</svg>

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 364 B

20
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,20 @@
import { streamText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
export const maxDuration = 30;
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
baseURL: process.env.OPENAI_BASE_URL || "",
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("deepseek-chat"),
messages,
});
return result.toDataStreamResponse();
}

View File

@ -2,6 +2,7 @@ import "@/app/globals.css";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@ -21,7 +22,16 @@ export default function RootLayout({
<body
className={cn("font-sans antialiased flex min-h-full", inter.variable)}
>
<main className="w-full">{children}</main>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<main className="w-full">
{children}
</main>
</ThemeProvider>
</body>
</html>
);

View File

@ -0,0 +1,20 @@
import { ArrowRight } from "lucide-react";
export default function Banner() {
return (
<div className="px-4 py-3 bg-zinc-800">
<p className="flex justify-center text-sm">
<a href="https://github.com/NGC2207/judge4c" className="group">
<span className="me-1 text-base leading-none"></span>
Introducing to Judge4c
<ArrowRight
className="-mt-0.5 ms-2 inline-flex opacity-60 transition-transform group-hover:translate-x-0.5"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</a>
</p>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { Play } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function Run() {
return (
<div className="inline-flex -space-x-px rounded-lg shadow-sm shadow-black/5 rtl:space-x-reverse">
<Button
className="rounded-none shadow-none first:rounded-s-lg last:rounded-e-lg focus-visible:z-10"
variant="outline"
>
<Play
className="-ms-1 me-2 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Run
</Button>
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import { useChat } from "ai/react";
import { Send } from "lucide-react";
import {
ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage,
} from "@/components/ui/chat/chat-bubble";
import {
ExpandableChatHeader,
ExpandableChatBody,
ExpandableChatFooter,
} from "@/components/ui/chat/expandable-chat";
import { Button } from "@/components/ui/button";
import { ChatInput } from "@/components/ui/chat/chat-input";
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div className="h-full w-full flex flex-col border sm:rounded-lg shadow-md overflow-hidden transition-all duration-200 ease-out">
<ExpandableChatHeader className="flex-col text-center justify-center border-[#3e4452]">
<h1 className="text-xl font-semibold">Chat with AI Assistant </h1>
</ExpandableChatHeader>
<ExpandableChatBody>
<ChatMessageList>
{messages.map((message, index) => (
<ChatBubble key={index} layout="ai">
<ChatBubbleAvatar
src={message.role === "user" ? "/default.svg" : "/ai.svg"}
fallback={message.role === "user" ? "US" : "AI"}
/>
<ChatBubbleMessage layout="ai" className="border-[#3e4452]">
{message.content}
</ChatBubbleMessage>
</ChatBubble>
))}
</ChatMessageList>
</ExpandableChatBody>
<ExpandableChatFooter className="border-[#3e4452]">
<form onSubmit={handleSubmit}>
<div className="flex items-center justify-between gap-2">
<ChatInput
value={input}
onChange={handleInputChange}
className="bg-[#3e4452]"
/>
<Button type="submit" size="icon">
<Send className="size-4" />
</Button>
</div>
</form>
</ExpandableChatFooter>
</div>
);
}

View File

@ -0,0 +1,28 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Home } from "lucide-react";
export default function Nav() {
return (
<Breadcrumb>
<BreadcrumbList className="rounded-lg border border-border bg-background px-3 py-2 shadow-sm shadow-black/5">
<BreadcrumbItem>
<BreadcrumbLink href="#">
<Home size={16} strokeWidth={2} aria-hidden="true" />
<span className="sr-only">Home</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Playground</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@ -0,0 +1,68 @@
import { Box, House, PanelsTopLeft } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Terminal() {
return (
<Tabs
defaultValue="tab-1"
orientation="vertical"
className="flex h-full w-full gap-2 py-3 pr-3"
>
<TabsList className="justify-start flex-col gap-1 rounded-none bg-transparent px-1 py-0 text-foreground">
<TabsTrigger
value="tab-1"
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
>
<House
className="-ms-0.5 me-1.5 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Overview
</TabsTrigger>
<TabsTrigger
value="tab-2"
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
>
<PanelsTopLeft
className="-ms-0.5 me-1.5 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Projects
</TabsTrigger>
<TabsTrigger
value="tab-3"
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
>
<Box
className="-ms-0.5 me-1.5 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Packages
</TabsTrigger>
</TabsList>
<div className="grow rounded-lg border border-border text-start bg-[#3e4452]">
<TabsContent value="tab-1">
<p className="px-4 py-1.5 text-xs text-muted-foreground">
Content for Tab 1
</p>
</TabsContent>
<TabsContent value="tab-2">
<p className="px-4 py-1.5 text-xs text-muted-foreground">
Content for Tab 2
</p>
</TabsContent>
<TabsContent value="tab-3">
<p className="px-4 py-1.5 text-xs text-muted-foreground">
Content for Tab 3
</p>
</TabsContent>
</div>
</Tabs>
);
}

View File

@ -0,0 +1,23 @@
import Run from "./button/run";
import Nav from "./nav";
import User from "./user";
export default function Tools() {
return (
<header className="relative bg-neutral-700 h-12">
<nav className="flex items-center h-full px-5">
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<Nav />
</div>
<div className="relative flex items-center gap-2">
<User />
</div>
</div>
</nav>
<div className="absolute left-1/2 top-0 h-full flex items-center -translate-x-1/2">
<Run />
</div>
</header>
);
}

View File

@ -0,0 +1,35 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export default function User() {
return (
<div className="relative">
<Avatar>
<AvatarImage src="/default.svg" alt="User Avatar" />
<AvatarFallback>KK</AvatarFallback>
</Avatar>
<span className="absolute -end-1 -top-1">
<span className="sr-only">Verified</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
className="fill-background"
d="M3.046 8.277A4.402 4.402 0 0 1 8.303 3.03a4.4 4.4 0 0 1 7.411 0 4.397 4.397 0 0 1 5.19 3.068c.207.713.23 1.466.067 2.19a4.4 4.4 0 0 1 0 7.415 4.403 4.403 0 0 1-3.06 5.187 4.398 4.398 0 0 1-2.186.072 4.398 4.398 0 0 1-7.422 0 4.398 4.398 0 0 1-5.257-5.248 4.4 4.4 0 0 1 0-7.437Z"
/>
<path
className="fill-primary"
d="M4.674 8.954a3.602 3.602 0 0 1 4.301-4.293 3.6 3.6 0 0 1 6.064 0 3.598 3.598 0 0 1 4.3 4.302 3.6 3.6 0 0 1 0 6.067 3.6 3.6 0 0 1-4.29 4.302 3.6 3.6 0 0 1-6.074 0 3.598 3.598 0 0 1-4.3-4.293 3.6 3.6 0 0 1 0-6.085Z"
/>
<path
className="fill-background"
d="M15.707 9.293a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414L11 12.586l3.293-3.293a1 1 0 0 1 1.414 0Z"
/>
</svg>
</span>
</div>
);
}

View File

@ -0,0 +1,41 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import Tools from "./components/tools";
import Banner from "./components/banner";
import Terminal from "./components/terminal";
import Chat from "./components/chat";
export default function PlaygroundLayout() {
return (
<div className="h-screen flex flex-col bg-[#282c34]">
<Banner />
<Tools />
<div className="flex flex-1 min-h-0">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={25}>One</ResizablePanel>
<ResizableHandle className="bg-[#3e4452]" />
<ResizablePanel defaultSize={75}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75}>
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={75}>One</ResizablePanel>
<ResizableHandle className="bg-[#3e4452]" />
<ResizablePanel defaultSize={25}>
<Chat/>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle className="bg-[#3e4452]" />
<ResizablePanel defaultSize={25}>
<Terminal />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,36 @@
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Toggle } from "@/components/ui/toggle";
export default function ButtonDemo() {
const { theme, setTheme } = useTheme();
return (
<div>
<Toggle
variant="outline"
className="group size-9"
pressed={theme === "dark"}
onPressedChange={() =>
setTheme((prev) => (prev === "dark" ? "light" : "dark"))
}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
<Moon
size={16}
strokeWidth={2}
className="shrink-0 scale-0 opacity-0 transition-all group-data-[state=on]:scale-100 group-data-[state=on]:opacity-100"
aria-hidden="true"
/>
<Sun
size={16}
strokeWidth={2}
className="absolute shrink-0 scale-100 opacity-100 transition-all group-data-[state=on]:scale-0 group-data-[state=on]:opacity-0"
aria-hidden="true"
/>
</Toggle>
</div>
);
}

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,202 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import MessageLoading from "./message-loading";
import { Button, ButtonProps } from "../button";
// ChatBubble
const chatBubbleVariant = cva(
"flex gap-2 max-w-[60%] items-end relative group",
{
variants: {
variant: {
received: "self-start",
sent: "self-end flex-row-reverse",
},
layout: {
default: "",
ai: "max-w-full w-full items-center",
},
},
defaultVariants: {
variant: "received",
layout: "default",
},
},
);
interface ChatBubbleProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleVariant> {}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, variant, layout, children, ...props }, ref) => (
<div
className={cn(
chatBubbleVariant({ variant, layout, className }),
"relative group",
)}
ref={ref}
{...props}
>
{React.Children.map(children, (child) =>
React.isValidElement(child) && typeof child.type !== "string"
? React.cloneElement(child, {
variant,
layout,
} as React.ComponentProps<typeof child.type>)
: child,
)}
</div>
),
);
ChatBubble.displayName = "ChatBubble";
// ChatBubbleAvatar
interface ChatBubbleAvatarProps {
src?: string;
fallback?: string;
className?: string;
}
const ChatBubbleAvatar: React.FC<ChatBubbleAvatarProps> = ({
src,
fallback,
className,
}) => (
<Avatar className={className}>
<AvatarImage src={src} alt="Avatar" />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
);
// ChatBubbleMessage
const chatBubbleMessageVariants = cva("p-4", {
variants: {
variant: {
received:
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
},
layout: {
default: "",
ai: "border-t w-full rounded-none bg-transparent",
},
},
defaultVariants: {
variant: "received",
layout: "default",
},
});
interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> {
isLoading?: boolean;
}
const ChatBubbleMessage = React.forwardRef<
HTMLDivElement,
ChatBubbleMessageProps
>(
(
{ className, variant, layout, isLoading = false, children, ...props },
ref,
) => (
<div
className={cn(
chatBubbleMessageVariants({ variant, layout, className }),
"break-words max-w-full whitespace-pre-wrap",
)}
ref={ref}
{...props}
>
{isLoading ? (
<div className="flex items-center space-x-2">
<MessageLoading />
</div>
) : (
children
)}
</div>
),
);
ChatBubbleMessage.displayName = "ChatBubbleMessage";
// ChatBubbleTimestamp
interface ChatBubbleTimestampProps
extends React.HTMLAttributes<HTMLDivElement> {
timestamp: string;
}
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
timestamp,
className,
...props
}) => (
<div className={cn("text-xs mt-2 text-right", className)} {...props}>
{timestamp}
</div>
);
// ChatBubbleAction
type ChatBubbleActionProps = ButtonProps & {
icon: React.ReactNode;
};
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
icon,
onClick,
className,
variant = "ghost",
size = "icon",
...props
}) => (
<Button
variant={variant}
size={size}
className={className}
onClick={onClick}
{...props}
>
{icon}
</Button>
);
interface ChatBubbleActionWrapperProps
extends React.HTMLAttributes<HTMLDivElement> {
variant?: "sent" | "received";
className?: string;
}
const ChatBubbleActionWrapper = React.forwardRef<
HTMLDivElement,
ChatBubbleActionWrapperProps
>(({ variant, className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"absolute top-1/2 -translate-y-1/2 flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
variant === "sent"
? "-left-1 -translate-x-full flex-row-reverse"
: "-right-1 translate-x-full",
className,
)}
{...props}
>
{children}
</div>
));
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
export {
ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage,
ChatBubbleTimestamp,
chatBubbleVariant,
chatBubbleMessageVariants,
ChatBubbleAction,
ChatBubbleActionWrapper,
};

View File

@ -0,0 +1,23 @@
import * as React from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
interface ChatInputProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement>{}
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
({ className, ...props }, ref) => (
<Textarea
autoComplete="off"
ref={ref}
name="message"
className={cn(
"max-h-12 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center h-16 resize-none",
className,
)}
{...props}
/>
),
);
ChatInput.displayName = "ChatInput";
export { ChatInput };

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
smooth?: boolean;
}
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
({ className, children, smooth = false, ...props }, _ref) => {
const {
scrollRef,
isAtBottom,
autoScrollEnabled,
scrollToBottom,
disableAutoScroll,
} = useAutoScroll({
smooth,
content: children,
});
return (
<div className="relative w-full h-full">
<div
className={`flex flex-col w-full h-full p-4 overflow-y-auto ${className}`}
ref={scrollRef}
onWheel={disableAutoScroll}
onTouchMove={disableAutoScroll}
{...props}
>
<div className="flex flex-col gap-6">{children}</div>
</div>
{!isAtBottom && (
<Button
onClick={() => {
scrollToBottom();
}}
size="icon"
variant="outline"
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
}
);
ChatMessageList.displayName = "ChatMessageList";
export { ChatMessageList };

View File

@ -0,0 +1,153 @@
"use client";
import React, { useRef, useState } from "react";
import { X, MessageCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export type ChatPosition = "bottom-right" | "bottom-left";
export type ChatSize = "sm" | "md" | "lg" | "xl" | "full";
const chatConfig = {
dimensions: {
sm: "sm:max-w-sm sm:max-h-[500px]",
md: "sm:max-w-md sm:max-h-[600px]",
lg: "sm:max-w-lg sm:max-h-[700px]",
xl: "sm:max-w-xl sm:max-h-[800px]",
full: "sm:w-full sm:h-full",
},
positions: {
"bottom-right": "bottom-5 right-5",
"bottom-left": "bottom-5 left-5",
},
chatPositions: {
"bottom-right": "sm:bottom-[calc(100%+10px)] sm:right-0",
"bottom-left": "sm:bottom-[calc(100%+10px)] sm:left-0",
},
states: {
open: "pointer-events-auto opacity-100 visible scale-100 translate-y-0",
closed:
"pointer-events-none opacity-0 invisible scale-100 sm:translate-y-5",
},
};
interface ExpandableChatProps extends React.HTMLAttributes<HTMLDivElement> {
position?: ChatPosition;
size?: ChatSize;
icon?: React.ReactNode;
}
const ExpandableChat: React.FC<ExpandableChatProps> = ({
className,
position = "bottom-right",
size = "md",
icon,
children,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const chatRef = useRef<HTMLDivElement>(null);
const toggleChat = () => setIsOpen(!isOpen);
return (
<div
className={cn(`fixed ${chatConfig.positions[position]} z-50`, className)}
{...props}
>
<div
ref={chatRef}
className={cn(
"flex flex-col bg-background border sm:rounded-lg shadow-md overflow-hidden transition-all duration-250 ease-out sm:absolute sm:w-[90vw] sm:h-[80vh] fixed inset-0 w-full h-full sm:inset-auto",
chatConfig.chatPositions[position],
chatConfig.dimensions[size],
isOpen ? chatConfig.states.open : chatConfig.states.closed,
className,
)}
>
{children}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 sm:hidden"
onClick={toggleChat}
>
<X className="h-4 w-4" />
</Button>
</div>
<ExpandableChatToggle
icon={icon}
isOpen={isOpen}
toggleChat={toggleChat}
/>
</div>
);
};
ExpandableChat.displayName = "ExpandableChat";
const ExpandableChatHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn("flex items-center justify-between p-4 border-b", className)}
{...props}
/>
);
ExpandableChatHeader.displayName = "ExpandableChatHeader";
const ExpandableChatBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn("flex-grow overflow-y-auto", className)} {...props} />;
ExpandableChatBody.displayName = "ExpandableChatBody";
const ExpandableChatFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn("border-t p-4", className)} {...props} />;
ExpandableChatFooter.displayName = "ExpandableChatFooter";
interface ExpandableChatToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode;
isOpen: boolean;
toggleChat: () => void;
}
const ExpandableChatToggle: React.FC<ExpandableChatToggleProps> = ({
className,
icon,
isOpen,
toggleChat,
...props
}) => (
<Button
variant="default"
onClick={toggleChat}
className={cn(
"w-14 h-14 rounded-full shadow-md flex items-center justify-center hover:shadow-lg hover:shadow-black/30 transition-all duration-300",
className,
)}
{...props}
>
{isOpen ? (
<X className="h-6 w-6" />
) : (
icon || <MessageCircle className="h-6 w-6" />
)}
</Button>
);
ExpandableChatToggle.displayName = "ExpandableChatToggle";
export {
ExpandableChat,
ExpandableChatHeader,
ExpandableChatBody,
ExpandableChatFooter,
};

View File

@ -0,0 +1,135 @@
// @hidden
import { useCallback, useEffect, useRef, useState } from "react";
interface ScrollState {
isAtBottom: boolean;
autoScrollEnabled: boolean;
}
interface UseAutoScrollOptions {
offset?: number;
smooth?: boolean;
content?: React.ReactNode;
}
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
const { offset = 20, smooth = false, content } = options;
const scrollRef = useRef<HTMLDivElement>(null);
const lastContentHeight = useRef(0);
const userHasScrolled = useRef(false);
const [scrollState, setScrollState] = useState<ScrollState>({
isAtBottom: true,
autoScrollEnabled: true,
});
const checkIsAtBottom = useCallback(
(element: HTMLElement) => {
const { scrollTop, scrollHeight, clientHeight } = element;
const distanceToBottom = Math.abs(
scrollHeight - scrollTop - clientHeight
);
return distanceToBottom <= offset;
},
[offset]
);
const scrollToBottom = useCallback(
(instant?: boolean) => {
if (!scrollRef.current) return;
const targetScrollTop =
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
if (instant) {
scrollRef.current.scrollTop = targetScrollTop;
} else {
scrollRef.current.scrollTo({
top: targetScrollTop,
behavior: smooth ? "smooth" : "auto",
});
}
setScrollState({
isAtBottom: true,
autoScrollEnabled: true,
});
userHasScrolled.current = false;
},
[smooth]
);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const atBottom = checkIsAtBottom(scrollRef.current);
setScrollState((prev) => ({
isAtBottom: atBottom,
// Re-enable auto-scroll if at the bottom
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
}));
}, [checkIsAtBottom]);
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const currentHeight = scrollElement.scrollHeight;
const hasNewContent = currentHeight !== lastContentHeight.current;
if (hasNewContent) {
if (scrollState.autoScrollEnabled) {
requestAnimationFrame(() => {
scrollToBottom(lastContentHeight.current === 0);
});
}
lastContentHeight.current = currentHeight;
}
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
if (scrollState.autoScrollEnabled) {
scrollToBottom(true);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [scrollState.autoScrollEnabled, scrollToBottom]);
const disableAutoScroll = useCallback(() => {
const atBottom = scrollRef.current
? checkIsAtBottom(scrollRef.current)
: false;
// Only disable if not at bottom
if (!atBottom) {
userHasScrolled.current = true;
setScrollState((prev) => ({
...prev,
autoScrollEnabled: false,
}));
}
}, [checkIsAtBottom]);
return {
scrollRef,
isAtBottom: scrollState.isAtBottom,
autoScrollEnabled: scrollState.autoScrollEnabled,
scrollToBottom: () => scrollToBottom(false),
disableAutoScroll,
};
}

View File

@ -0,0 +1,45 @@
// @hidden
export default function MessageLoading() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="text-foreground"
>
<circle cx="4" cy="12" r="2" fill="currentColor">
<animate
id="spinner_qFRN"
begin="0;spinner_OcgL.end+0.25s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="12" cy="12" r="2" fill="currentColor">
<animate
begin="spinner_qFRN.begin+0.1s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="20" cy="12" r="2" fill="currentColor">
<animate
id="spinner_OcgL"
begin="spinner_qFRN.begin+0.2s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
</svg>
);
}

View File

@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -1,11 +1,11 @@
"use client"
"use client";
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@ -14,13 +14,13 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
"inline-flex items-center justify-center rounded-lg bg-muted p-0.5 text-muted-foreground/70",
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@ -29,13 +29,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium outline-offset-2 transition-all hover:text-muted-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:shadow-black/5",
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@ -44,12 +44,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
"mt-2 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70",
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }