Spaces:
Running
Running
"use client"; | |
import type { Message as TMessage } from "ai"; | |
import { AnimatePresence, motion } from "motion/react"; | |
import { memo, useCallback, useEffect, useState } from "react"; | |
import equal from "fast-deep-equal"; | |
import { Markdown } from "./markdown"; | |
import { cn } from "@/lib/utils"; | |
import { ChevronDownIcon, ChevronUpIcon, LightbulbIcon } from "lucide-react"; | |
import { SpinnerIcon } from "./icons"; | |
import { ToolInvocation } from "./tool-invocation"; | |
interface ReasoningPart { | |
type: "reasoning"; | |
reasoning: string; | |
details: Array<{ type: "text"; text: string }>; | |
} | |
interface ReasoningMessagePartProps { | |
part: ReasoningPart; | |
isReasoning: boolean; | |
} | |
export function ReasoningMessagePart({ | |
part, | |
isReasoning, | |
}: ReasoningMessagePartProps) { | |
const [isExpanded, setIsExpanded] = useState(false); | |
const memoizedSetIsExpanded = useCallback((value: boolean) => { | |
setIsExpanded(value); | |
}, []); | |
useEffect(() => { | |
memoizedSetIsExpanded(isReasoning); | |
}, [isReasoning, memoizedSetIsExpanded]); | |
return ( | |
<div className="flex flex-col py-1"> | |
{isReasoning ? ( | |
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"> | |
<div className="animate-spin"> | |
<SpinnerIcon /> | |
</div> | |
<div className="text-sm">Thinking...</div> | |
</div> | |
) : ( | |
<div className="flex items-center gap-2 group"> | |
<div className="flex items-center gap-2"> | |
<LightbulbIcon className="h-4 w-4 text-zinc-400" /> | |
<div className="text-sm text-zinc-600 dark:text-zinc-300">Reasoning</div> | |
</div> | |
<button | |
className={cn( | |
"cursor-pointer rounded-md p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100", | |
{ | |
"opacity-100 bg-zinc-100 dark:bg-zinc-800": isExpanded, | |
}, | |
)} | |
onClick={() => setIsExpanded(!isExpanded)} | |
> | |
{isExpanded ? ( | |
<ChevronDownIcon className="h-3.5 w-3.5" /> | |
) : ( | |
<ChevronUpIcon className="h-3.5 w-3.5" /> | |
)} | |
</button> | |
</div> | |
)} | |
<AnimatePresence initial={false}> | |
{isExpanded && ( | |
<motion.div | |
key="reasoning" | |
className="text-sm text-zinc-600 dark:text-zinc-400 flex flex-col gap-3 border-l-2 pl-4 mt-2 border-zinc-200 dark:border-zinc-700 overflow-hidden" | |
initial={{ height: 0, opacity: 0 }} | |
animate={{ height: "auto", opacity: 1 }} | |
exit={{ height: 0, opacity: 0 }} | |
transition={{ duration: 0.2, ease: "easeInOut" }} | |
> | |
{part.details.map((detail, detailIndex) => | |
detail.type === "text" ? ( | |
<Markdown key={detailIndex}>{detail.text}</Markdown> | |
) : ( | |
"<redacted>" | |
), | |
)} | |
</motion.div> | |
)} | |
</AnimatePresence> | |
</div> | |
); | |
} | |
const PurePreviewMessage = ({ | |
message, | |
isLatestMessage, | |
status, | |
}: { | |
message: TMessage; | |
isLoading: boolean; | |
status: "error" | "submitted" | "streaming" | "ready"; | |
isLatestMessage: boolean; | |
}) => { | |
return ( | |
<AnimatePresence key={message.id}> | |
<motion.div | |
className={cn( | |
"w-full mx-auto px-4 group/message", | |
message.role === "assistant" ? "mb-8" : "mb-6" | |
)} | |
initial={{ y: 5, opacity: 0 }} | |
animate={{ y: 0, opacity: 1 }} | |
key={`message-${message.id}`} | |
data-role={message.role} | |
> | |
<div | |
className={cn( | |
"flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl", | |
"group-data-[role=user]/message:w-fit", | |
)} | |
> | |
<div className="flex flex-col w-full space-y-3"> | |
{message.parts?.map((part, i) => { | |
switch (part.type) { | |
case "text": | |
return ( | |
<motion.div | |
initial={{ y: 5, opacity: 0 }} | |
animate={{ y: 0, opacity: 1 }} | |
key={`message-${message.id}-part-${i}`} | |
className="flex flex-row gap-2 items-start w-full" | |
> | |
<div | |
className={cn("flex flex-col gap-3 w-full", { | |
"bg-secondary text-secondary-foreground px-4 py-3 rounded-2xl": | |
message.role === "user", | |
})} | |
> | |
<Markdown>{part.text}</Markdown> | |
</div> | |
</motion.div> | |
); | |
case "tool-invocation": | |
const { toolName, state, args } = part.toolInvocation; | |
const result = 'result' in part.toolInvocation ? part.toolInvocation.result : null; | |
return ( | |
<ToolInvocation | |
key={`message-${message.id}-part-${i}`} | |
toolName={toolName} | |
state={state} | |
args={args} | |
result={result} | |
isLatestMessage={isLatestMessage} | |
status={status} | |
/> | |
); | |
case "reasoning": | |
return ( | |
<ReasoningMessagePart | |
key={`message-${message.id}-${i}`} | |
// @ts-expect-error part | |
part={part} | |
isReasoning={ | |
(message.parts && | |
status === "streaming" && | |
i === message.parts.length - 1) ?? | |
false | |
} | |
/> | |
); | |
default: | |
return null; | |
} | |
})} | |
</div> | |
</div> | |
</motion.div> | |
</AnimatePresence> | |
); | |
}; | |
export const Message = memo(PurePreviewMessage, (prevProps, nextProps) => { | |
if (prevProps.status !== nextProps.status) return false; | |
if (prevProps.message.annotations !== nextProps.message.annotations) | |
return false; | |
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false; | |
return true; | |
}); | |