feat(cli): add all components with shadcn-chat-cli

This commit is contained in:
cfngc4594 2025-03-24 11:38:00 +08:00
parent cc3fb8afc4
commit 2b4a4b527e
6 changed files with 613 additions and 0 deletions

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>
);
}