AI Reasoning
AI Reasoning é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
other
O que é
AI Reasoning é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i lucide-react motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
AI Reasoning
"use client";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
const SPRING_DEFAULT = {
bounce: 0.1,
duration: 0.25,
type: "spring" as const,
};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
/**
* A beat before auto-collapsing. Snapping shut the instant the trace finishes
* makes it feel like the content was yanked away mid-read.
*/
const AUTO_COLLAPSE_DELAY_MS = 600;
const MS_PER_SECOND = 1000;
const SHIMMER_SECONDS = 1.8;
export type AIReasoningProps = {
children: ReactNode;
className?: string;
/**
* Collapse itself once `isStreaming` goes false. The trace is scaffolding —
* interesting while it happens, clutter once the answer has landed.
*/
collapseWhenDone?: boolean;
/** Start expanded. */
defaultOpen?: boolean;
/** Seconds the model spent. Omit to have the component time it itself. */
duration?: number;
isStreaming?: boolean;
};
/**
* Collapsible reasoning trace.
*
* The summary line shimmers only while the model is actually working, so the
* shimmer means something rather than being decoration. When the trace ends it
* settles, reports how long it took, and gets out of the way.
*/
const AIReasoning = ({
children,
className,
collapseWhenDone = true,
defaultOpen,
duration,
isStreaming = false,
}: AIReasoningProps) => {
const shouldReduceMotion = useReducedMotion();
const [isOpen, setIsOpen] = useState(defaultOpen ?? isStreaming);
// Once someone opens or closes it by hand, stop deciding for them.
const [isUserControlled, setIsUserControlled] = useState(false);
const startedAtRef = useRef<number | null>(null);
const [measuredSeconds, setMeasuredSeconds] = useState<number | null>(null);
useEffect(() => {
if (isStreaming) {
startedAtRef.current = Date.now();
setMeasuredSeconds(null);
if (!isUserControlled) {
setIsOpen(true);
}
return;
}
if (startedAtRef.current !== null) {
setMeasuredSeconds((Date.now() - startedAtRef.current) / MS_PER_SECOND);
startedAtRef.current = null;
}
}, [isStreaming, isUserControlled]);
useEffect(() => {
if (isStreaming || !collapseWhenDone || isUserControlled) {
return;
}
const timeout = setTimeout(() => setIsOpen(false), AUTO_COLLAPSE_DELAY_MS);
return () => clearTimeout(timeout);
}, [collapseWhenDone, isStreaming, isUserControlled]);
const seconds = duration ?? measuredSeconds;
const summary = (() => {
if (isStreaming) {
return "Thinking";
}
if (seconds !== null) {
return `Thought for ${seconds.toFixed(1)}s`;
}
return "Reasoning";
})();
return (
<div className={cn("w-full", className)}>
<button
aria-expanded={isOpen}
className="group flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 text-left text-muted-foreground text-sm transition-colors hover:text-foreground"
onClick={() => {
setIsUserControlled(true);
setIsOpen((current) => !current);
}}
type="button"
>
<motion.span
animate={{ rotate: isOpen ? 90 : 0 }}
className="flex size-4 items-center justify-center"
transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
>
<ChevronRight aria-hidden="true" size={14} />
</motion.span>
{/* The shimmer is the "still working" signal, so it exists only while
streaming. A permanently shimmering label is just decoration. */}
{isStreaming && !shouldReduceMotion ? (
<span
className="bg-[length:200%_100%] bg-clip-text text-transparent"
style={{
animation: `ai-reasoning-shimmer ${SHIMMER_SECONDS}s linear infinite`,
backgroundImage:
"linear-gradient(90deg, currentColor 0%, currentColor 35%, color-mix(in oklab, currentColor 25%, transparent) 50%, currentColor 65%, currentColor 100%)",
}}
>
{summary}
</span>
) : (
<span>{summary}</span>
)}
</button>
<style>{`
@keyframes ai-reasoning-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
`}</style>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { height: 0, opacity: 0 }
}
initial={
shouldReduceMotion
? { height: "auto", opacity: 1 }
: { height: 0, opacity: 0 }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
height: SPRING_DEFAULT,
opacity: { duration: 0.18, ease: EASE_OUT },
}
}
>
{/* The rule doubles as a margin: the trace reads as an aside, not as
part of the answer. */}
<div className="mt-1 ml-[7px] border-border border-l pl-4 text-muted-foreground text-sm leading-relaxed">
{children}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
};
export default AIReasoning;