Spaces:
Sleeping
Sleeping
| "use client"; | |
| import type { Message as TMessage } from "ai"; | |
| 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, BrainIcon } from "lucide-react"; | |
| import { SpinnerIcon } from "./icons"; | |
| import { ToolInvocation } from "./tool-invocation"; | |
| import { CopyButton } from "./copy-button"; | |
| interface ReasoningPart { | |
| type: "reasoning"; | |
| reasoning: string; | |
| details: Array<{ type: "text"; text: string }>; | |
| } | |
| interface ReasoningMessagePartProps { | |
| part: ReasoningPart; | |
| isReasoning: boolean; | |
| } | |
| interface ThinkingPart { | |
| type: "thinking"; | |
| thinking: string; | |
| details: Array<{ type: "text"; text: string }>; | |
| } | |
| interface ThinkingMessagePartProps { | |
| part: ThinkingPart; | |
| isThinking: boolean; | |
| } | |
| export function ThinkingMessagePart({ | |
| part, | |
| isThinking, | |
| }: ThinkingMessagePartProps) { | |
| const [isExpanded, setIsExpanded] = useState(false); | |
| const memoizedSetIsExpanded = useCallback((value: boolean) => { | |
| setIsExpanded(value); | |
| }, []); | |
| useEffect(() => { | |
| memoizedSetIsExpanded(isThinking); | |
| }, [isThinking, memoizedSetIsExpanded]); | |
| return ( | |
| <div className="flex flex-col mb-2 group"> | |
| {isThinking ? ( | |
| <div className={cn( | |
| "flex items-center gap-2.5 rounded-full py-1.5 px-3", | |
| "bg-primary/10 text-primary", | |
| "border border-primary/20 w-fit" | |
| )}> | |
| <div className="animate-spin h-3.5 w-3.5"> | |
| <SpinnerIcon /> | |
| </div> | |
| <div className="text-xs font-medium tracking-tight">Thinking...</div> | |
| </div> | |
| ) : ( | |
| <button | |
| onClick={() => setIsExpanded(!isExpanded)} | |
| className={cn( | |
| "flex items-center justify-between w-full", | |
| "rounded-md py-2 px-3 mb-0.5", | |
| "bg-muted/50 border border-border/60 hover:border-border/80", | |
| "transition-all duration-150 cursor-pointer", | |
| isExpanded ? "bg-muted border-primary/20" : "" | |
| )} | |
| > | |
| <div className="flex items-center gap-2.5"> | |
| <div className={cn( | |
| "flex items-center justify-center w-6 h-6 rounded-full", | |
| "bg-primary/10", | |
| "text-primary ring-1 ring-primary/20", | |
| )}> | |
| <BrainIcon className="h-3.5 w-3.5" /> | |
| </div> | |
| <div className="text-sm font-medium text-foreground flex items-center gap-1.5"> | |
| Thinking | |
| <span className="text-xs text-muted-foreground font-normal"> | |
| (click to {isExpanded ? "hide" : "view"}) | |
| </span> | |
| </div> | |
| </div> | |
| <div className={cn( | |
| "flex items-center justify-center", | |
| "rounded-full p-0.5 w-5 h-5", | |
| "text-muted-foreground hover:text-foreground", | |
| "bg-background/80 border border-border/50", | |
| "transition-colors", | |
| )}> | |
| {isExpanded ? ( | |
| <ChevronDownIcon className="h-3 w-3" /> | |
| ) : ( | |
| <ChevronUpIcon className="h-3 w-3" /> | |
| )} | |
| </div> | |
| </button> | |
| )} | |
| {isExpanded && ( | |
| <div | |
| className={cn( | |
| "text-sm text-muted-foreground flex flex-col gap-2", | |
| "pl-2.5 ml-0.5 mt-2", | |
| "border-l border-primary/30" | |
| )} | |
| > | |
| {part.details.map((detail, detailIndex) => | |
| detail.type === "text" ? ( | |
| <div key={detailIndex} className="px-2 py-1.5 bg-muted/10 rounded-md border border-border/30"> | |
| <Markdown>{detail.text}</Markdown> | |
| </div> | |
| ) : ( | |
| "<redacted>" | |
| ), | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| 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 mb-2 group"> | |
| {isReasoning ? ( | |
| <div className={cn( | |
| "flex items-center gap-2.5 rounded-full py-1.5 px-3", | |
| "bg-indigo-50/50 dark:bg-indigo-900/10 text-indigo-700 dark:text-indigo-300", | |
| "border border-indigo-200/50 dark:border-indigo-700/20 w-fit" | |
| )}> | |
| <div className="animate-spin h-3.5 w-3.5"> | |
| <SpinnerIcon /> | |
| </div> | |
| <div className="text-xs font-medium tracking-tight">Thinking...</div> | |
| </div> | |
| ) : ( | |
| <button | |
| onClick={() => setIsExpanded(!isExpanded)} | |
| className={cn( | |
| "flex items-center justify-between w-full", | |
| "rounded-md py-2 px-3 mb-0.5", | |
| "bg-muted/50 border border-border/60 hover:border-border/80", | |
| "transition-all duration-150 cursor-pointer", | |
| isExpanded ? "bg-muted border-primary/20" : "" | |
| )} | |
| > | |
| <div className="flex items-center gap-2.5"> | |
| <div className={cn( | |
| "flex items-center justify-center w-6 h-6 rounded-full", | |
| "bg-amber-50 dark:bg-amber-900/20", | |
| "text-amber-600 dark:text-amber-400 ring-1 ring-amber-200 dark:ring-amber-700/30", | |
| )}> | |
| <LightbulbIcon className="h-3.5 w-3.5" /> | |
| </div> | |
| <div className="text-sm font-medium text-foreground flex items-center gap-1.5"> | |
| Reasoning | |
| <span className="text-xs text-muted-foreground font-normal"> | |
| (click to {isExpanded ? "hide" : "view"}) | |
| </span> | |
| </div> | |
| </div> | |
| <div className={cn( | |
| "flex items-center justify-center", | |
| "rounded-full p-0.5 w-5 h-5", | |
| "text-muted-foreground hover:text-foreground", | |
| "bg-background/80 border border-border/50", | |
| "transition-colors", | |
| )}> | |
| {isExpanded ? ( | |
| <ChevronDownIcon className="h-3 w-3" /> | |
| ) : ( | |
| <ChevronUpIcon className="h-3 w-3" /> | |
| )} | |
| </div> | |
| </button> | |
| )} | |
| {isExpanded && ( | |
| <div | |
| className={cn( | |
| "text-sm text-muted-foreground flex flex-col gap-2", | |
| "pl-3.5 ml-0.5 mt-1", | |
| "border-l border-amber-200/50 dark:border-amber-700/30" | |
| )} | |
| > | |
| <div className="text-xs text-muted-foreground/70 pl-1 font-medium"> | |
| The assistant's thought process: | |
| </div> | |
| {part.details.map((detail, detailIndex) => | |
| detail.type === "text" ? ( | |
| <div key={detailIndex} className="px-2 py-1.5 bg-muted/10 rounded-md border border-border/30"> | |
| <Markdown>{detail.text}</Markdown> | |
| </div> | |
| ) : ( | |
| "<redacted>" | |
| ), | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const PurePreviewMessage = ({ | |
| message, | |
| isLatestMessage, | |
| status, | |
| }: { | |
| message: TMessage; | |
| isLoading: boolean; | |
| status: "error" | "submitted" | "streaming" | "ready"; | |
| isLatestMessage: boolean; | |
| }) => { | |
| // Create a string with all text parts for copy functionality | |
| const getMessageText = () => { | |
| if (!message.parts) return ""; | |
| return message.parts | |
| .filter((part) => part.type === "text") | |
| .map((part) => (part.type === "text" ? part.text : "")) | |
| .join("\n\n"); | |
| }; | |
| // Only show copy button if the message is from the assistant and not currently streaming | |
| const shouldShowCopyButton = message.role === "assistant" && (!isLatestMessage || status !== "streaming"); | |
| return ( | |
| <div | |
| className={cn( | |
| "w-full mx-auto sm:px-4 group/message", | |
| message.role === "assistant" ? "mb-8" : "mb-6" | |
| )} | |
| 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.content && | |
| message.content | |
| .split(/(<think>[\s\S]*?(?:<\/think>|$))/g) | |
| .map((part: string, i: number) => { | |
| if (part.startsWith("<think>")) { | |
| const isClosed = part.endsWith("</think>"); | |
| const thinkingContent = part.slice( | |
| 7, | |
| isClosed ? -8 : undefined, | |
| ); | |
| return ( | |
| <ThinkingMessagePart | |
| key={`message-${message.id}-part-${i}`} | |
| part={{ | |
| type: "thinking", | |
| thinking: thinkingContent, | |
| details: [{ type: "text", text: thinkingContent }], | |
| }} | |
| isThinking={status === "streaming" && !isClosed} | |
| /> | |
| ); | |
| } else if (part.trim()) { | |
| return ( | |
| <div | |
| 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-1.5 rounded-2xl": | |
| message.role === "user", | |
| })} | |
| > | |
| <Markdown>{part}</Markdown> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| })} | |
| {shouldShowCopyButton && ( | |
| <div className="flex justify-start mt-2"> | |
| <CopyButton text={getMessageText()} /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export const Message = memo(PurePreviewMessage, (prevProps, nextProps) => { | |
| if (prevProps.status !== nextProps.status) return false; | |
| if (prevProps.isLoading !== nextProps.isLoading) return false; | |
| if (prevProps.isLatestMessage !== nextProps.isLatestMessage) return false; | |
| if (prevProps.message.annotations !== nextProps.message.annotations) return false; | |
| if (prevProps.message.id !== nextProps.message.id) return false; | |
| if (!equal(prevProps.message.parts, nextProps.message.parts)) return false; | |
| return true; | |
| }); | |