Skip to content

Commit

Permalink
feat: ✨ Add chat support components
Browse files Browse the repository at this point in the history
  • Loading branch information
edwinhern committed Jan 29, 2025
1 parent 97a5ad3 commit e0155c5
Show file tree
Hide file tree
Showing 13 changed files with 1,492 additions and 0 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"format": "pnpm lint && biome format --write"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-slot": "^1.1.1",
"@t3-oss/env-nextjs": "^0.12.0",
"@vercel/analytics": "^1.4.1",
Expand All @@ -23,6 +24,7 @@
"ollama-ai-provider": "^1.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
Expand Down
772 changes: 772 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions public/IconRobot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react";

import { AppNavbar } from "@/common/layout/navbar";
import { Providers } from "@/common/layout/providers";
import ChatSupport from "@/components/ui/chat-support";

import "./globals.css";

Expand Down Expand Up @@ -54,6 +55,7 @@ export default function RootLayout({ children }: Readonly<PropsWithChildren>) {
<AppNavbar className="mx-auto px-4 py-4 md:max-w-[700px] md:px-0 md:py-8">
<div className="pt-8">{children}</div>
</AppNavbar>
<ChatSupport />
</Providers>
</body>
</html>
Expand Down
40 changes: 40 additions & 0 deletions src/components/ui/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";

import { cn } from "@/lib/utils";

const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;

const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;

const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;

export { Avatar, AvatarImage, AvatarFallback };
132 changes: 132 additions & 0 deletions src/components/ui/chat-support.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { ChatBubble, ChatBubbleAvatar, ChatBubbleMessage } from "@/components/ui/chat/chat-bubble";
import { ChatInput } from "@/components/ui/chat/chat-input";
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
import {
ExpandableChat,
ExpandableChatBody,
ExpandableChatFooter,
ExpandableChatHeader,
} from "@/components/ui/chat/expandable-chat";
import { Button } from "@/ui/button";
import { useChat } from "ai/react";
import { IconSend3 } from "justd-icons";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";

export default function ChatSupport() {
const messagesRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
onResponse(response) {
if (response) {
setIsGenerating(false);
}
},
onError() {
setIsGenerating(false);
},
});

useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, [messages]);

const cleanAIResponse = (content: string) => {
return content.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
};

const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsGenerating(true);
handleSubmit(e);
};

const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (isGenerating || isLoading || !input) return;
setIsGenerating(true);
onSubmit(e as unknown as React.FormEvent<HTMLFormElement>);
}
};

return (
<ExpandableChat size="md" position="bottom-right">
<ExpandableChatHeader className="flex-col items-start">
<h2 className="font-bold text-lg">Chat with Edwin</h2>
</ExpandableChatHeader>
<ExpandableChatBody>
<ChatMessageList ref={messagesRef}>
{/* Initial message */}
<ChatBubble variant="received">
<ChatBubbleAvatar fallback="AI" />
<ChatBubbleMessage>
Hi there! 👋🏻 Thanks for visiting my website. Feel free to ask me anything about
programming, web development, or my experiences in tech. Let me know how I can help!
</ChatBubbleMessage>
</ChatBubble>

{/* Messages */}
{messages.map((message) => (
<ChatBubble key={message.id} variant={message.role === "user" ? "sent" : "received"}>
<ChatBubbleAvatar src="" fallback={message.role === "user" ? "US" : "AI"} />
<ChatBubbleMessage variant={message.role === "user" ? "sent" : "received"}>
{message.role === "assistant" ? (
<ReactMarkdown
components={{
h1: ({ node, ...props }) => (
<h1 className="my-4 font-bold text-2xl" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="my-3 font-bold text-xl" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="my-2 font-bold text-lg" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="my-2 ml-4 list-disc" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="my-2 ml-4 list-decimal" {...props} />
),
}}
>
{cleanAIResponse(message.content)}
</ReactMarkdown>
) : (
message.content
)}
</ChatBubbleMessage>
</ChatBubble>
))}

{isGenerating && (
<ChatBubble variant="received">
<ChatBubbleAvatar src="" fallback="🤖" />
<ChatBubbleMessage isLoading />
</ChatBubble>
)}
</ChatMessageList>
</ExpandableChatBody>

<ExpandableChatFooter>
<form className="relative flex gap-2" ref={formRef} onSubmit={onSubmit}>
<ChatInput
className="min-h-12 bg-background shadow-none "
onChange={handleInputChange}
value={input}
onKeyDown={onKeyDown}
/>
<Button type="submit" className="h-12 px-3" disabled={isLoading || isGenerating || !input}>
<IconSend3 />
</Button>
</form>
</ExpandableChatFooter>
</ExpandableChat>
);
}
164 changes: 164 additions & 0 deletions src/components/ui/chat/chat-bubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { Button, type ButtonProps } from "../button";
import MessageLoading from "./message-loading";

// ChatBubble
const chatBubbleVariant = cva("group relative flex max-w-[60%] items-end gap-2", {
variants: {
variant: {
received: "self-start",
sent: "flex-row-reverse self-end",
},
layout: {
default: "w-full max-w-full text-sm",
ai: "w-full max-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 }), "group relative")} 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: "rounded-r-lg rounded-tl-lg bg-secondary text-secondary-foreground",
sent: "rounded-l-lg rounded-tr-lg bg-primary text-primary-foreground",
},
layout: {
default: "",
ai: "w-full rounded-none border-t 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 }),
"max-w-full whitespace-pre-wrap break-words",
)}
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("mt-2 text-right text-xs", 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(
"-translate-y-1/2 absolute top-1/2 flex opacity-0 transition-opacity duration-200 group-hover:opacity-100",
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,
};
21 changes: 21 additions & 0 deletions src/components/ui/chat/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import * as React from "react";

interface ChatInputProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(({ className, ...props }, ref) => (
<Textarea
autoComplete="off"
ref={ref}
name="message"
className={cn(
"flex h-16 max-h-12 w-full resize-none items-center rounded-md bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
));
ChatInput.displayName = "ChatInput";

export { ChatInput };
Loading

0 comments on commit e0155c5

Please sign in to comment.