reasoning.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. 'use client'
  2. import { useControllableState } from '@radix-ui/react-use-controllable-state'
  3. import {
  4. Collapsible,
  5. CollapsibleContent,
  6. CollapsibleTrigger,
  7. } from '../ui/collapsible'
  8. import { cn } from '../../utils'
  9. import { BrainIcon, ChevronDownIcon } from 'lucide-react'
  10. import type { ComponentProps } from 'react'
  11. import { createContext, memo, useContext, useEffect, useState } from 'react'
  12. import { Response } from './response'
  13. import { Shimmer } from './shimmer'
  14. type ReasoningContextValue = {
  15. isStreaming: boolean
  16. isOpen: boolean
  17. setIsOpen: (open: boolean) => void
  18. duration: number
  19. }
  20. const ReasoningContext = createContext<ReasoningContextValue | null>(null)
  21. const useReasoning = () => {
  22. const context = useContext(ReasoningContext)
  23. if (!context) {
  24. throw new Error('Reasoning components must be used within Reasoning')
  25. }
  26. return context
  27. }
  28. export type ReasoningProps = ComponentProps<typeof Collapsible> & {
  29. isStreaming?: boolean
  30. open?: boolean
  31. defaultOpen?: boolean
  32. onOpenChange?: (open: boolean) => void
  33. duration?: number
  34. }
  35. const AUTO_CLOSE_DELAY = 1000
  36. const MS_IN_S = 1000
  37. export const Reasoning = memo(
  38. ({
  39. className,
  40. isStreaming = false,
  41. open,
  42. defaultOpen = true,
  43. onOpenChange,
  44. duration: durationProp,
  45. children,
  46. ...props
  47. }: ReasoningProps) => {
  48. const [isOpen, setIsOpen] = useControllableState({
  49. prop: open,
  50. defaultProp: defaultOpen,
  51. onChange: onOpenChange,
  52. })
  53. const [duration, setDuration] = useControllableState({
  54. prop: durationProp,
  55. defaultProp: 0,
  56. })
  57. const [hasAutoClosed, setHasAutoClosed] = useState(false)
  58. const [startTime, setStartTime] = useState<number | null>(null)
  59. // Track duration when streaming starts and ends
  60. useEffect(() => {
  61. if (isStreaming) {
  62. if (startTime === null) {
  63. setStartTime(Date.now())
  64. }
  65. } else if (startTime !== null) {
  66. setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
  67. setStartTime(null)
  68. }
  69. }, [isStreaming, startTime, setDuration])
  70. // Auto-open when streaming starts, auto-close when streaming ends (once only)
  71. useEffect(() => {
  72. if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
  73. // Add a small delay before closing to allow user to see the content
  74. const timer = setTimeout(() => {
  75. setIsOpen(false)
  76. setHasAutoClosed(true)
  77. }, AUTO_CLOSE_DELAY)
  78. return () => clearTimeout(timer)
  79. }
  80. }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
  81. const handleOpenChange = (newOpen: boolean) => {
  82. setIsOpen(newOpen)
  83. }
  84. return (
  85. <ReasoningContext.Provider
  86. value={{ isStreaming, isOpen, setIsOpen, duration }}
  87. >
  88. <Collapsible
  89. className={cn('not-prose mb-4', className)}
  90. onOpenChange={handleOpenChange}
  91. open={isOpen}
  92. {...props}
  93. >
  94. {children}
  95. </Collapsible>
  96. </ReasoningContext.Provider>
  97. )
  98. }
  99. )
  100. export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
  101. const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
  102. if (isStreaming || duration === 0) {
  103. return <Shimmer duration={1}>Thinking...</Shimmer>
  104. }
  105. if (duration === undefined) {
  106. return <p>Thought for a few seconds</p>
  107. }
  108. return <p>Thought for {duration} seconds</p>
  109. }
  110. export const ReasoningTrigger = memo(
  111. ({ className, children, ...props }: ReasoningTriggerProps) => {
  112. const { isStreaming, isOpen, duration } = useReasoning()
  113. return (
  114. <CollapsibleTrigger
  115. className={cn(
  116. 'flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground',
  117. className
  118. )}
  119. {...props}
  120. >
  121. {children ?? (
  122. <>
  123. <BrainIcon className="size-4" />
  124. {getThinkingMessage(isStreaming, duration)}
  125. <ChevronDownIcon
  126. className={cn(
  127. 'size-4 transition-transform',
  128. isOpen ? 'rotate-180' : 'rotate-0'
  129. )}
  130. />
  131. </>
  132. )}
  133. </CollapsibleTrigger>
  134. )
  135. }
  136. )
  137. export type ReasoningContentProps = ComponentProps<
  138. typeof CollapsibleContent
  139. > & {
  140. children: string
  141. }
  142. export const ReasoningContent = memo(
  143. ({ className, children, ...props }: ReasoningContentProps) => (
  144. <CollapsibleContent
  145. className={cn(
  146. 'mt-4 text-sm',
  147. '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 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
  148. className
  149. )}
  150. {...props}
  151. >
  152. <Response className="grid gap-2">{children}</Response>
  153. </CollapsibleContent>
  154. )
  155. )
  156. Reasoning.displayName = 'Reasoning'
  157. ReasoningTrigger.displayName = 'ReasoningTrigger'
  158. ReasoningContent.displayName = 'ReasoningContent'