Spaces:
Running
Running
"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; | |
}); | |