feat: ai elements (#1143)

This commit is contained in:
josh 2025-08-28 14:15:36 +01:00 committed by GitHub
parent 66e8227655
commit f09be3f286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3558 additions and 500 deletions

View file

@ -322,7 +322,7 @@ function PureArtifact({
artifactStatus={artifact.status}
/>
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
<div className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
<MultimodalInput
chatId={chatId}
input={input}
@ -337,7 +337,7 @@ function PureArtifact({
setMessages={setMessages}
selectedVisibilityType={selectedVisibilityType}
/>
</form>
</div>
</div>
</motion.div>
)}

View file

@ -148,7 +148,7 @@ export function Chat({
isArtifactVisible={isArtifactVisible}
/>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
<div className="sticky bottom-0 flex gap-2 px-4 pb-4 mx-auto w-full bg-background md:pb-6 md:max-w-3xl z-[1] border-t-0">
{!isReadonly && (
<MultimodalInput
chatId={id}
@ -164,7 +164,7 @@ export function Chat({
selectedVisibilityType={visibilityType}
/>
)}
</form>
</div>
</div>
<Artifact

View file

@ -1,8 +1,9 @@
import { TerminalWindowIcon, LoaderIcon, CrossSmallIcon } from './icons';
import { TerminalWindowIcon, CrossSmallIcon } from './icons';
import { Loader } from './elements/loader';
import { Button } from './ui/button';
import {
Dispatch,
SetStateAction,
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
@ -79,7 +80,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
return consoleOutputs.length > 0 ? (
<>
<div
className="h-2 w-full fixed cursor-ns-resize z-50"
className="fixed z-50 w-full h-2 cursor-ns-resize"
onMouseDown={startResizing}
style={{ bottom: height - 4 }}
role="slider"
@ -88,15 +89,15 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div
className={cn(
'fixed flex flex-col bottom-0 dark:bg-zinc-900 bg-zinc-50 w-full border-t z-40 overflow-y-scroll overflow-x-hidden dark:border-zinc-700 border-zinc-200',
'flex overflow-x-hidden overflow-y-scroll fixed bottom-0 z-40 flex-col w-full border-t dark:bg-zinc-900 bg-zinc-50 dark:border-zinc-700 border-zinc-200',
{
'select-none': isResizing,
},
)}
style={{ height }}
>
<div className="flex flex-row justify-between items-center w-full h-fit border-b dark:border-zinc-700 border-zinc-200 px-2 py-1 sticky top-0 z-50 bg-muted">
<div className="text-sm pl-2 dark:text-zinc-50 text-zinc-800 flex flex-row gap-3 items-center">
<div className="flex sticky top-0 z-50 flex-row justify-between items-center px-2 py-1 w-full border-b h-fit dark:border-zinc-700 border-zinc-200 bg-muted">
<div className="flex flex-row gap-3 items-center pl-2 text-sm dark:text-zinc-50 text-zinc-800">
<div className="text-muted-foreground">
<TerminalWindowIcon />
</div>
@ -104,7 +105,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
</div>
<Button
variant="ghost"
className="size-fit p-1 hover:dark:bg-zinc-700 hover:bg-zinc-200"
className="p-1 size-fit hover:dark:bg-zinc-700 hover:bg-zinc-200"
size="icon"
onClick={() => setConsoleOutputs([])}
>
@ -116,7 +117,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
{consoleOutputs.map((consoleOutput, index) => (
<div
key={consoleOutput.id}
className="px-4 py-2 flex flex-row text-sm border-b dark:border-zinc-700 border-zinc-200 dark:bg-zinc-900 bg-zinc-50 font-mono"
className="flex flex-row px-4 py-2 font-mono text-sm border-b dark:border-zinc-700 border-zinc-200 dark:bg-zinc-900 bg-zinc-50"
>
<div
className={cn('w-12 shrink-0', {
@ -134,8 +135,8 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
consoleOutput.status,
) ? (
<div className="flex flex-row gap-2">
<div className="animate-spin size-fit self-center mb-auto mt-0.5">
<LoaderIcon />
<div className="size-fit self-center mb-auto mt-0.5">
<Loader size={16} />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === 'in_progress'
@ -148,20 +149,20 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
</div>
</div>
) : (
<div className="dark:text-zinc-50 text-zinc-900 w-full flex flex-col gap-2 overflow-x-scroll">
<div className="flex overflow-x-scroll flex-col gap-2 w-full dark:text-zinc-50 text-zinc-900">
{consoleOutput.contents.map((content, index) =>
content.type === 'image' ? (
<picture key={`${consoleOutput.id}-${index}`}>
<img
src={content.value}
alt="output"
className="rounded-md max-w-screen-toast-mobile w-full"
className="w-full rounded-md max-w-screen-toast-mobile"
/>
</picture>
) : (
<div
key={`${consoleOutput.id}-${index}`}
className="whitespace-pre-line break-words w-full"
className="w-full whitespace-pre-line break-words"
>
{content.value}
</div>

View file

@ -0,0 +1,65 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
className,
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View file

@ -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<BranchContextType | null>(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<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
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 (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
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
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden',
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
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 (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className,
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className,
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View file

@ -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<CodeBlockContextType>({
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className,
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
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 (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View file

@ -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<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className,
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View file

@ -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) => (
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className,
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View file

@ -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) => (
<span
className={cn('group inline items-center gap-1', className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<'span'>;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn('transition-colors group-hover:bg-accent', className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps,
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn('w-full space-y-2 p-4 pl-8', className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className,
)}
{...props}
/>
);
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 (
<div
className={cn(
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className,
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className,
)}
{...props}
>
{children}
</blockquote>
);

View file

@ -0,0 +1,96 @@
import { cn } from '@/lib/utils';
import type { HTMLAttributes } from 'react';
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: 'currentcolor' }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className,
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View file

@ -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<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
'is-user:dark',
className,
)}
{...props}
>
{children}
</div>
);
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string;
name?: string;
};
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<Avatar className={cn('size-8 ring-1 ring-border', className)} {...props}>
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
);

View file

@ -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<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
'w-full overflow-hidden rounded-xl border bg-background shadow-sm',
className,
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
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<HTMLTextAreaElement> = (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 (
<Textarea
className={cn(
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
disableAutoResize ? 'field-sizing-fixed' : resizeOnNewLinesOnly ? 'field-sizing-fixed' : 'field-sizing-content max-h-[6lh]',
'bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className,
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className,
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className,
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className,
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View file

@ -0,0 +1,171 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error('Reasoning components must be used within Reasoning');
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosedRef(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose mb-4', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
},
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-2 text-muted-foreground text-sm',
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{isStreaming || duration === 0 ? (
<p>Thinking...</p>
) : (
<p>Thought for {duration} seconds</p>
)}
<ChevronDownIcon
className={cn(
'size-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0',
)}
/>
</>
)}
</CollapsibleTrigger>
);
},
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
),
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View file

@ -0,0 +1,22 @@
'use client';
import { cn } from '@/lib/utils';
import { type ComponentProps, memo } from 'react';
import { Streamdown } from 'streamdown';
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
Response.displayName = 'Response';

View file

@ -0,0 +1,74 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { BookIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type SourcesProps = ComponentProps<'div'>;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn('not-prose mb-4 text-primary text-xs', className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger className="flex items-center gap-2" {...props}>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
);
export type SourceProps = ComponentProps<'a'>;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);

View file

@ -0,0 +1,53 @@
'use client';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { ComponentProps } from 'react';
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = 'outline',
size = 'sm',
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion);
};
return (
<Button
className={cn('cursor-pointer rounded-full px-4', className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View file

@ -0,0 +1,94 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
export type TaskItemFileProps = ComponentProps<'div'>;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
className,
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<'div'>;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View file

@ -0,0 +1,144 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart['type'];
state: ToolUIPart['state'];
className?: string;
};
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
} as const;
const icons = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
<Badge className="rounded-full text-xs flex items-center gap-1" variant="secondary">
{icons[status]}
<span>{labels[status]}</span>
</Badge>
);
};
export const ToolHeader = ({
className,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'flex w-full items-center justify-between gap-4 p-3',
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{type}</span>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(state)}
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className,
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<'div'> & {
output: ReactNode;
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground',
)}
>
{errorText && <div>{errorText}</div>}
{output && <div>{output}</div>}
</div>
</div>
);
};

View file

@ -0,0 +1,252 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error('WebPreview components must be used within a WebPreview');
}
return context;
};
export type WebPreviewProps = ComponentProps<'div'> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
export const WebPreview = ({
className,
children,
defaultUrl = '',
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
};
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
'flex size-full flex-col rounded-lg border bg-card',
className,
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<'div'>;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn('flex items-center gap-1 border-b p-2', className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn('size-full', className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: 'log' | 'warn' | 'error';
message: string;
timestamp: Date;
}>;
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180',
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'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 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground',
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -1,30 +0,0 @@
import Link from 'next/link';
import React, { memo } from 'react';
import { Streamdown, type StreamdownProps } from 'streamdown';
type Components = StreamdownProps['components'];
const components: Partial<Components> = {
a: ({ node, children, ...props }) => {
return (
// @ts-expect-error
<Link
className="text-blue-500 hover:underline"
target="_blank"
rel="noreferrer"
{...props}
>
{children}
</Link>
);
},
};
const NonMemoizedMarkdown = ({ children }: { children: string }) => (
<Streamdown components={components}>{children}</Streamdown>
);
export const Markdown = memo(
NonMemoizedMarkdown,
(prevProps, nextProps) => prevProps.children === nextProps.children,
);

View file

@ -4,13 +4,7 @@ import { useCopyToClipboard } from 'usehooks-ts';
import type { Vote } from '@/lib/db/schema';
import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
import { Button } from './ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './ui/tooltip';
import { Actions, Action } from './elements/actions';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { toast } from 'sonner';
@ -34,13 +28,9 @@ export function PureMessageActions({
if (message.role === 'user') return null;
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground"
variant="outline"
<Actions>
<Action
tooltip="Copy"
onClick={async () => {
const textFromParts = message.parts
?.filter((part) => part.type === 'text')
@ -58,18 +48,12 @@ export function PureMessageActions({
}}
>
<CopyIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</Action>
<Tooltip>
<TooltipTrigger asChild>
<Button
<Action
tooltip="Upvote Response"
data-testid="message-upvote"
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
disabled={vote?.isUpvoted}
variant="outline"
onClick={async () => {
const upvote = fetch('/api/vote', {
method: 'PATCH',
@ -111,17 +95,11 @@ export function PureMessageActions({
}}
>
<ThumbUpIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Upvote Response</TooltipContent>
</Tooltip>
</Action>
<Tooltip>
<TooltipTrigger asChild>
<Button
<Action
tooltip="Downvote Response"
data-testid="message-downvote"
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
variant="outline"
disabled={vote && !vote.isUpvoted}
onClick={async () => {
const downvote = fetch('/api/vote', {
@ -164,12 +142,8 @@ export function PureMessageActions({
}}
>
<ThumbDownIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Downvote Response</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Action>
</Actions>
);
}

View file

@ -1,9 +1,6 @@
'use client';
import { useState } from 'react';
import { ChevronDownIcon, LoaderIcon } from './icons';
import { motion, AnimatePresence } from 'framer-motion';
import { Markdown } from './markdown';
import { Reasoning, ReasoningTrigger, ReasoningContent } from './elements/reasoning';
interface MessageReasoningProps {
isLoading: boolean;
@ -14,65 +11,14 @@ export function MessageReasoning({
isLoading,
reasoning,
}: MessageReasoningProps) {
const [isExpanded, setIsExpanded] = useState(true);
const variants = {
collapsed: {
height: 0,
opacity: 0,
marginTop: 0,
marginBottom: 0,
},
expanded: {
height: 'auto',
opacity: 1,
marginTop: '1rem',
marginBottom: '0.5rem',
},
};
return (
<div className="flex flex-col">
{isLoading ? (
<div className="flex flex-row gap-2 items-center">
<div className="font-medium">Reasoning</div>
<div className="animate-spin">
<LoaderIcon />
</div>
</div>
) : (
<div className="flex flex-row gap-2 items-center">
<div className="font-medium">Reasoned for a few seconds</div>
<button
data-testid="message-reasoning-toggle"
type="button"
className="cursor-pointer"
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<ChevronDownIcon />
</button>
</div>
)}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
<Reasoning
isStreaming={isLoading}
defaultOpen={true}
data-testid="message-reasoning"
key="content"
initial="collapsed"
animate="expanded"
exit="collapsed"
variants={variants}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
className="pl-4 text-zinc-600 dark:text-zinc-400 border-l flex flex-col gap-4"
>
<Markdown>{reasoning}</Markdown>
</motion.div>
)}
</AnimatePresence>
</div>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>
</Reasoning>
);
}

View file

@ -3,9 +3,17 @@ import cx from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import { DocumentToolCall, DocumentToolResult } from './document';
import { DocumentToolResult } from './document';
import { PencilEditIcon, SparklesIcon } from './icons';
import { Markdown } from './markdown';
import { Response } from './elements/response';
import { MessageContent } from './elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from './elements/tool';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
@ -54,7 +62,7 @@ const PurePreviewMessage = ({
<AnimatePresence>
<motion.div
data-testid={`message-${message.role}`}
className="w-full mx-auto max-w-3xl px-4 group/message"
className="px-4 mx-auto w-full max-w-3xl group/message"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
data-role={message.role}
@ -69,7 +77,7 @@ const PurePreviewMessage = ({
)}
>
{message.role === 'assistant' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
<div className="flex justify-center items-center rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
<div className="translate-y-px">
<SparklesIcon size={14} />
</div>
@ -84,7 +92,7 @@ const PurePreviewMessage = ({
{attachmentsFromMessage.length > 0 && (
<div
data-testid={`message-attachments`}
className="flex flex-row justify-end gap-2"
className="flex flex-row gap-2 justify-end"
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
@ -123,7 +131,7 @@ const PurePreviewMessage = ({
<Button
data-testid="message-edit-button"
variant="ghost"
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
className="px-2 rounded-full opacity-0 h-fit text-muted-foreground group-hover/message:opacity-100"
onClick={() => {
setMode('edit');
}}
@ -135,15 +143,16 @@ const PurePreviewMessage = ({
</Tooltip>
)}
<div
<MessageContent
data-testid="message-content"
className={cn('flex flex-col gap-4', {
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
className={cn('justify-start items-start text-left', {
'bg-primary text-primary-foreground':
message.role === 'user',
'bg-transparent': message.role === 'assistant',
})}
>
<Markdown>{sanitizeText(part.text)}</Markdown>
</div>
<Response>{sanitizeText(part.text)}</Response>
</MessageContent>
</div>
);
}
@ -168,144 +177,120 @@ const PurePreviewMessage = ({
if (type === 'tool-getWeather') {
const { toolCallId, state } = part;
if (state === 'input-available') {
return (
<div key={toolCallId} className="skeleton">
<Weather />
</div>
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-getWeather" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={<Weather weatherAtLocation={part.output} />}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
if (state === 'output-available') {
const { output } = part;
return (
<div key={toolCallId}>
<Weather weatherAtLocation={output} />
</div>
);
}
}
if (type === 'tool-createDocument') {
const { toolCallId, state } = part;
if (state === 'input-available') {
const { input } = part;
return (
<div key={toolCallId}>
<DocumentPreview isReadonly={isReadonly} args={input} />
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-createDocument" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={
'error' in part.output ? (
<div className="p-2 text-red-500 rounded border">
Error: {String(part.output.error)}
</div>
);
}
if (state === 'output-available') {
const { output } = part;
if ('error' in output) {
return (
<div
key={toolCallId}
className="text-red-500 p-2 border rounded"
>
Error: {String(output.error)}
</div>
);
}
return (
<div key={toolCallId}>
) : (
<DocumentPreview
isReadonly={isReadonly}
result={output}
result={part.output}
/>
</div>
);
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
if (type === 'tool-updateDocument') {
const { toolCallId, state } = part;
if (state === 'input-available') {
const { input } = part;
return (
<div key={toolCallId}>
<DocumentToolCall
type="update"
args={input}
isReadonly={isReadonly}
/>
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-updateDocument" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={
'error' in part.output ? (
<div className="p-2 text-red-500 rounded border">
Error: {String(part.output.error)}
</div>
);
}
if (state === 'output-available') {
const { output } = part;
if ('error' in output) {
return (
<div
key={toolCallId}
className="text-red-500 p-2 border rounded"
>
Error: {String(output.error)}
</div>
);
}
return (
<div key={toolCallId}>
) : (
<DocumentToolResult
type="update"
result={output}
result={part.output}
isReadonly={isReadonly}
/>
</div>
);
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
if (type === 'tool-requestSuggestions') {
const { toolCallId, state } = part;
if (state === 'input-available') {
const { input } = part;
return (
<div key={toolCallId}>
<DocumentToolCall
type="request-suggestions"
args={input}
isReadonly={isReadonly}
/>
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-requestSuggestions" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={
'error' in part.output ? (
<div className="p-2 text-red-500 rounded border">
Error: {String(part.output.error)}
</div>
);
}
if (state === 'output-available') {
const { output } = part;
if ('error' in output) {
return (
<div
key={toolCallId}
className="text-red-500 p-2 border rounded"
>
Error: {String(output.error)}
</div>
);
}
return (
<div key={toolCallId}>
) : (
<DocumentToolResult
type="request-suggestions"
result={output}
result={part.output}
isReadonly={isReadonly}
/>
</div>
);
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
})}
@ -345,7 +330,7 @@ export const ThinkingMessage = () => {
return (
<motion.div
data-testid="message-assistant-loading"
className="w-full mx-auto max-w-3xl px-4 group/message min-h-96"
className="px-4 mx-auto w-full max-w-3xl group/message min-h-96"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
data-role={role}
@ -358,7 +343,7 @@ export const ThinkingMessage = () => {
},
)}
>
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
<div className="flex justify-center items-center rounded-full ring-1 size-8 shrink-0 ring-border">
<SparklesIcon size={14} />
</div>

View file

@ -8,6 +8,7 @@ import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider';
import { Conversation, ConversationContent, ConversationScrollButton } from './elements/conversation';
interface MessagesProps {
chatId: string;
@ -43,10 +44,9 @@ function PureMessages({
useDataStream();
return (
<div
ref={messagesContainerRef}
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4 relative"
>
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
<Conversation className="flex flex-col min-w-0 gap-6 pt-4 pb-32">
<ConversationContent className="flex flex-col gap-6">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
@ -79,6 +79,9 @@ function PureMessages({
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
/>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
</div>
);
}

View file

@ -1,8 +1,6 @@
'use client';
import type { UIMessage } from 'ai';
import cx from 'classnames';
import type React from 'react';
import {
useRef,
useEffect,
@ -19,8 +17,14 @@ import { useLocalStorage, useWindowSize } from 'usehooks-ts';
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
import { PreviewAttachment } from './preview-attachment';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
import { SuggestedActions } from './suggested-actions';
import {
PromptInput,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
PromptInputSubmit,
} from './elements/prompt-input';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import { AnimatePresence, motion } from 'framer-motion';
@ -102,7 +106,6 @@ function PureMultimodalInput({
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value);
adjustHeight();
};
const fileInputRef = useRef<HTMLInputElement>(null);
@ -208,7 +211,7 @@ function PureMultimodalInput({
}, [status, scrollToBottom]);
return (
<div className="relative w-full flex flex-col gap-4">
<div className="flex relative flex-col gap-4 w-full">
<AnimatePresence>
{!isAtBottom && (
<motion.div
@ -216,7 +219,7 @@ function PureMultimodalInput({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="absolute left-1/2 bottom-28 -translate-x-1/2 z-50"
className="absolute bottom-28 left-1/2 z-50 -translate-x-1/2"
>
<Button
data-testid="scroll-to-bottom-button"
@ -253,13 +256,35 @@ function PureMultimodalInput({
tabIndex={-1}
/>
<PromptInput
className="border border-transparent shadow-lg transition-all duration-200 shadow-black/10 hover:border-primary/20 focus-within:border-primary/30 focus-within:shadow-xl focus-within:shadow-primary/20"
onSubmit={(event) => {
event.preventDefault();
if (status !== 'ready') {
toast.error('Please wait for the model to finish its response!');
} else {
submitForm();
}
}}
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
data-testid="attachments-preview"
className="flex flex-row gap-2 overflow-x-scroll items-end"
className="flex overflow-x-scroll flex-row gap-2 items-end px-3 py-2"
>
{attachments.map((attachment) => (
<PreviewAttachment key={attachment.url} attachment={attachment} />
<PreviewAttachment
key={attachment.url}
attachment={attachment}
onRemove={() => {
setAttachments((currentAttachments) =>
currentAttachments.filter((a) => a.url !== attachment.url),
);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
/>
))}
{uploadQueue.map((filename) => (
@ -276,50 +301,36 @@ function PureMultimodalInput({
</div>
)}
<Textarea
<PromptInputTextarea
data-testid="multimodal-input"
ref={textareaRef}
placeholder="Send a message..."
value={input}
onChange={handleInput}
className={cx(
'min-h-[24px] max-h-[calc(75dvh)] overflow-hidden resize-none rounded-2xl !text-base bg-muted pb-10 dark:border-zinc-700',
className,
)}
rows={2}
minHeight={48}
maxHeight={48}
disableAutoResize={true}
style={{ height: '48px', minHeight: '48px', maxHeight: '48px' }}
className="text-sm resize-none py-1 px-3 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
rows={1}
autoFocus
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
if (status !== 'ready') {
toast.error('Please wait for the model to finish its response!');
} else {
submitForm();
}
}
}}
/>
<div className="absolute bottom-0 p-2 w-fit flex flex-row justify-start">
<PromptInputToolbar className="px-2 py-1">
<PromptInputTools className="gap-2">
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
</div>
<div className="absolute bottom-0 right-0 p-2 w-fit flex flex-row justify-end">
</PromptInputTools>
{status === 'submitted' ? (
<StopButton stop={stop} setMessages={setMessages} />
) : (
<SendButton
input={input}
submitForm={submitForm}
uploadQueue={uploadQueue}
<PromptInputSubmit
status={status}
disabled={!input.trim() || uploadQueue.length > 0}
className="bg-primary hover:bg-primary/90 text-primary-foreground size-8"
size="sm"
/>
)}
</div>
</PromptInputToolbar>
</PromptInput>
</div>
);
}

View file

@ -1,45 +1,55 @@
import type { Attachment } from '@/lib/types';
import { LoaderIcon } from './icons';
import { Loader } from './elements/loader';
import { CrossSmallIcon, PencilEditIcon } from './icons';
import { Button } from './ui/button';
export const PreviewAttachment = ({
attachment,
isUploading = false,
onRemove,
onEdit,
}: {
attachment: Attachment;
isUploading?: boolean;
onRemove?: () => void;
onEdit?: () => void;
}) => {
const { name, url, contentType } = attachment;
return (
<div data-testid="input-attachment-preview" className="flex flex-col gap-2">
<div className="w-20 h-16 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
{contentType ? (
contentType.startsWith('image') ? (
// NOTE: it is recommended to use next/image for images
// eslint-disable-next-line @next/next/no-img-element
<div data-testid="input-attachment-preview" className="group relative w-16 h-16 rounded-lg overflow-hidden bg-muted border">
{contentType?.startsWith('image') ? (
<img
key={url}
src={url}
alt={name ?? 'An image attachment'}
className="rounded-md size-full object-cover"
className="w-full h-full object-cover"
/>
) : (
<div className="" />
)
) : (
<div className="" />
<div className="w-full h-full flex items-center justify-center text-xs text-muted-foreground">
File
</div>
)}
{isUploading && (
<div
data-testid="input-attachment-loader"
className="animate-spin absolute text-zinc-500"
>
<LoaderIcon />
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader size={16} />
</div>
)}
{onRemove && !isUploading && (
<Button
onClick={onRemove}
size="sm"
variant="destructive"
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 transition-opacity size-4 p-0 rounded-full"
>
<CrossSmallIcon size={8} />
</Button>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent text-white text-[10px] px-1 py-0.5 truncate">
{name}
</div>
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
</div>
);
};

View file

@ -1,11 +1,11 @@
'use client';
import { motion } from 'framer-motion';
import { Button } from './ui/button';
import { memo } from 'react';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { VisibilityType } from './visibility-selector';
import type { ChatMessage } from '@/lib/types';
import { Suggestion } from './elements/suggestion';
interface SuggestedActionsProps {
chatId: string;
@ -19,59 +19,35 @@ function PureSuggestedActions({
selectedVisibilityType,
}: SuggestedActionsProps) {
const suggestedActions = [
{
title: 'What are the advantages',
label: 'of using Next.js?',
action: 'What are the advantages of using Next.js?',
},
{
title: 'Write code to',
label: `demonstrate djikstra's algorithm`,
action: `Write code to demonstrate djikstra's algorithm`,
},
{
title: 'Help me write an essay',
label: `about silicon valley`,
action: `Help me write an essay about silicon valley`,
},
{
title: 'What is the weather',
label: 'in San Francisco?',
action: 'What is the weather in San Francisco?',
},
'What are the advantages of using Next.js?',
'Write code to demonstrate Dijkstra\'s algorithm',
'Help me write an essay about Silicon Valley',
'What is the weather in San Francisco?',
];
return (
<div
data-testid="suggested-actions"
className="grid sm:grid-cols-2 gap-2 w-full"
>
<div data-testid="suggested-actions" className="grid sm:grid-cols-2 gap-2 w-full">
{suggestedActions.map((suggestedAction, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.05 * index }}
key={`suggested-action-${suggestedAction.title}-${index}`}
className={index > 1 ? 'hidden sm:block' : 'block'}
key={suggestedAction}
>
<Button
variant="ghost"
onClick={async () => {
<Suggestion
suggestion={suggestedAction}
onClick={(suggestion) => {
window.history.replaceState({}, '', `/chat/${chatId}`);
sendMessage({
role: 'user',
parts: [{ type: 'text', text: suggestedAction.action }],
parts: [{ type: 'text', text: suggestion }],
});
}}
className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
className="text-left w-full h-auto whitespace-normal p-3"
>
<span className="font-medium">{suggestedAction.title}</span>
<span className="text-muted-foreground">
{suggestedAction.label}
</span>
</Button>
{suggestedAction}
</Suggestion>
</motion.div>
))}
</div>

View file

@ -9,7 +9,7 @@ import type { UISuggestion } from '@/lib/editor/suggestions';
import { CrossIcon, MessageIcon } from './icons';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
import { ArtifactKind } from './artifact';
import type { ArtifactKind } from './artifact';
export const Suggestion = ({
suggestion,
@ -41,16 +41,16 @@ export const Suggestion = ({
) : (
<motion.div
key={suggestion.id}
className="absolute bg-background p-3 flex flex-col gap-3 rounded-2xl border text-sm w-56 shadow-xl z-50 -right-12 md:-right-16 font-sans"
className="flex absolute -right-12 z-50 flex-col gap-3 p-3 w-56 font-sans text-sm rounded-2xl border shadow-xl bg-background md:-right-16"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: -20 }}
exit={{ opacity: 0, y: -10 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 bg-muted-foreground/25 rounded-full" />
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-center">
<div className="rounded-full size-4 bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button

50
components/ui/avatar.tsx Normal file
View file

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
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 }

36
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

262
components/ui/carousel.tsx Normal file
View file

@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View file

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -5,14 +5,14 @@ import { DOMParser, type Node } from 'prosemirror-model';
import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view';
import { renderToString } from 'react-dom/server';
import { Markdown } from '@/components/markdown';
import { Response } from '@/components/elements/response';
import { documentSchema } from './config';
import { createSuggestionWidget, type UISuggestion } from './suggestions';
export const buildDocumentFromContent = (content: string) => {
const parser = DOMParser.fromSchema(documentSchema);
const stringFromMarkdown = renderToString(<Markdown>{content}</Markdown>);
const stringFromMarkdown = renderToString(<Response>{content}</Response>);
const tempContainer = document.createElement('div');
tempContainer.innerHTML = stringFromMarkdown;
return parser.parse(tempContainer);

View file

@ -9,7 +9,7 @@ import { createRoot } from 'react-dom/client';
import { Suggestion as PreviewSuggestion } from '@/components/suggestion';
import type { Suggestion } from '@/lib/db/schema';
import { ArtifactKind } from '@/components/artifact';
import type { ArtifactKind } from '@/components/artifact';
export interface UISuggestion extends Suggestion {
selectionStart: number;

View file

@ -27,17 +27,23 @@
"@codemirror/state": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.35.3",
"@icons-pack/react-simple-icons": "^13.7.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@radix-ui/react-visually-hidden": "^1.1.0",
"@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1",
@ -54,6 +60,7 @@
"diff-match-patch": "^1.0.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.34.0",
"embla-carousel-react": "^8.6.0",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^11.3.19",
"geist": "^1.3.1",
@ -77,6 +84,7 @@
"react-data-grid": "7.0.0-beta.47",
"react-dom": "19.0.0-rc-45804af1-20241021",
"react-resizable-panels": "^2.1.7",
"react-syntax-highlighter": "^15.6.6",
"redis": "^5.0.0",
"resumable-stream": "^2.0.0",
"server-only": "^0.0.1",
@ -86,6 +94,7 @@
"swr": "^2.2.5",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0",
"zod": "^3.25.76"
},
@ -99,6 +108,7 @@
"@types/pdf-parse": "^1.1.4",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13",
"drizzle-kit": "^0.25.0",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",

741
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff