Implemented Inspiration-agent app, a chat interface for interacting with repoguide so viewers of the repository have a nicer interface to access him through

This commit is contained in:
NicholaiVogel 2025-10-05 19:54:47 -06:00
parent 11815fc119
commit de26e3464d
50 changed files with 33509 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@icons-pack/react-simple-icons": "^13.8.0",
"@opennextjs/cloudflare": "^1.3.0", "@opennextjs/cloudflare": "^1.3.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.7",
@ -28,11 +29,15 @@
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"ai": "^5.0.60",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"color": "^5.0.2", "color": "^5.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"harden-react-markdown": "^1.1.2",
"katex": "^0.16.23",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next": "15.4.6", "next": "15.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@ -41,7 +46,14 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.64.0", "react-hook-form": "^7.64.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1",
"zod": "^4.1.11" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
@ -51,6 +63,7 @@
"@types/node": "^20.19.19", "@types/node": "^20.19.19",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@ -31,7 +31,6 @@ export default function Home() {
{ id: 17, height: 240, width: 'normal' }, { id: 17, height: 240, width: 'normal' },
{ id: 18, height: 180, width: 'normal' }, { id: 18, height: 180, width: 'normal' },
]; ];
// Mock data for collections // Mock data for collections
const mockCollections = [ const mockCollections = [
{ id: 1, name: 'Brand Identity', itemCount: 47 }, { id: 1, name: 'Brand Identity', itemCount: 47 },

View File

@ -0,0 +1,65 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn-ui/components/ui/tooltip';
import { cn } from '@repo/shadcn-ui/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',
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@ -0,0 +1,212 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(null);
const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error('Branch components must be used within Branch');
}
return context;
};
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View File

@ -0,0 +1,148 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@ -0,0 +1,62 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn('p-4', className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@ -0,0 +1,24 @@
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn(
'h-auto max-w-full overflow-hidden rounded-md',
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View File

@ -0,0 +1,283 @@
'use client';
import { Badge } from '@repo/shadcn-ui/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@repo/shadcn-ui/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@repo/shadcn-ui/components/ui/hover-card';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { type ComponentProps, useCallback, useEffect, useState, useRef, createContext, useContext } from 'react';
// Context to share carousel API with child components
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
// Hook to access carousel API from the nearest InlineCitationCarousel parent
const useCarouselApi = () => {
const api = useContext(CarouselApiContext);
return api;
};
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} />
);
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', className)} {...props} />
);
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div
className={cn(
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn('shrink-0', className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<'div'> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn('space-y-1', className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
className
)}
{...props}
>
{children}
</blockquote>
);

View File

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

View File

@ -0,0 +1,64 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@repo/shadcn-ui/components/ui/avatar';
import { cn } from '@repo/shadcn-ui/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',
className
)}
{...props}
>
<div className="is-user:dark">{children}</div>
</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 ring-1 ring-border', className)}
{...props}
>
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
);

View File

@ -0,0 +1,225 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn-ui/components/ui/select';
import { Textarea } from '@repo/shadcn-ui/components/ui/textarea';
import { cn } from '@repo/shadcn-ui/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 divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
className
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
minHeight?: number;
maxHeight?: number;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
minHeight = 48,
maxHeight = 164,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
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',
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View File

@ -0,0 +1,180 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { cn } from '@repo/shadcn-ui/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;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = false,
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) / 1000));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (isStreaming && !isOpen) {
setIsOpen(true);
} else if (!isStreaming && isOpen && !defaultOpen && !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
> & {
title?: string;
};
export const ReasoningTrigger = memo(
({
className,
title = 'Reasoning',
children,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-2 text-muted-foreground text-sm',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{isStreaming || duration === 0 ? (
<p>Thinking...</p>
) : (
<p>Thought for {duration} seconds</p>
)}
<ChevronDownIcon
className={cn(
'size-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View File

@ -0,0 +1,392 @@
'use client';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ComponentProps, HTMLAttributes } from 'react';
import { isValidElement, memo } from 'react';
import ReactMarkdown, { type Options } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { CodeBlock, CodeBlockCopyButton } from './code-block';
import 'katex/dist/katex.min.css';
import hardenReactMarkdown from 'harden-react-markdown';
/**
* Parses markdown text and removes incomplete tokens to prevent partial rendering
* of links, images, bold, and italic formatting during streaming.
*/
function parseIncompleteMarkdown(text: string): string {
if (!text || typeof text !== 'string') {
return text;
}
let result = text;
// Handle incomplete links and images
// Pattern: [...] or ![...] where the closing ] is missing
const linkImagePattern = /(!?\[)([^\]]*?)$/;
const linkMatch = result.match(linkImagePattern);
if (linkMatch) {
// If we have an unterminated [ or ![, remove it and everything after
const startIndex = result.lastIndexOf(linkMatch[1]);
result = result.substring(0, startIndex);
}
// Handle incomplete bold formatting (**)
const boldPattern = /(\*\*)([^*]*?)$/;
const boldMatch = result.match(boldPattern);
if (boldMatch) {
// Count the number of ** in the entire string
const asteriskPairs = (result.match(/\*\*/g) || []).length;
// If odd number of **, we have an incomplete bold - complete it
if (asteriskPairs % 2 === 1) {
result = `${result}**`;
}
}
// Handle incomplete italic formatting (__)
const italicPattern = /(__)([^_]*?)$/;
const italicMatch = result.match(italicPattern);
if (italicMatch) {
// Count the number of __ in the entire string
const underscorePairs = (result.match(/__/g) || []).length;
// If odd number of __, we have an incomplete italic - complete it
if (underscorePairs % 2 === 1) {
result = `${result}__`;
}
}
// Handle incomplete single asterisk italic (*)
const singleAsteriskPattern = /(\*)([^*]*?)$/;
const singleAsteriskMatch = result.match(singleAsteriskPattern);
if (singleAsteriskMatch) {
// Count single asterisks that aren't part of **
const singleAsterisks = result.split('').reduce((acc, char, index) => {
if (char === '*') {
// Check if it's part of a ** pair
const prevChar = result[index - 1];
const nextChar = result[index + 1];
if (prevChar !== '*' && nextChar !== '*') {
return acc + 1;
}
}
return acc;
}, 0);
// If odd number of single *, we have an incomplete italic - complete it
if (singleAsterisks % 2 === 1) {
result = `${result}*`;
}
}
// Handle incomplete single underscore italic (_)
const singleUnderscorePattern = /(_)([^_]*?)$/;
const singleUnderscoreMatch = result.match(singleUnderscorePattern);
if (singleUnderscoreMatch) {
// Count single underscores that aren't part of __
const singleUnderscores = result.split('').reduce((acc, char, index) => {
if (char === '_') {
// Check if it's part of a __ pair
const prevChar = result[index - 1];
const nextChar = result[index + 1];
if (prevChar !== '_' && nextChar !== '_') {
return acc + 1;
}
}
return acc;
}, 0);
// If odd number of single _, we have an incomplete italic - complete it
if (singleUnderscores % 2 === 1) {
result = `${result}_`;
}
}
// Handle incomplete inline code blocks (`) - but avoid code blocks (```)
const inlineCodePattern = /(`)([^`]*?)$/;
const inlineCodeMatch = result.match(inlineCodePattern);
if (inlineCodeMatch) {
// Check if we're dealing with a code block (triple backticks)
const hasCodeBlockStart = result.includes('```');
const codeBlockPattern = /```[\s\S]*?```/g;
const completeCodeBlocks = (result.match(codeBlockPattern) || []).length;
const allTripleBackticks = (result.match(/```/g) || []).length;
// If we have an odd number of ``` sequences, we're inside an incomplete code block
// In this case, don't complete inline code
const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1;
if (!insideIncompleteCodeBlock) {
// Count the number of single backticks that are NOT part of triple backticks
let singleBacktickCount = 0;
for (let i = 0; i < result.length; i++) {
if (result[i] === '`') {
// Check if this backtick is part of a triple backtick sequence
const isTripleStart = result.substring(i, i + 3) === '```';
const isTripleMiddle =
i > 0 && result.substring(i - 1, i + 2) === '```';
const isTripleEnd = i > 1 && result.substring(i - 2, i + 1) === '```';
if (!(isTripleStart || isTripleMiddle || isTripleEnd)) {
singleBacktickCount++;
}
}
}
// If odd number of single backticks, we have an incomplete inline code - complete it
if (singleBacktickCount % 2 === 1) {
result = `${result}\``;
}
}
}
// Handle incomplete strikethrough formatting (~~)
const strikethroughPattern = /(~~)([^~]*?)$/;
const strikethroughMatch = result.match(strikethroughPattern);
if (strikethroughMatch) {
// Count the number of ~~ in the entire string
const tildePairs = (result.match(/~~/g) || []).length;
// If odd number of ~~, we have an incomplete strikethrough - complete it
if (tildePairs % 2 === 1) {
result = `${result}~~`;
}
}
return result;
}
// Create a hardened version of ReactMarkdown
const HardenedMarkdown = hardenReactMarkdown(ReactMarkdown);
export type ResponseProps = HTMLAttributes<HTMLDivElement> & {
options?: Options;
children: Options['children'];
allowedImagePrefixes?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['allowedImagePrefixes'];
allowedLinkPrefixes?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['allowedLinkPrefixes'];
defaultOrigin?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['defaultOrigin'];
parseIncompleteMarkdown?: boolean;
};
const components: Options['components'] = {
ol: ({ node, children, className, ...props }) => (
<ol className={cn('ml-4 list-outside list-decimal', className)} {...props}>
{children}
</ol>
),
li: ({ node, children, className, ...props }) => (
<li className={cn('py-1', className)} {...props}>
{children}
</li>
),
ul: ({ node, children, className, ...props }) => (
<ul className={cn('ml-4 list-outside list-disc', className)} {...props}>
{children}
</ul>
),
hr: ({ node, className, ...props }) => (
<hr className={cn('my-6 border-border', className)} {...props} />
),
strong: ({ node, children, className, ...props }) => (
<span className={cn('font-semibold', className)} {...props}>
{children}
</span>
),
a: ({ node, children, className, ...props }) => (
<a
className={cn('font-medium text-primary underline', className)}
rel="noreferrer"
target="_blank"
{...props}
>
{children}
</a>
),
h1: ({ node, children, className, ...props }) => (
<h1
className={cn('mt-6 mb-2 font-semibold text-3xl', className)}
{...props}
>
{children}
</h1>
),
h2: ({ node, children, className, ...props }) => (
<h2
className={cn('mt-6 mb-2 font-semibold text-2xl', className)}
{...props}
>
{children}
</h2>
),
h3: ({ node, children, className, ...props }) => (
<h3 className={cn('mt-6 mb-2 font-semibold text-xl', className)} {...props}>
{children}
</h3>
),
h4: ({ node, children, className, ...props }) => (
<h4 className={cn('mt-6 mb-2 font-semibold text-lg', className)} {...props}>
{children}
</h4>
),
h5: ({ node, children, className, ...props }) => (
<h5
className={cn('mt-6 mb-2 font-semibold text-base', className)}
{...props}
>
{children}
</h5>
),
h6: ({ node, children, className, ...props }) => (
<h6 className={cn('mt-6 mb-2 font-semibold text-sm', className)} {...props}>
{children}
</h6>
),
table: ({ node, children, className, ...props }) => (
<div className="my-4 overflow-x-auto">
<table
className={cn('w-full border-collapse border border-border', className)}
{...props}
>
{children}
</table>
</div>
),
thead: ({ node, children, className, ...props }) => (
<thead className={cn('bg-muted/50', className)} {...props}>
{children}
</thead>
),
tbody: ({ node, children, className, ...props }) => (
<tbody className={cn('divide-y divide-border', className)} {...props}>
{children}
</tbody>
),
tr: ({ node, children, className, ...props }) => (
<tr className={cn('border-border border-b', className)} {...props}>
{children}
</tr>
),
th: ({ node, children, className, ...props }) => (
<th
className={cn('px-4 py-2 text-left font-semibold text-sm', className)}
{...props}
>
{children}
</th>
),
td: ({ node, children, className, ...props }) => (
<td className={cn('px-4 py-2 text-sm', className)} {...props}>
{children}
</td>
),
blockquote: ({ node, children, className, ...props }) => (
<blockquote
className={cn(
'my-4 border-muted-foreground/30 border-l-4 pl-4 text-muted-foreground italic',
className
)}
{...props}
>
{children}
</blockquote>
),
code: ({ node, className, ...props }) => {
const inline = node?.position?.start.line === node?.position?.end.line;
if (!inline) {
return <code className={className} {...props} />;
}
return (
<code
className={cn(
'rounded bg-muted px-1.5 py-0.5 font-mono text-sm',
className
)}
{...props}
/>
);
},
pre: ({ node, className, children }) => {
let language = 'javascript';
if (typeof node?.properties?.className === 'string') {
language = node.properties.className.replace('language-', '');
}
// Extract code content from children safely
let code = '';
if (
isValidElement(children) &&
children.props &&
typeof (children.props as any).children === 'string'
) {
code = (children.props as any).children;
} else if (typeof children === 'string') {
code = children;
}
return (
<CodeBlock
className={cn('my-4 h-auto', className)}
code={code}
language={language}
>
<CodeBlockCopyButton
onCopy={() => console.log('Copied code to clipboard')}
onError={() => console.error('Failed to copy code to clipboard')}
/>
</CodeBlock>
);
},
};
export const Response = memo(
({
className,
options,
children,
allowedImagePrefixes,
allowedLinkPrefixes,
defaultOrigin,
parseIncompleteMarkdown: shouldParseIncompleteMarkdown = true,
...props
}: ResponseProps) => {
// Parse the children to remove incomplete markdown tokens if enabled
const parsedChildren =
typeof children === 'string' && shouldParseIncompleteMarkdown
? parseIncompleteMarkdown(children)
: children;
return (
<div
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
{...props}
>
<HardenedMarkdown
allowedImagePrefixes={allowedImagePrefixes ?? ['*']}
allowedLinkPrefixes={allowedLinkPrefixes ?? ['*']}
components={components}
defaultOrigin={defaultOrigin}
rehypePlugins={[rehypeKatex]}
remarkPlugins={[remarkGfm, remarkMath]}
{...options}
>
{parsedChildren}
</HardenedMarkdown>
</div>
);
},
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = 'Response';

View File

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

View File

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

View File

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

View File

@ -0,0 +1,142 @@
'use client';
import { Badge } from '@repo/shadcn-ui/components/ui/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { cn } from '@repo/shadcn-ui/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" variant="secondary">
{icons[status]}
{labels[status]}
</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>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<'div'> & {
output: ReactNode;
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn('space-y-2 p-4', className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? 'Error' : 'Result'}
</h4>
<div
className={cn(
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
errorText
? 'bg-destructive/10 text-destructive'
: 'bg-muted/50 text-foreground'
)}
>
{errorText && <div>{errorText}</div>}
{output && <div>{output}</div>}
</div>
</div>
);
};

View File

@ -0,0 +1,269 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { Input } from '@repo/shadcn-ui/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn-ui/components/ui/tooltip';
import { cn } from '@repo/shadcn-ui/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);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
};
// Use defaultValue for uncontrolled input when no onChange is provided
if (!onChange && !value) {
return (
<Input
className="h-8 flex-1 text-sm"
defaultValue={url}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
{...props}
/>
);
}
return (
<Input
className="h-8 flex-1 text-sm"
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn('size-full', className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
logs?: Array<{
level: 'log' | 'warn' | 'error';
message: string;
timestamp: Date;
}>;
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
'text-xs',
log.level === 'error' && 'text-destructive',
log.level === 'warn' && 'text-yellow-600',
log.level === 'log' && 'text-foreground'
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@ -0,0 +1,620 @@
'use client';
import {
type IconType,
SiAstro,
SiBiome,
SiBower,
SiBun,
SiC,
SiCircleci,
SiCoffeescript,
SiCplusplus,
SiCss,
SiCssmodules,
SiDart,
SiDocker,
SiDocusaurus,
SiDotenv,
SiEditorconfig,
SiEslint,
SiGatsby,
SiGitignoredotio,
SiGnubash,
SiGo,
SiGraphql,
SiGrunt,
SiGulp,
SiHandlebarsdotjs,
SiHtml5,
SiJavascript,
SiJest,
SiJson,
SiLess,
SiMarkdown,
SiMdx,
SiMintlify,
SiMocha,
SiMysql,
SiNextdotjs,
SiPerl,
SiPhp,
SiPostcss,
SiPrettier,
SiPrisma,
SiPug,
SiPython,
SiR,
SiReact,
SiReadme,
SiRedis,
SiRemix,
SiRive,
SiRollupdotjs,
SiRuby,
SiSanity,
SiSass,
SiScala,
SiSentry,
SiShadcnui,
SiStorybook,
SiStylelint,
SiSublimetext,
SiSvelte,
SiSvg,
SiSwift,
SiTailwindcss,
SiToml,
SiTypescript,
SiVercel,
SiVite,
SiVuedotjs,
SiWebassembly,
} from '@icons-pack/react-simple-icons';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
ReactElement,
ReactNode,
} from 'react';
import {
cloneElement,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
export type BundledLanguage = string;
const filenameIconMap = {
'.env': SiDotenv,
'*.astro': SiAstro,
'biome.json': SiBiome,
'.bowerrc': SiBower,
'bun.lockb': SiBun,
'*.c': SiC,
'*.cpp': SiCplusplus,
'.circleci/config.yml': SiCircleci,
'*.coffee': SiCoffeescript,
'*.module.css': SiCssmodules,
'*.css': SiCss,
'*.dart': SiDart,
Dockerfile: SiDocker,
'docusaurus.config.js': SiDocusaurus,
'.editorconfig': SiEditorconfig,
'.eslintrc': SiEslint,
'eslint.config.*': SiEslint,
'gatsby-config.*': SiGatsby,
'.gitignore': SiGitignoredotio,
'*.go': SiGo,
'*.graphql': SiGraphql,
'*.sh': SiGnubash,
'Gruntfile.*': SiGrunt,
'gulpfile.*': SiGulp,
'*.hbs': SiHandlebarsdotjs,
'*.html': SiHtml5,
'*.js': SiJavascript,
'*.json': SiJson,
'*.test.js': SiJest,
'*.less': SiLess,
'*.md': SiMarkdown,
'*.mdx': SiMdx,
'mintlify.json': SiMintlify,
'mocha.opts': SiMocha,
'*.mustache': SiHandlebarsdotjs,
'*.sql': SiMysql,
'next.config.*': SiNextdotjs,
'*.pl': SiPerl,
'*.php': SiPhp,
'postcss.config.*': SiPostcss,
'prettier.config.*': SiPrettier,
'*.prisma': SiPrisma,
'*.pug': SiPug,
'*.py': SiPython,
'*.r': SiR,
'*.rb': SiRuby,
'*.jsx': SiReact,
'*.tsx': SiReact,
'readme.md': SiReadme,
'*.rdb': SiRedis,
'remix.config.*': SiRemix,
'*.riv': SiRive,
'rollup.config.*': SiRollupdotjs,
'sanity.config.*': SiSanity,
'*.sass': SiSass,
'*.scss': SiSass,
'*.sc': SiScala,
'*.scala': SiScala,
'sentry.client.config.*': SiSentry,
'components.json': SiShadcnui,
'storybook.config.*': SiStorybook,
'stylelint.config.*': SiStylelint,
'.sublime-settings': SiSublimetext,
'*.svelte': SiSvelte,
'*.svg': SiSvg,
'*.swift': SiSwift,
'tailwind.config.*': SiTailwindcss,
'*.toml': SiToml,
'*.ts': SiTypescript,
'vercel.json': SiVercel,
'vite.config.*': SiVite,
'*.vue': SiVuedotjs,
'*.wasm': SiWebassembly,
};
const lineNumberClassNames = cn(
'[&_code]:[counter-reset:line]',
'[&_code]:[counter-increment:line_0]',
'[&_.line]:before:content-[counter(line)]',
'[&_.line]:before:inline-block',
'[&_.line]:before:[counter-increment:line]',
'[&_.line]:before:w-4',
'[&_.line]:before:mr-4',
'[&_.line]:before:text-[13px]',
'[&_.line]:before:text-right',
'[&_.line]:before:text-muted-foreground/50',
'[&_.line]:before:font-mono',
'[&_.line]:before:select-none'
);
const darkModeClassNames = cn(
'dark:[&_.shiki]:!text-[var(--shiki-dark)]',
'dark:[&_.shiki]:!bg-[var(--shiki-dark-bg)]',
'dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]',
'dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]',
'dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]',
'dark:[&_.shiki_span]:!text-[var(--shiki-dark)]',
'dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]',
'dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]',
'dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]'
);
const lineHighlightClassNames = cn(
'[&_.line.highlighted]:bg-blue-50',
'[&_.line.highlighted]:after:bg-blue-500',
'[&_.line.highlighted]:after:absolute',
'[&_.line.highlighted]:after:left-0',
'[&_.line.highlighted]:after:top-0',
'[&_.line.highlighted]:after:bottom-0',
'[&_.line.highlighted]:after:w-0.5',
'dark:[&_.line.highlighted]:!bg-blue-500/10'
);
const lineDiffClassNames = cn(
'[&_.line.diff]:after:absolute',
'[&_.line.diff]:after:left-0',
'[&_.line.diff]:after:top-0',
'[&_.line.diff]:after:bottom-0',
'[&_.line.diff]:after:w-0.5',
'[&_.line.diff.add]:bg-emerald-50',
'[&_.line.diff.add]:after:bg-emerald-500',
'[&_.line.diff.remove]:bg-rose-50',
'[&_.line.diff.remove]:after:bg-rose-500',
'dark:[&_.line.diff.add]:!bg-emerald-500/10',
'dark:[&_.line.diff.remove]:!bg-rose-500/10'
);
const lineFocusedClassNames = cn(
'[&_code:has(.focused)_.line]:blur-[2px]',
'[&_code:has(.focused)_.line.focused]:blur-none'
);
const wordHighlightClassNames = cn(
'[&_.highlighted-word]:bg-blue-50',
'dark:[&_.highlighted-word]:!bg-blue-500/10'
);
const codeBlockClassName = cn(
'mt-0 bg-background text-sm',
'[&_pre]:py-4',
'[&_.shiki]:!bg-[var(--shiki-bg)]',
'[&_code]:w-full',
'[&_code]:grid',
'[&_code]:overflow-x-auto',
'[&_code]:bg-transparent',
'[&_.line]:px-4',
'[&_.line]:w-full',
'[&_.line]:relative'
);
type CodeBlockData = {
language: string;
filename: string;
code: string;
};
type CodeBlockContextType = {
value: string | undefined;
onValueChange: ((value: string) => void) | undefined;
data: CodeBlockData[];
};
const CodeBlockContext = createContext<CodeBlockContextType>({
value: undefined,
onValueChange: undefined,
data: [],
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
data: CodeBlockData[];
};
export const CodeBlock = ({
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultValue,
className,
data,
...props
}: CodeBlockProps) => {
const [value, onValueChange] = useControllableState({
defaultProp: defaultValue ?? '',
prop: controlledValue,
onChange: controlledOnValueChange,
});
return (
<CodeBlockContext.Provider value={{ value, onValueChange, data }}>
<div
className={cn('size-full overflow-hidden rounded-md border', className)}
{...props}
/>
</CodeBlockContext.Provider>
);
};
export type CodeBlockHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CodeBlockHeader = ({
className,
...props
}: CodeBlockHeaderProps) => (
<div
className={cn(
'flex flex-row items-center border-b bg-secondary p-1',
className
)}
{...props}
/>
);
export type CodeBlockFilesProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockFiles = ({
className,
children,
...props
}: CodeBlockFilesProps) => {
const { data } = useContext(CodeBlockContext);
return (
<div
className={cn('flex grow flex-row items-center gap-2', className)}
{...props}
>
{data.map(children)}
</div>
);
};
export type CodeBlockFilenameProps = HTMLAttributes<HTMLDivElement> & {
icon?: IconType;
value?: string;
};
export const CodeBlockFilename = ({
className,
icon,
value,
children,
...props
}: CodeBlockFilenameProps) => {
const { value: activeValue } = useContext(CodeBlockContext);
const defaultIcon = Object.entries(filenameIconMap).find(([pattern]) => {
const regex = new RegExp(
`^${pattern.replace(/\\/g, '\\\\').replace(/\./g, '\\.').replace(/\*/g, '.*')}$`
);
return regex.test(children as string);
})?.[1];
const Icon = icon ?? defaultIcon;
if (value !== activeValue) {
return null;
}
return (
<div
className="flex items-center gap-2 bg-secondary px-4 py-1.5 text-muted-foreground text-xs"
{...props}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1 truncate">{children}</span>
</div>
);
};
export type CodeBlockSelectProps = ComponentProps<typeof Select>;
export const CodeBlockSelect = (props: CodeBlockSelectProps) => {
const { value, onValueChange } = useContext(CodeBlockContext);
return <Select onValueChange={onValueChange} value={value} {...props} />;
};
export type CodeBlockSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
export const CodeBlockSelectTrigger = ({
className,
...props
}: CodeBlockSelectTriggerProps) => (
<SelectTrigger
className={cn(
'w-fit border-none text-muted-foreground text-xs shadow-none',
className
)}
{...props}
/>
);
export type CodeBlockSelectValueProps = ComponentProps<typeof SelectValue>;
export const CodeBlockSelectValue = (props: CodeBlockSelectValueProps) => (
<SelectValue {...props} />
);
export type CodeBlockSelectContentProps = Omit<
ComponentProps<typeof SelectContent>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockSelectContent = ({
children,
...props
}: CodeBlockSelectContentProps) => {
const { data } = useContext(CodeBlockContext);
return <SelectContent {...props}>{data.map(children)}</SelectContent>;
};
export type CodeBlockSelectItemProps = ComponentProps<typeof SelectItem>;
export const CodeBlockSelectItem = ({
className,
...props
}: CodeBlockSelectItemProps) => (
<SelectItem className={cn('text-sm', className)} {...props} />
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
asChild,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { data, value } = useContext(CodeBlockContext);
const code = data.find((item) => item.language === value)?.code;
const copyToClipboard = () => {
if (
typeof window === 'undefined' ||
!navigator.clipboard.writeText ||
!code
) {
return;
}
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
}, onError);
};
if (asChild) {
return cloneElement(children as ReactElement, {
// @ts-expect-error - we know this is a button
onClick: copyToClipboard,
});
}
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon className="text-muted-foreground" size={14} />}
</Button>
);
};
type CodeBlockFallbackProps = HTMLAttributes<HTMLDivElement>;
const CodeBlockFallback = ({ children, ...props }: CodeBlockFallbackProps) => (
<div {...props}>
<pre className="w-full">
<code>
{children
?.toString()
.split('\n')
.map((line, i) => (
<span className="line" key={i}>
{line}
</span>
))}
</code>
</pre>
</div>
);
export type CodeBlockBodyProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockBody = ({ children, ...props }: CodeBlockBodyProps) => {
const { data } = useContext(CodeBlockContext);
return <div {...props}>{data.map(children)}</div>;
};
export type CodeBlockItemProps = HTMLAttributes<HTMLDivElement> & {
value: string;
lineNumbers?: boolean;
};
export const CodeBlockItem = ({
children,
lineNumbers = true,
className,
value,
...props
}: CodeBlockItemProps) => {
const { value: activeValue } = useContext(CodeBlockContext);
if (value !== activeValue) {
return null;
}
return (
<div
className={cn(
codeBlockClassName,
lineHighlightClassNames,
lineDiffClassNames,
lineFocusedClassNames,
wordHighlightClassNames,
darkModeClassNames,
lineNumbers && lineNumberClassNames,
className
)}
{...props}
>
{children}
</div>
);
};
export type CodeBlockContentProps = HTMLAttributes<HTMLDivElement> & {
themes?: {
light: string;
dark: string;
};
language?: BundledLanguage;
syntaxHighlighting?: boolean;
children: string;
};
export const CodeBlockContent = ({
children,
themes = {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
language = 'typescript',
syntaxHighlighting = true,
...props
}: CodeBlockContentProps) => {
const [highlightedCode, setHighlightedCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(syntaxHighlighting);
useEffect(() => {
if (!syntaxHighlighting) {
setIsLoading(false);
return;
}
const loadHighlightedCode = async () => {
try {
const { codeToHtml } = await import('shiki');
const html = await codeToHtml(children, {
lang: language,
themes: {
light: themes.light,
dark: themes.dark,
},
});
setHighlightedCode(html);
setIsLoading(false);
} catch (error) {
console.error(`Failed to highlight code for language "${language}":`, error);
setIsLoading(false);
}
};
loadHighlightedCode();
}, [children, language, themes, syntaxHighlighting]);
if (!syntaxHighlighting || isLoading) {
return <CodeBlockFallback {...props}>{children}</CodeBlockFallback>;
}
return (
<div
dangerouslySetInnerHTML={{ __html: highlightedCode }}
{...props}
/>
);
};

View File

@ -0,0 +1,63 @@
import {
transformerNotationDiff,
transformerNotationErrorLevel,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from '@shikijs/transformers';
import type { HTMLAttributes } from 'react';
import {
type BundledLanguage,
type CodeOptionsMultipleThemes,
codeToHtml,
} from 'shiki';
export type CodeBlockContentProps = HTMLAttributes<HTMLDivElement> & {
themes?: CodeOptionsMultipleThemes['themes'];
language?: BundledLanguage;
children: string;
syntaxHighlighting?: boolean;
};
export const CodeBlockContent = async ({
children,
themes,
language,
syntaxHighlighting = true,
...props
}: CodeBlockContentProps) => {
const html = syntaxHighlighting
? await codeToHtml(children as string, {
lang: language ?? 'typescript',
themes: themes ?? {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
transformers: [
transformerNotationDiff({
matchAlgorithm: 'v3',
}),
transformerNotationHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationWordHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationFocus({
matchAlgorithm: 'v3',
}),
transformerNotationErrorLevel({
matchAlgorithm: 'v3',
}),
],
})
: children;
return (
<div
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Kinda how Shiki works"
dangerouslySetInnerHTML={{ __html: html }}
{...props}
/>
);
};

45
inspiration-repo-agent/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# OpenNext
/.open-next
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
!.env.example

View File

@ -0,0 +1,5 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,144 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.15 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.68 0.19 45);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.68 0.19 45);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: #ff8c00 #000000;
}
body {
@apply bg-background text-foreground;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #000000;
}
*::-webkit-scrollbar-thumb {
background: #ff8c00;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #ff9d1a;
}
}

8342
inspiration-repo-agent/cloudflare-env.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
// added by create cloudflare to enable calling `getCloudflareContext()` in `next dev`
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
initOpenNextCloudflareForDev();

View File

@ -0,0 +1,9 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
// Uncomment to enable R2 cache,
// It should be imported as:
// `import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";`
// See https://opennext.js.org/cloudflare/caching for more details
// incrementalCache: r2IncrementalCache,
});

18612
inspiration-repo-agent/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,74 @@
{
"name": "my-v0-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"geist": "^1.3.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "15.5.4",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-day-picker": "9.8.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "3.25.67"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -0,0 +1,3 @@
# https://developers.cloudflare.com/workers/static-assets/headers
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,118 @@
import { type NextRequest, NextResponse } from "next/server"
const WEBHOOK_URL = "https://n8n.biohazardvfx.com/webhook/d2ab4653-a107-412c-a905-ccd80e5b76cd"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, timestamp, sessionId } = body
if (!message) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
console.log("[v0] Sending to webhook:", { message, timestamp, sessionId })
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
timestamp,
sessionId,
}),
})
console.log("[v0] Webhook response status:", response.status)
const responseText = await response.text()
console.log("[v0] Webhook response body (first 200 chars):", responseText.substring(0, 200))
if (!response.ok) {
// Try to parse as JSON if possible, otherwise use text
let errorData
try {
errorData = responseText ? JSON.parse(responseText) : {}
} catch {
errorData = { message: responseText || "Unknown error" }
}
console.error("[v0] Webhook error:", errorData)
return NextResponse.json(
{
error: errorData.message || "Failed to communicate with webhook",
hint: errorData.hint,
code: errorData.code,
},
{ status: response.status },
)
}
if (!responseText) {
console.log("[v0] Empty response from webhook")
return NextResponse.json({
response:
"The webhook received your message but didn't return a response. Please ensure your n8n workflow includes a 'Respond to Webhook' node that returns data.",
hint: "Add a 'Respond to Webhook' node in your n8n workflow to send responses back to the chat.",
})
}
try {
// Split response by newlines to get individual JSON objects
const lines = responseText.trim().split("\n")
const chunks: string[] = []
for (const line of lines) {
if (!line.trim()) continue
try {
const chunk = JSON.parse(line)
// Extract content from "item" type chunks
if (chunk.type === "item" && chunk.content) {
chunks.push(chunk.content)
}
} catch (parseError) {
console.log("[v0] Failed to parse line:", line)
}
}
// Combine all chunks into a single message
if (chunks.length > 0) {
const fullMessage = chunks.join("")
console.log("[v0] Combined message from", chunks.length, "chunks")
return NextResponse.json({ response: fullMessage })
}
// If no chunks found, try parsing as regular JSON
const data = JSON.parse(responseText)
console.log("[v0] Parsed webhook data:", data)
// Extract the response from various possible fields
let responseMessage = data.response || data.message || data.output || data.text
// If the response is an object, try to extract from nested fields
if (typeof responseMessage === "object") {
responseMessage =
responseMessage.response || responseMessage.message || responseMessage.output || responseMessage.text
}
// If still no message found, stringify the entire response
if (!responseMessage) {
responseMessage = JSON.stringify(data)
}
return NextResponse.json({ response: responseMessage })
} catch (parseError) {
console.log("[v0] Response is not JSON, returning as text")
// If not JSON, return the text as the response
return NextResponse.json({ response: responseText })
}
} catch (error) {
console.error("[v0] API route error:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,145 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.15 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.68 0.19 45);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.68 0.19 45);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: #ff8c00 #000000;
}
body {
@apply bg-background text-foreground;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #000000;
}
*::-webkit-scrollbar-thumb {
background: #ff8c00;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #ff9d1a;
}
}

View File

@ -0,0 +1,28 @@
import type React from "react"
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { Analytics } from "@vercel/analytics/next"
import { Suspense } from "react"
import "./globals.css"
export const metadata: Metadata = {
title: "AI Chat Interface",
description: "Chat with AI Agent",
generator: "v0.app",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className="dark">
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable}`}>
<Suspense fallback={null}>{children}</Suspense>
<Analytics />
</body>
</html>
)
}

View File

@ -0,0 +1,9 @@
import { ChatInterface } from "@/components/chat-interface"
export default function Home() {
return (
<main className="bg-black">
<ChatInterface />
</main>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Send, Bot, Loader2, SquarePen } from "lucide-react"
interface Message {
id: string
role: "user" | "assistant"
content: string
timestamp: Date
isError?: boolean
hint?: string
}
export function ChatInterface() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [sessionId, setSessionId] = useState<string>("")
const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// Try to get existing sessionID from localStorage
let existingSessionId = localStorage.getItem("chat-session-id")
if (!existingSessionId) {
// Generate new sessionID using timestamp and random string
existingSessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
localStorage.setItem("chat-session-id", existingSessionId)
}
setSessionId(existingSessionId)
}, [])
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
}
}, [messages, isLoading])
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || isLoading) return
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInput("")
setIsLoading(true)
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: userMessage.content,
timestamp: userMessage.timestamp.toISOString(),
sessionId: sessionId,
}),
})
const data = await response.json()
if (!response.ok) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.error || "Failed to communicate with the webhook.",
timestamp: new Date(),
isError: true,
hint: data.hint,
}
setMessages((prev) => [...prev, errorMessage])
} else {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.response || data.message || JSON.stringify(data),
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
}
} catch (error) {
console.error("[v0] Error sending message:", error)
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Sorry, I encountered an error processing your message. Please try again.",
timestamp: new Date(),
isError: true,
}
setMessages((prev) => [...prev, errorMessage])
} finally {
setIsLoading(false)
inputRef.current?.focus()
}
}
const startNewChat = () => {
// Clear all messages
setMessages([])
// Generate new sessionID
const newSessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
setSessionId(newSessionId)
localStorage.setItem("chat-session-id", newSessionId)
// Clear input
setInput("")
// Focus input
inputRef.current?.focus()
}
return (
<div className="flex h-screen w-full flex-col">
{messages.length > 0 && (
<div className="absolute right-4 top-4 z-10">
<Button
onClick={startNewChat}
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full bg-neutral-900/80 text-neutral-400 backdrop-blur-sm hover:bg-neutral-800 hover:text-white"
title="Start new chat"
>
<SquarePen className="h-4 w-4" />
</Button>
</div>
)}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-4 py-8 pb-32">
<div className="mx-auto max-w-3xl">
{messages.length === 0 ? (
<div className="flex h-full min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
<h1 className="text-2xl font-semibold text-white">Hello there!</h1>
<p className="text-base text-neutral-400">How can I help you today?</p>
</div>
) : (
<div className="space-y-6">
{messages.map((message) => (
<div key={message.id} className="flex gap-3">
{message.role === "assistant" && (
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-neutral-800">
<Bot className="h-4 w-4 text-neutral-400" />
</div>
)}
<div className={`flex-1 ${message.role === "user" ? "flex justify-end" : ""}`}>
<div
className={`inline-block max-w-[85%] rounded-2xl px-4 py-2.5 ${
message.role === "user"
? "bg-primary text-white"
: message.isError
? "bg-red-500/10 text-red-400"
: "bg-neutral-800/50 text-neutral-100"
}`}
>
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{message.content}</p>
</div>
{message.hint && <p className="mt-1 text-xs text-neutral-500">{message.hint}</p>}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-neutral-800">
<Bot className="h-4 w-4 text-neutral-400" />
</div>
<div className="inline-block rounded-2xl bg-neutral-800/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
<p className="text-sm text-neutral-400">Thinking...</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
<div className="fixed bottom-6 left-1/2 z-20 w-full max-w-3xl -translate-x-1/2 px-4">
<form onSubmit={sendMessage} className="relative">
<div className="flex items-center gap-2 rounded-2xl border border-neutral-800 bg-neutral-900/95 p-2 shadow-2xl backdrop-blur-md">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a follow-up..."
disabled={isLoading}
className="flex-1 border-0 bg-transparent text-white placeholder:text-neutral-500 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<Button
type="submit"
disabled={!input.trim() || isLoading}
size="icon"
className="h-9 w-9 flex-shrink-0 rounded-xl bg-primary text-white hover:bg-primary/90"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"types": [
"./cloudflare-env.d.ts",
"node"
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,51 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "inspiration-repo-agent",
"main": ".open-next/worker.js",
"compatibility_date": "2025-03-01",
"compatibility_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}