feat: ai elements (#1143)
This commit is contained in:
parent
66e8227655
commit
f09be3f286
38 changed files with 3558 additions and 500 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
65
components/elements/actions.tsx
Normal file
65
components/elements/actions.tsx
Normal 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;
|
||||
};
|
||||
212
components/elements/branch.tsx
Normal file
212
components/elements/branch.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
148
components/elements/code-block.tsx
Normal file
148
components/elements/code-block.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
62
components/elements/conversation.tsx
Normal file
62
components/elements/conversation.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
24
components/elements/image.tsx
Normal file
24
components/elements/image.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
287
components/elements/inline-citation.tsx
Normal file
287
components/elements/inline-citation.tsx
Normal 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>
|
||||
);
|
||||
96
components/elements/loader.tsx
Normal file
96
components/elements/loader.tsx
Normal 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>
|
||||
);
|
||||
58
components/elements/message.tsx
Normal file
58
components/elements/message.tsx
Normal 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>
|
||||
);
|
||||
235
components/elements/prompt-input.tsx
Normal file
235
components/elements/prompt-input.tsx
Normal 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} />
|
||||
);
|
||||
171
components/elements/reasoning.tsx
Normal file
171
components/elements/reasoning.tsx
Normal 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';
|
||||
22
components/elements/response.tsx
Normal file
22
components/elements/response.tsx
Normal 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';
|
||||
74
components/elements/source.tsx
Normal file
74
components/elements/source.tsx
Normal 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>
|
||||
);
|
||||
53
components/elements/suggestion.tsx
Normal file
53
components/elements/suggestion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
94
components/elements/task.tsx
Normal file
94
components/elements/task.tsx
Normal 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>
|
||||
);
|
||||
144
components/elements/tool.tsx
Normal file
144
components/elements/tool.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
252
components/elements/web-preview.tsx
Normal file
252
components/elements/web-preview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,42 +28,32 @@ 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"
|
||||
onClick={async () => {
|
||||
const textFromParts = message.parts
|
||||
?.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
<Actions>
|
||||
<Action
|
||||
tooltip="Copy"
|
||||
onClick={async () => {
|
||||
const textFromParts = message.parts
|
||||
?.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!textFromParts) {
|
||||
toast.error("There's no text to copy!");
|
||||
return;
|
||||
}
|
||||
if (!textFromParts) {
|
||||
toast.error("There's no text to copy!");
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(textFromParts);
|
||||
toast.success('Copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
</Tooltip>
|
||||
await copyToClipboard(textFromParts);
|
||||
toast.success('Copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-testid="message-upvote"
|
||||
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
|
||||
disabled={vote?.isUpvoted}
|
||||
variant="outline"
|
||||
<Action
|
||||
tooltip="Upvote Response"
|
||||
data-testid="message-upvote"
|
||||
disabled={vote?.isUpvoted}
|
||||
onClick={async () => {
|
||||
const upvote = fetch('/api/vote', {
|
||||
method: 'PATCH',
|
||||
|
|
@ -109,20 +93,14 @@ export function PureMessageActions({
|
|||
error: 'Failed to upvote response.',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Upvote Response</TooltipContent>
|
||||
</Tooltip>
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
</Action>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-testid="message-downvote"
|
||||
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
|
||||
variant="outline"
|
||||
disabled={vote && !vote.isUpvoted}
|
||||
<Action
|
||||
tooltip="Downvote Response"
|
||||
data-testid="message-downvote"
|
||||
disabled={vote && !vote.isUpvoted}
|
||||
onClick={async () => {
|
||||
const downvote = fetch('/api/vote', {
|
||||
method: 'PATCH',
|
||||
|
|
@ -162,14 +140,10 @@ export function PureMessageActions({
|
|||
error: 'Failed to downvote response.',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Downvote Response</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
</Action>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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>
|
||||
<Reasoning
|
||||
isStreaming={isLoading}
|
||||
defaultOpen={true}
|
||||
data-testid="message-reasoning"
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoning}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'output-available') {
|
||||
const { output } = part;
|
||||
return (
|
||||
<div key={toolCallId}>
|
||||
<Weather weatherAtLocation={output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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 (type === 'tool-createDocument') {
|
||||
const { toolCallId, state } = part;
|
||||
|
||||
if (state === 'input-available') {
|
||||
const { input } = part;
|
||||
return (
|
||||
<div key={toolCallId}>
|
||||
<DocumentPreview isReadonly={isReadonly} args={input} />
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<DocumentPreview
|
||||
isReadonly={isReadonly}
|
||||
result={part.output}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</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}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<DocumentToolResult
|
||||
type="update"
|
||||
result={part.output}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</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}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<DocumentToolResult
|
||||
type="request-suggestions"
|
||||
result={part.output}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,42 +44,44 @@ function PureMessages({
|
|||
useDataStream();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4 relative"
|
||||
>
|
||||
{messages.length === 0 && <Greeting />}
|
||||
<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) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
isLoading={status === 'streaming' && messages.length - 1 === index}
|
||||
vote={
|
||||
votes
|
||||
? votes.find((vote) => vote.messageId === message.id)
|
||||
: undefined
|
||||
}
|
||||
setMessages={setMessages}
|
||||
regenerate={regenerate}
|
||||
isReadonly={isReadonly}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
isLoading={status === 'streaming' && messages.length - 1 === index}
|
||||
vote={
|
||||
votes
|
||||
? votes.find((vote) => vote.messageId === message.id)
|
||||
: undefined
|
||||
}
|
||||
setMessages={setMessages}
|
||||
regenerate={regenerate}
|
||||
isReadonly={isReadonly}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{status === 'submitted' &&
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
|
||||
{status === 'submitted' &&
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
|
||||
|
||||
<motion.div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
onViewportLeave={onViewportLeave}
|
||||
onViewportEnter={onViewportEnter}
|
||||
/>
|
||||
<motion.div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
onViewportLeave={onViewportLeave}
|
||||
onViewportEnter={onViewportEnter}
|
||||
/>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,73 +256,81 @@ function PureMultimodalInput({
|
|||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||
<div
|
||||
data-testid="attachments-preview"
|
||||
className="flex flex-row gap-2 overflow-x-scroll items-end"
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
||||
))}
|
||||
|
||||
{uploadQueue.map((filename) => (
|
||||
<PreviewAttachment
|
||||
key={filename}
|
||||
attachment={{
|
||||
url: '',
|
||||
name: filename,
|
||||
contentType: '',
|
||||
}}
|
||||
isUploading={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
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}
|
||||
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();
|
||||
}
|
||||
<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 overflow-x-scroll flex-row gap-2 items-end px-3 py-2"
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
key={attachment.url}
|
||||
attachment={attachment}
|
||||
onRemove={() => {
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.filter((a) => a.url !== attachment.url),
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute bottom-0 p-2 w-fit flex flex-row justify-start">
|
||||
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 right-0 p-2 w-fit flex flex-row justify-end">
|
||||
{status === 'submitted' ? (
|
||||
<StopButton stop={stop} setMessages={setMessages} />
|
||||
) : (
|
||||
<SendButton
|
||||
input={input}
|
||||
submitForm={submitForm}
|
||||
uploadQueue={uploadQueue}
|
||||
/>
|
||||
{uploadQueue.map((filename) => (
|
||||
<PreviewAttachment
|
||||
key={filename}
|
||||
attachment={{
|
||||
url: '',
|
||||
name: filename,
|
||||
contentType: '',
|
||||
}}
|
||||
isUploading={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptInputTextarea
|
||||
data-testid="multimodal-input"
|
||||
ref={textareaRef}
|
||||
placeholder="Send a message..."
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
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
|
||||
/>
|
||||
<PromptInputToolbar className="px-2 py-1">
|
||||
<PromptInputTools className="gap-2">
|
||||
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
||||
</PromptInputTools>
|
||||
{status === 'submitted' ? (
|
||||
<StopButton stop={stop} setMessages={setMessages} />
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
disabled={!input.trim() || uploadQueue.length > 0}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground size-8"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt={name ?? 'An image attachment'}
|
||||
className="rounded-md size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="" />
|
||||
)
|
||||
) : (
|
||||
<div className="" />
|
||||
)}
|
||||
<div data-testid="input-attachment-preview" className="group relative w-16 h-16 rounded-lg overflow-hidden bg-muted border">
|
||||
{contentType?.startsWith('image') ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={name ?? 'An image attachment'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{isUploading && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,61 +19,37 @@ 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"
|
||||
>
|
||||
{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'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
window.history.replaceState({}, '', `/chat/${chatId}`);
|
||||
|
||||
sendMessage({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: suggestedAction.action }],
|
||||
});
|
||||
}}
|
||||
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"
|
||||
<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={suggestedAction}
|
||||
>
|
||||
<span className="font-medium">{suggestedAction.title}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{suggestedAction.label}
|
||||
</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
<Suggestion
|
||||
suggestion={suggestedAction}
|
||||
onClick={(suggestion) => {
|
||||
window.history.replaceState({}, '', `/chat/${chatId}`);
|
||||
sendMessage({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: suggestion }],
|
||||
});
|
||||
}}
|
||||
className="text-left w-full h-auto whitespace-normal p-3"
|
||||
>
|
||||
{suggestedAction}
|
||||
</Suggestion>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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
262
components/ui/carousel.tsx
Normal 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,
|
||||
}
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal 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 }
|
||||
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal 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 }
|
||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue