| 'use client';
|
|
|
| import type { ComponentProps } from 'react';
|
| import { createContext, memo, useContext, useEffect, useState } from 'react';
|
| import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
| import { BrainIcon, ChevronDownIcon } from 'lucide-react';
|
| import { Streamdown } from 'streamdown';
|
| import { cn } from '@/lib/utils';
|
| import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
| import { Shimmer } from './shimmer';
|
|
|
| type ReasoningContextValue = {
|
| isStreaming: boolean;
|
| isOpen: boolean;
|
| setIsOpen: (open: boolean) => void;
|
| duration: number | undefined;
|
| };
|
|
|
| const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
|
|
| const useReasoning = () => {
|
| const context = useContext(ReasoningContext);
|
| if (!context) {
|
| throw new Error('Reasoning components must be used within Reasoning');
|
| }
|
| return context;
|
| };
|
|
|
| export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
| isStreaming?: boolean;
|
| open?: boolean;
|
| defaultOpen?: boolean;
|
| onOpenChange?: (open: boolean) => void;
|
| duration?: number;
|
| };
|
|
|
| const AUTO_CLOSE_DELAY = 1000;
|
| const MS_IN_S = 1000;
|
|
|
| export const Reasoning = memo(
|
| ({
|
| className,
|
| isStreaming = false,
|
| open,
|
| defaultOpen = true,
|
| onOpenChange,
|
| duration: durationProp,
|
| children,
|
| ...props
|
| }: ReasoningProps) => {
|
| const [isOpen, setIsOpen] = useControllableState({
|
| prop: open,
|
| defaultProp: defaultOpen,
|
| onChange: onOpenChange,
|
| });
|
| const [duration, setDuration] = useControllableState({
|
| prop: durationProp,
|
| defaultProp: undefined,
|
| });
|
|
|
| const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
| const [startTime, setStartTime] = useState<number | null>(null);
|
|
|
|
|
| useEffect(() => {
|
| if (isStreaming) {
|
| if (startTime === null) {
|
| setStartTime(Date.now());
|
| }
|
| } else if (startTime !== null) {
|
| setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
| setStartTime(null);
|
| }
|
| }, [isStreaming, startTime, setDuration]);
|
|
|
|
|
| useEffect(() => {
|
| if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
|
|
| const timer = setTimeout(() => {
|
| setIsOpen(false);
|
| setHasAutoClosed(true);
|
| }, AUTO_CLOSE_DELAY);
|
|
|
| return () => clearTimeout(timer);
|
| }
|
| }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
|
|
| 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>;
|
|
|
| const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
| if (isStreaming || duration === 0) {
|
| return <Shimmer duration={1}>Thinking...</Shimmer>;
|
| }
|
| if (duration === undefined) {
|
| return <p>Thought for a few seconds</p>;
|
| }
|
| return <p>Thought for {duration} seconds</p>;
|
| };
|
|
|
| export const ReasoningTrigger = memo(({ className, children, ...props }: ReasoningTriggerProps) => {
|
| const { isStreaming, isOpen, duration } = useReasoning();
|
|
|
| return (
|
| <CollapsibleTrigger
|
| className={cn('text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors', className)}
|
| {...props}
|
| >
|
| {children ?? (
|
| <>
|
| <BrainIcon className='size-4' />
|
| {getThinkingMessage(isStreaming, duration)}
|
| <ChevronDownIcon className={cn('size-4 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-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
| className
|
| )}
|
| {...props}
|
| >
|
| <Streamdown {...props}>{children}</Streamdown>
|
| </CollapsibleContent>
|
| ));
|
|
|
| Reasoning.displayName = 'Reasoning';
|
| ReasoningTrigger.displayName = 'ReasoningTrigger';
|
| ReasoningContent.displayName = 'ReasoningContent';
|
|
|