From f09be3f2863f97fc381a2d06f2d995a3e808ccee Mon Sep 17 00:00:00 2001 From: josh <144584931+dancer@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:15:36 +0100 Subject: [PATCH] feat: ai elements (#1143) --- components/artifact.tsx | 4 +- components/chat.tsx | 4 +- components/console.tsx | 29 +- components/elements/actions.tsx | 65 +++ components/elements/branch.tsx | 212 +++++++ components/elements/code-block.tsx | 148 +++++ components/elements/conversation.tsx | 62 ++ components/elements/image.tsx | 24 + components/elements/inline-citation.tsx | 287 +++++++++ components/elements/loader.tsx | 96 +++ components/elements/message.tsx | 58 ++ components/elements/prompt-input.tsx | 235 ++++++++ components/elements/reasoning.tsx | 171 ++++++ components/elements/response.tsx | 22 + components/elements/source.tsx | 74 +++ components/elements/suggestion.tsx | 53 ++ components/elements/task.tsx | 94 +++ components/elements/tool.tsx | 144 +++++ components/elements/web-preview.tsx | 252 ++++++++ components/markdown.tsx | 30 - components/message-actions.tsx | 96 ++- components/message-reasoning.tsx | 72 +-- components/message.tsx | 257 ++++---- components/messages.tsx | 69 +-- components/multimodal-input.tsx | 149 ++--- components/preview-attachment.tsx | 66 ++- components/suggested-actions.tsx | 80 +-- components/suggestion.tsx | 10 +- components/ui/avatar.tsx | 50 ++ components/ui/badge.tsx | 36 ++ components/ui/carousel.tsx | 262 +++++++++ components/ui/collapsible.tsx | 11 + components/ui/hover-card.tsx | 29 + components/ui/scroll-area.tsx | 48 ++ lib/editor/functions.tsx | 4 +- lib/editor/suggestions.tsx | 2 +- package.json | 12 +- pnpm-lock.yaml | 741 +++++++++++++++++++++++- 38 files changed, 3558 insertions(+), 500 deletions(-) create mode 100644 components/elements/actions.tsx create mode 100644 components/elements/branch.tsx create mode 100644 components/elements/code-block.tsx create mode 100644 components/elements/conversation.tsx create mode 100644 components/elements/image.tsx create mode 100644 components/elements/inline-citation.tsx create mode 100644 components/elements/loader.tsx create mode 100644 components/elements/message.tsx create mode 100644 components/elements/prompt-input.tsx create mode 100644 components/elements/reasoning.tsx create mode 100644 components/elements/response.tsx create mode 100644 components/elements/source.tsx create mode 100644 components/elements/suggestion.tsx create mode 100644 components/elements/task.tsx create mode 100644 components/elements/tool.tsx create mode 100644 components/elements/web-preview.tsx delete mode 100644 components/markdown.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/scroll-area.tsx diff --git a/components/artifact.tsx b/components/artifact.tsx index bf3bfd6..6b266d9 100644 --- a/components/artifact.tsx +++ b/components/artifact.tsx @@ -322,7 +322,7 @@ function PureArtifact({ artifactStatus={artifact.status} /> -
+
- +
)} diff --git a/components/chat.tsx b/components/chat.tsx index f66cbbd..f187ad3 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -148,7 +148,7 @@ export function Chat({ isArtifactVisible={isArtifactVisible} /> -
+
{!isReadonly && ( )} - +
0 ? ( <>
-
-
+
+
@@ -104,7 +105,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
+ ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; diff --git a/components/elements/branch.tsx b/components/elements/branch.tsx new file mode 100644 index 0000000..dbb8f64 --- /dev/null +++ b/components/elements/branch.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { UIMessage } from 'ai'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; + +type BranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const BranchContext = createContext(null); + +const useBranch = () => { + const context = useContext(BranchContext); + + if (!context) { + throw new Error('Branch components must be used within Branch'); + } + + return context; +}; + +export type BranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const Branch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: BranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: BranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0', className)} + {...props} + /> + + ); +}; + +export type BranchMessagesProps = HTMLAttributes; + +export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => { + const { currentBranch, setBranches, branches } = useBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0', + index === currentBranch ? 'block' : 'hidden', + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type BranchSelectorProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const BranchSelector = ({ + className, + from, + ...props +}: BranchSelectorProps) => { + const { totalBranches } = useBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( +
+ ); +}; + +export type BranchPreviousProps = ComponentProps; + +export const BranchPrevious = ({ + className, + children, + ...props +}: BranchPreviousProps) => { + const { goToPrevious, totalBranches } = useBranch(); + + return ( + + ); +}; + +export type BranchNextProps = ComponentProps; + +export const BranchNext = ({ + className, + children, + ...props +}: BranchNextProps) => { + const { goToNext, totalBranches } = useBranch(); + + return ( + + ); +}; + +export type BranchPageProps = HTMLAttributes; + +export const BranchPage = ({ className, ...props }: BranchPageProps) => { + const { currentBranch, totalBranches } = useBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; diff --git a/components/elements/code-block.tsx b/components/elements/code-block.tsx new file mode 100644 index 0000000..c6df4b2 --- /dev/null +++ b/components/elements/code-block.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'; +import { createContext, useContext, useState } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { + oneDark, + oneLight, +} from 'react-syntax-highlighter/dist/esm/styles/prism'; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: '', +}); + +export type CodeBlockProps = HTMLAttributes & { + code: string; + language: string; + showLineNumbers?: boolean; + children?: ReactNode; +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => ( + +
+
+ + {code} + + + {code} + + {children && ( +
+ {children} +
+ )} +
+
+
+); + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === 'undefined' || !navigator.clipboard.writeText) { + onError?.(new Error('Clipboard API not available')); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/components/elements/conversation.tsx b/components/elements/conversation.tsx new file mode 100644 index 0000000..c817ad7 --- /dev/null +++ b/components/elements/conversation.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { ArrowDownIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/components/elements/image.tsx b/components/elements/image.tsx new file mode 100644 index 0000000..011fdf5 --- /dev/null +++ b/components/elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils'; +import type { Experimental_GeneratedImage } from 'ai'; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/components/elements/inline-citation.tsx b/components/elements/inline-citation.tsx new file mode 100644 index 0000000..3d26f97 --- /dev/null +++ b/components/elements/inline-citation.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { + Carousel, + CarouselContent, + CarouselItem, + type CarouselApi, +} from '@/components/ui/carousel'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { cn } from '@/lib/utils'; +import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; + +export type InlineCitationProps = ComponentProps<'span'>; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<'span'>; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources.length ? ( + <> + {new URL(sources[0]).hostname}{' '} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + 'unknown' + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<'div'>; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<'div'>; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps, +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<'div'>; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<'div'>; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on('select', () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<'button'>; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<'button'>; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<'div'> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<'blockquote'>; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/components/elements/loader.tsx b/components/elements/loader.tsx new file mode 100644 index 0000000..af919d4 --- /dev/null +++ b/components/elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from '@/lib/utils'; +import type { HTMLAttributes } from 'react'; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/components/elements/message.tsx b/components/elements/message.tsx new file mode 100644 index 0000000..feaceaf --- /dev/null +++ b/components/elements/message.tsx @@ -0,0 +1,58 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; +import type { UIMessage } from 'ai'; +import type { ComponentProps, HTMLAttributes } from 'react'; + +export type MessageProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
div]:max-w-[80%]', + className, + )} + {...props} + /> +); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageAvatarProps = ComponentProps & { + src: string; + name?: string; +}; + +export const MessageAvatar = ({ + src, + name, + className, + ...props +}: MessageAvatarProps) => ( + + + {name?.slice(0, 2) || 'ME'} + +); diff --git a/components/elements/prompt-input.tsx b/components/elements/prompt-input.tsx new file mode 100644 index 0000000..82a703d --- /dev/null +++ b/components/elements/prompt-input.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import type { ChatStatus } from 'ai'; +import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react'; +import type { + ComponentProps, + HTMLAttributes, + KeyboardEventHandler, +} from 'react'; +import { Children } from 'react'; + +export type PromptInputProps = HTMLAttributes; + +export const PromptInput = ({ className, ...props }: PromptInputProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps & { + minHeight?: number; + maxHeight?: number; + disableAutoResize?: boolean; + resizeOnNewLinesOnly?: boolean; +}; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = 'What would you like to know?', + minHeight = 48, + maxHeight = 164, + disableAutoResize = false, + resizeOnNewLinesOnly = false, + ...props +}: PromptInputTextareaProps) => { + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === 'Enter') { + // Don't submit if IME composition is in progress + if (e.nativeEvent.isComposing) { + return; + } + + if (e.shiftKey) { + // Allow newline + return; + } + + // Submit on Enter (without Shift) + e.preventDefault(); + const form = e.currentTarget.form; + if (form) { + form.requestSubmit(); + } + } + }; + + return ( +