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}
|
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
|
<MultimodalInput
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
input={input}
|
input={input}
|
||||||
|
|
@ -337,7 +337,7 @@ function PureArtifact({
|
||||||
setMessages={setMessages}
|
setMessages={setMessages}
|
||||||
selectedVisibilityType={selectedVisibilityType}
|
selectedVisibilityType={selectedVisibilityType}
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export function Chat({
|
||||||
isArtifactVisible={isArtifactVisible}
|
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 && (
|
{!isReadonly && (
|
||||||
<MultimodalInput
|
<MultimodalInput
|
||||||
chatId={id}
|
chatId={id}
|
||||||
|
|
@ -164,7 +164,7 @@ export function Chat({
|
||||||
selectedVisibilityType={visibilityType}
|
selectedVisibilityType={visibilityType}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Artifact
|
<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 { Button } from './ui/button';
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
type Dispatch,
|
||||||
SetStateAction,
|
type SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -79,7 +80,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
return consoleOutputs.length > 0 ? (
|
return consoleOutputs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<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}
|
onMouseDown={startResizing}
|
||||||
style={{ bottom: height - 4 }}
|
style={{ bottom: height - 4 }}
|
||||||
role="slider"
|
role="slider"
|
||||||
|
|
@ -88,15 +89,15 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
'select-none': isResizing,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
style={{ height }}
|
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="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="text-sm pl-2 dark:text-zinc-50 text-zinc-800 flex flex-row gap-3 items-center">
|
<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">
|
<div className="text-muted-foreground">
|
||||||
<TerminalWindowIcon />
|
<TerminalWindowIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +105,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"
|
size="icon"
|
||||||
onClick={() => setConsoleOutputs([])}
|
onClick={() => setConsoleOutputs([])}
|
||||||
>
|
>
|
||||||
|
|
@ -116,7 +117,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
{consoleOutputs.map((consoleOutput, index) => (
|
{consoleOutputs.map((consoleOutput, index) => (
|
||||||
<div
|
<div
|
||||||
key={consoleOutput.id}
|
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
|
<div
|
||||||
className={cn('w-12 shrink-0', {
|
className={cn('w-12 shrink-0', {
|
||||||
|
|
@ -134,8 +135,8 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
consoleOutput.status,
|
consoleOutput.status,
|
||||||
) ? (
|
) ? (
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<div className="animate-spin size-fit self-center mb-auto mt-0.5">
|
<div className="size-fit self-center mb-auto mt-0.5">
|
||||||
<LoaderIcon />
|
<Loader size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{consoleOutput.status === 'in_progress'
|
{consoleOutput.status === 'in_progress'
|
||||||
|
|
@ -148,20 +149,20 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
</div>
|
</div>
|
||||||
</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) =>
|
{consoleOutput.contents.map((content, index) =>
|
||||||
content.type === 'image' ? (
|
content.type === 'image' ? (
|
||||||
<picture key={`${consoleOutput.id}-${index}`}>
|
<picture key={`${consoleOutput.id}-${index}`}>
|
||||||
<img
|
<img
|
||||||
src={content.value}
|
src={content.value}
|
||||||
alt="output"
|
alt="output"
|
||||||
className="rounded-md max-w-screen-toast-mobile w-full"
|
className="w-full rounded-md max-w-screen-toast-mobile"
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={`${consoleOutput.id}-${index}`}
|
key={`${consoleOutput.id}-${index}`}
|
||||||
className="whitespace-pre-line break-words w-full"
|
className="w-full whitespace-pre-line break-words"
|
||||||
>
|
>
|
||||||
{content.value}
|
{content.value}
|
||||||
</div>
|
</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 type { Vote } from '@/lib/db/schema';
|
||||||
|
|
||||||
import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
|
import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
|
||||||
import { Button } from './ui/button';
|
import { Actions, Action } from './elements/actions';
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from './ui/tooltip';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -34,13 +28,9 @@ export function PureMessageActions({
|
||||||
if (message.role === 'user') return null;
|
if (message.role === 'user') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<Actions>
|
||||||
<div className="flex flex-row gap-2">
|
<Action
|
||||||
<Tooltip>
|
tooltip="Copy"
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="py-1 px-2 h-fit text-muted-foreground"
|
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const textFromParts = message.parts
|
const textFromParts = message.parts
|
||||||
?.filter((part) => part.type === 'text')
|
?.filter((part) => part.type === 'text')
|
||||||
|
|
@ -58,18 +48,12 @@ export function PureMessageActions({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Button>
|
</Action>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Copy</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Action
|
||||||
<TooltipTrigger asChild>
|
tooltip="Upvote Response"
|
||||||
<Button
|
|
||||||
data-testid="message-upvote"
|
data-testid="message-upvote"
|
||||||
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
|
|
||||||
disabled={vote?.isUpvoted}
|
disabled={vote?.isUpvoted}
|
||||||
variant="outline"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const upvote = fetch('/api/vote', {
|
const upvote = fetch('/api/vote', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
@ -111,17 +95,11 @@ export function PureMessageActions({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThumbUpIcon />
|
<ThumbUpIcon />
|
||||||
</Button>
|
</Action>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Upvote Response</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Action
|
||||||
<TooltipTrigger asChild>
|
tooltip="Downvote Response"
|
||||||
<Button
|
|
||||||
data-testid="message-downvote"
|
data-testid="message-downvote"
|
||||||
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
|
|
||||||
variant="outline"
|
|
||||||
disabled={vote && !vote.isUpvoted}
|
disabled={vote && !vote.isUpvoted}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const downvote = fetch('/api/vote', {
|
const downvote = fetch('/api/vote', {
|
||||||
|
|
@ -164,12 +142,8 @@ export function PureMessageActions({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThumbDownIcon />
|
<ThumbDownIcon />
|
||||||
</Button>
|
</Action>
|
||||||
</TooltipTrigger>
|
</Actions>
|
||||||
<TooltipContent>Downvote Response</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from './elements/reasoning';
|
||||||
import { ChevronDownIcon, LoaderIcon } from './icons';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Markdown } from './markdown';
|
|
||||||
|
|
||||||
interface MessageReasoningProps {
|
interface MessageReasoningProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -14,65 +11,14 @@ export function MessageReasoning({
|
||||||
isLoading,
|
isLoading,
|
||||||
reasoning,
|
reasoning,
|
||||||
}: MessageReasoningProps) {
|
}: 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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<Reasoning
|
||||||
{isLoading ? (
|
isStreaming={isLoading}
|
||||||
<div className="flex flex-row gap-2 items-center">
|
defaultOpen={true}
|
||||||
<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"
|
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>
|
<ReasoningTrigger />
|
||||||
</motion.div>
|
<ReasoningContent>{reasoning}</ReasoningContent>
|
||||||
)}
|
</Reasoning>
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,17 @@ import cx from 'classnames';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import type { Vote } from '@/lib/db/schema';
|
import type { Vote } from '@/lib/db/schema';
|
||||||
import { DocumentToolCall, DocumentToolResult } from './document';
|
import { DocumentToolResult } from './document';
|
||||||
import { PencilEditIcon, SparklesIcon } from './icons';
|
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 { MessageActions } from './message-actions';
|
||||||
import { PreviewAttachment } from './preview-attachment';
|
import { PreviewAttachment } from './preview-attachment';
|
||||||
import { Weather } from './weather';
|
import { Weather } from './weather';
|
||||||
|
|
@ -54,7 +62,7 @@ const PurePreviewMessage = ({
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
data-testid={`message-${message.role}`}
|
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 }}
|
initial={{ y: 5, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
data-role={message.role}
|
data-role={message.role}
|
||||||
|
|
@ -69,7 +77,7 @@ const PurePreviewMessage = ({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.role === 'assistant' && (
|
{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">
|
<div className="translate-y-px">
|
||||||
<SparklesIcon size={14} />
|
<SparklesIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,7 +92,7 @@ const PurePreviewMessage = ({
|
||||||
{attachmentsFromMessage.length > 0 && (
|
{attachmentsFromMessage.length > 0 && (
|
||||||
<div
|
<div
|
||||||
data-testid={`message-attachments`}
|
data-testid={`message-attachments`}
|
||||||
className="flex flex-row justify-end gap-2"
|
className="flex flex-row gap-2 justify-end"
|
||||||
>
|
>
|
||||||
{attachmentsFromMessage.map((attachment) => (
|
{attachmentsFromMessage.map((attachment) => (
|
||||||
<PreviewAttachment
|
<PreviewAttachment
|
||||||
|
|
@ -123,7 +131,7 @@ const PurePreviewMessage = ({
|
||||||
<Button
|
<Button
|
||||||
data-testid="message-edit-button"
|
data-testid="message-edit-button"
|
||||||
variant="ghost"
|
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={() => {
|
onClick={() => {
|
||||||
setMode('edit');
|
setMode('edit');
|
||||||
}}
|
}}
|
||||||
|
|
@ -135,15 +143,16 @@ const PurePreviewMessage = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<MessageContent
|
||||||
data-testid="message-content"
|
data-testid="message-content"
|
||||||
className={cn('flex flex-col gap-4', {
|
className={cn('justify-start items-start text-left', {
|
||||||
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
|
'bg-primary text-primary-foreground':
|
||||||
message.role === 'user',
|
message.role === 'user',
|
||||||
|
'bg-transparent': message.role === 'assistant',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Markdown>{sanitizeText(part.text)}</Markdown>
|
<Response>{sanitizeText(part.text)}</Response>
|
||||||
</div>
|
</MessageContent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -168,144 +177,120 @@ const PurePreviewMessage = ({
|
||||||
if (type === 'tool-getWeather') {
|
if (type === 'tool-getWeather') {
|
||||||
const { toolCallId, state } = part;
|
const { toolCallId, state } = part;
|
||||||
|
|
||||||
if (state === 'input-available') {
|
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId} className="skeleton">
|
<Tool key={toolCallId} defaultOpen={true}>
|
||||||
<Weather />
|
<ToolHeader type="tool-getWeather" state={state} />
|
||||||
</div>
|
<ToolContent>
|
||||||
|
{state === 'input-available' && (
|
||||||
|
<ToolInput input={part.input} />
|
||||||
|
)}
|
||||||
|
{state === 'output-available' && (
|
||||||
|
<ToolOutput
|
||||||
|
output={<Weather weatherAtLocation={part.output} />}
|
||||||
|
errorText={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToolContent>
|
||||||
|
</Tool>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'output-available') {
|
|
||||||
const { output } = part;
|
|
||||||
return (
|
|
||||||
<div key={toolCallId}>
|
|
||||||
<Weather weatherAtLocation={output} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'tool-createDocument') {
|
if (type === 'tool-createDocument') {
|
||||||
const { toolCallId, state } = part;
|
const { toolCallId, state } = part;
|
||||||
|
|
||||||
if (state === 'input-available') {
|
|
||||||
const { input } = part;
|
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId}>
|
<Tool key={toolCallId} defaultOpen={true}>
|
||||||
<DocumentPreview isReadonly={isReadonly} args={input} />
|
<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>
|
</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
|
<DocumentPreview
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
result={output}
|
result={part.output}
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
errorText={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToolContent>
|
||||||
|
</Tool>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'tool-updateDocument') {
|
if (type === 'tool-updateDocument') {
|
||||||
const { toolCallId, state } = part;
|
const { toolCallId, state } = part;
|
||||||
|
|
||||||
if (state === 'input-available') {
|
|
||||||
const { input } = part;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId}>
|
<Tool key={toolCallId} defaultOpen={true}>
|
||||||
<DocumentToolCall
|
<ToolHeader type="tool-updateDocument" state={state} />
|
||||||
type="update"
|
<ToolContent>
|
||||||
args={input}
|
{state === 'input-available' && (
|
||||||
isReadonly={isReadonly}
|
<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>
|
</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
|
<DocumentToolResult
|
||||||
type="update"
|
type="update"
|
||||||
result={output}
|
result={part.output}
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
errorText={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToolContent>
|
||||||
|
</Tool>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'tool-requestSuggestions') {
|
if (type === 'tool-requestSuggestions') {
|
||||||
const { toolCallId, state } = part;
|
const { toolCallId, state } = part;
|
||||||
|
|
||||||
if (state === 'input-available') {
|
|
||||||
const { input } = part;
|
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId}>
|
<Tool key={toolCallId} defaultOpen={true}>
|
||||||
<DocumentToolCall
|
<ToolHeader type="tool-requestSuggestions" state={state} />
|
||||||
type="request-suggestions"
|
<ToolContent>
|
||||||
args={input}
|
{state === 'input-available' && (
|
||||||
isReadonly={isReadonly}
|
<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>
|
</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
|
<DocumentToolResult
|
||||||
type="request-suggestions"
|
type="request-suggestions"
|
||||||
result={output}
|
result={part.output}
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
errorText={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToolContent>
|
||||||
|
</Tool>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -345,7 +330,7 @@ export const ThinkingMessage = () => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
data-testid="message-assistant-loading"
|
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 }}
|
initial={{ y: 5, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
|
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
|
||||||
data-role={role}
|
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} />
|
<SparklesIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { motion } from 'framer-motion';
|
||||||
import { useMessages } from '@/hooks/use-messages';
|
import { useMessages } from '@/hooks/use-messages';
|
||||||
import type { ChatMessage } from '@/lib/types';
|
import type { ChatMessage } from '@/lib/types';
|
||||||
import { useDataStream } from './data-stream-provider';
|
import { useDataStream } from './data-stream-provider';
|
||||||
|
import { Conversation, ConversationContent, ConversationScrollButton } from './elements/conversation';
|
||||||
|
|
||||||
interface MessagesProps {
|
interface MessagesProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|
@ -43,10 +44,9 @@ function PureMessages({
|
||||||
useDataStream();
|
useDataStream();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
|
||||||
ref={messagesContainerRef}
|
<Conversation className="flex flex-col min-w-0 gap-6 pt-4 pb-32">
|
||||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4 relative"
|
<ConversationContent className="flex flex-col gap-6">
|
||||||
>
|
|
||||||
{messages.length === 0 && <Greeting />}
|
{messages.length === 0 && <Greeting />}
|
||||||
|
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
|
|
@ -79,6 +79,9 @@ function PureMessages({
|
||||||
onViewportLeave={onViewportLeave}
|
onViewportLeave={onViewportLeave}
|
||||||
onViewportEnter={onViewportEnter}
|
onViewportEnter={onViewportEnter}
|
||||||
/>
|
/>
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton />
|
||||||
|
</Conversation>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
import cx from 'classnames';
|
|
||||||
import type React from 'react';
|
|
||||||
import {
|
import {
|
||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -19,8 +17,14 @@ import { useLocalStorage, useWindowSize } from 'usehooks-ts';
|
||||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
||||||
import { PreviewAttachment } from './preview-attachment';
|
import { PreviewAttachment } from './preview-attachment';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Textarea } from './ui/textarea';
|
|
||||||
import { SuggestedActions } from './suggested-actions';
|
import { SuggestedActions } from './suggested-actions';
|
||||||
|
import {
|
||||||
|
PromptInput,
|
||||||
|
PromptInputTextarea,
|
||||||
|
PromptInputToolbar,
|
||||||
|
PromptInputTools,
|
||||||
|
PromptInputSubmit,
|
||||||
|
} from './elements/prompt-input';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import type { UseChatHelpers } from '@ai-sdk/react';
|
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
@ -102,7 +106,6 @@ function PureMultimodalInput({
|
||||||
|
|
||||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setInput(event.target.value);
|
setInput(event.target.value);
|
||||||
adjustHeight();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -208,7 +211,7 @@ function PureMultimodalInput({
|
||||||
}, [status, scrollToBottom]);
|
}, [status, scrollToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full flex flex-col gap-4">
|
<div className="flex relative flex-col gap-4 w-full">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -216,7 +219,7 @@ function PureMultimodalInput({
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, y: 10 }}
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
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
|
<Button
|
||||||
data-testid="scroll-to-bottom-button"
|
data-testid="scroll-to-bottom-button"
|
||||||
|
|
@ -253,13 +256,35 @@ function PureMultimodalInput({
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PromptInput
|
||||||
|
className="border border-transparent shadow-lg transition-all duration-200 shadow-black/10 hover:border-primary/20 focus-within:border-primary/30 focus-within:shadow-xl focus-within:shadow-primary/20"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (status !== 'ready') {
|
||||||
|
toast.error('Please wait for the model to finish its response!');
|
||||||
|
} else {
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||||
<div
|
<div
|
||||||
data-testid="attachments-preview"
|
data-testid="attachments-preview"
|
||||||
className="flex flex-row gap-2 overflow-x-scroll items-end"
|
className="flex overflow-x-scroll flex-row gap-2 items-end px-3 py-2"
|
||||||
>
|
>
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
<PreviewAttachment
|
||||||
|
key={attachment.url}
|
||||||
|
attachment={attachment}
|
||||||
|
onRemove={() => {
|
||||||
|
setAttachments((currentAttachments) =>
|
||||||
|
currentAttachments.filter((a) => a.url !== attachment.url),
|
||||||
|
);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{uploadQueue.map((filename) => (
|
{uploadQueue.map((filename) => (
|
||||||
|
|
@ -276,50 +301,36 @@ function PureMultimodalInput({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea
|
<PromptInputTextarea
|
||||||
data-testid="multimodal-input"
|
data-testid="multimodal-input"
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
className={cx(
|
minHeight={48}
|
||||||
'min-h-[24px] max-h-[calc(75dvh)] overflow-hidden resize-none rounded-2xl !text-base bg-muted pb-10 dark:border-zinc-700',
|
maxHeight={48}
|
||||||
className,
|
disableAutoResize={true}
|
||||||
)}
|
style={{ height: '48px', minHeight: '48px', maxHeight: '48px' }}
|
||||||
rows={2}
|
className="text-sm resize-none py-1 px-3 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||||
|
rows={1}
|
||||||
autoFocus
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<PromptInputToolbar className="px-2 py-1">
|
||||||
<div className="absolute bottom-0 p-2 w-fit flex flex-row justify-start">
|
<PromptInputTools className="gap-2">
|
||||||
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
||||||
</div>
|
</PromptInputTools>
|
||||||
|
|
||||||
<div className="absolute bottom-0 right-0 p-2 w-fit flex flex-row justify-end">
|
|
||||||
{status === 'submitted' ? (
|
{status === 'submitted' ? (
|
||||||
<StopButton stop={stop} setMessages={setMessages} />
|
<StopButton stop={stop} setMessages={setMessages} />
|
||||||
) : (
|
) : (
|
||||||
<SendButton
|
<PromptInputSubmit
|
||||||
input={input}
|
status={status}
|
||||||
submitForm={submitForm}
|
disabled={!input.trim() || uploadQueue.length > 0}
|
||||||
uploadQueue={uploadQueue}
|
className="bg-primary hover:bg-primary/90 text-primary-foreground size-8"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PromptInputToolbar>
|
||||||
|
</PromptInput>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,55 @@
|
||||||
import type { Attachment } from '@/lib/types';
|
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 = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
isUploading = false,
|
isUploading = false,
|
||||||
|
onRemove,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
isUploading?: boolean;
|
isUploading?: boolean;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { name, url, contentType } = attachment;
|
const { name, url, contentType } = attachment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="input-attachment-preview" className="flex flex-col gap-2">
|
<div data-testid="input-attachment-preview" className="group relative w-16 h-16 rounded-lg overflow-hidden bg-muted border">
|
||||||
<div className="w-20 h-16 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
|
{contentType?.startsWith('image') ? (
|
||||||
{contentType ? (
|
|
||||||
contentType.startsWith('image') ? (
|
|
||||||
// NOTE: it is recommended to use next/image for images
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
key={url}
|
|
||||||
src={url}
|
src={url}
|
||||||
alt={name ?? 'An image attachment'}
|
alt={name ?? 'An image attachment'}
|
||||||
className="rounded-md size-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="" />
|
<div className="w-full h-full flex items-center justify-center text-xs text-muted-foreground">
|
||||||
)
|
File
|
||||||
) : (
|
</div>
|
||||||
<div className="" />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
data-testid="input-attachment-loader"
|
<Loader size={16} />
|
||||||
className="animate-spin absolute text-zinc-500"
|
|
||||||
>
|
|
||||||
<LoaderIcon />
|
|
||||||
</div>
|
</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>
|
||||||
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { UseChatHelpers } from '@ai-sdk/react';
|
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||||
import type { VisibilityType } from './visibility-selector';
|
import type { VisibilityType } from './visibility-selector';
|
||||||
import type { ChatMessage } from '@/lib/types';
|
import type { ChatMessage } from '@/lib/types';
|
||||||
|
import { Suggestion } from './elements/suggestion';
|
||||||
|
|
||||||
interface SuggestedActionsProps {
|
interface SuggestedActionsProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|
@ -19,59 +19,35 @@ function PureSuggestedActions({
|
||||||
selectedVisibilityType,
|
selectedVisibilityType,
|
||||||
}: SuggestedActionsProps) {
|
}: SuggestedActionsProps) {
|
||||||
const suggestedActions = [
|
const suggestedActions = [
|
||||||
{
|
'What are the advantages of using Next.js?',
|
||||||
title: 'What are the advantages',
|
'Write code to demonstrate Dijkstra\'s algorithm',
|
||||||
label: 'of using Next.js?',
|
'Help me write an essay about Silicon Valley',
|
||||||
action: 'What are the advantages of using Next.js?',
|
'What is the weather in San Francisco?',
|
||||||
},
|
|
||||||
{
|
|
||||||
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?',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-testid="suggested-actions" className="grid sm:grid-cols-2 gap-2 w-full">
|
||||||
data-testid="suggested-actions"
|
|
||||||
className="grid sm:grid-cols-2 gap-2 w-full"
|
|
||||||
>
|
|
||||||
{suggestedActions.map((suggestedAction, index) => (
|
{suggestedActions.map((suggestedAction, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 20 }}
|
exit={{ opacity: 0, y: 20 }}
|
||||||
transition={{ delay: 0.05 * index }}
|
transition={{ delay: 0.05 * index }}
|
||||||
key={`suggested-action-${suggestedAction.title}-${index}`}
|
key={suggestedAction}
|
||||||
className={index > 1 ? 'hidden sm:block' : 'block'}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Suggestion
|
||||||
variant="ghost"
|
suggestion={suggestedAction}
|
||||||
onClick={async () => {
|
onClick={(suggestion) => {
|
||||||
window.history.replaceState({}, '', `/chat/${chatId}`);
|
window.history.replaceState({}, '', `/chat/${chatId}`);
|
||||||
|
|
||||||
sendMessage({
|
sendMessage({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{ type: 'text', text: suggestedAction.action }],
|
parts: [{ type: 'text', text: suggestion }],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
|
className="text-left w-full h-auto whitespace-normal p-3"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{suggestedAction.title}</span>
|
{suggestedAction}
|
||||||
<span className="text-muted-foreground">
|
</Suggestion>
|
||||||
{suggestedAction.label}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { UISuggestion } from '@/lib/editor/suggestions';
|
||||||
import { CrossIcon, MessageIcon } from './icons';
|
import { CrossIcon, MessageIcon } from './icons';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ArtifactKind } from './artifact';
|
import type { ArtifactKind } from './artifact';
|
||||||
|
|
||||||
export const Suggestion = ({
|
export const Suggestion = ({
|
||||||
suggestion,
|
suggestion,
|
||||||
|
|
@ -41,16 +41,16 @@ export const Suggestion = ({
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={suggestion.id}
|
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 }}
|
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: -20 }}
|
animate={{ opacity: 1, y: -20 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<div className="size-4 bg-muted-foreground/25 rounded-full" />
|
<div className="rounded-full size-4 bg-muted-foreground/25" />
|
||||||
<div className="font-medium">Assistant</div>
|
<div className="font-medium">Assistant</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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 }
|
||||||
|
|
@ -5,14 +5,14 @@ import { DOMParser, type Node } from 'prosemirror-model';
|
||||||
import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view';
|
import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view';
|
||||||
import { renderToString } from 'react-dom/server';
|
import { renderToString } from 'react-dom/server';
|
||||||
|
|
||||||
import { Markdown } from '@/components/markdown';
|
import { Response } from '@/components/elements/response';
|
||||||
|
|
||||||
import { documentSchema } from './config';
|
import { documentSchema } from './config';
|
||||||
import { createSuggestionWidget, type UISuggestion } from './suggestions';
|
import { createSuggestionWidget, type UISuggestion } from './suggestions';
|
||||||
|
|
||||||
export const buildDocumentFromContent = (content: string) => {
|
export const buildDocumentFromContent = (content: string) => {
|
||||||
const parser = DOMParser.fromSchema(documentSchema);
|
const parser = DOMParser.fromSchema(documentSchema);
|
||||||
const stringFromMarkdown = renderToString(<Markdown>{content}</Markdown>);
|
const stringFromMarkdown = renderToString(<Response>{content}</Response>);
|
||||||
const tempContainer = document.createElement('div');
|
const tempContainer = document.createElement('div');
|
||||||
tempContainer.innerHTML = stringFromMarkdown;
|
tempContainer.innerHTML = stringFromMarkdown;
|
||||||
return parser.parse(tempContainer);
|
return parser.parse(tempContainer);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { Suggestion as PreviewSuggestion } from '@/components/suggestion';
|
import { Suggestion as PreviewSuggestion } from '@/components/suggestion';
|
||||||
import type { Suggestion } from '@/lib/db/schema';
|
import type { Suggestion } from '@/lib/db/schema';
|
||||||
import { ArtifactKind } from '@/components/artifact';
|
import type { ArtifactKind } from '@/components/artifact';
|
||||||
|
|
||||||
export interface UISuggestion extends Suggestion {
|
export interface UISuggestion extends Suggestion {
|
||||||
selectionStart: number;
|
selectionStart: number;
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -27,17 +27,23 @@
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@codemirror/view": "^6.35.3",
|
"@codemirror/view": "^6.35.3",
|
||||||
|
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/api-logs": "^0.200.0",
|
"@opentelemetry/api-logs": "^0.200.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||||
"@vercel/analytics": "^1.3.1",
|
"@vercel/analytics": "^1.3.1",
|
||||||
"@vercel/blob": "^0.24.1",
|
"@vercel/blob": "^0.24.1",
|
||||||
|
|
@ -54,6 +60,7 @@
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.34.0",
|
"drizzle-orm": "^0.34.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"framer-motion": "^11.3.19",
|
"framer-motion": "^11.3.19",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
|
@ -77,6 +84,7 @@
|
||||||
"react-data-grid": "7.0.0-beta.47",
|
"react-data-grid": "7.0.0-beta.47",
|
||||||
"react-dom": "19.0.0-rc-45804af1-20241021",
|
"react-dom": "19.0.0-rc-45804af1-20241021",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"redis": "^5.0.0",
|
"redis": "^5.0.0",
|
||||||
"resumable-stream": "^2.0.0",
|
"resumable-stream": "^2.0.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
|
@ -86,6 +94,7 @@
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|
@ -99,6 +108,7 @@
|
||||||
"@types/pdf-parse": "^1.1.4",
|
"@types/pdf-parse": "^1.1.4",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"drizzle-kit": "^0.25.0",
|
"drizzle-kit": "^0.25.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
|
|
|
||||||
741
pnpm-lock.yaml
generated
741
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue