Skip to content

Commit 3db45bd

Browse files
committed
feat: Add Accordion component and implement AI reasoning display.
1 parent 518a4c5 commit 3db45bd

File tree

4 files changed

+423
-0
lines changed

4 files changed

+423
-0
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"use client";
2+
3+
import { memo, useCallback, useRef, useState, useMemo, Ref } from "react";
4+
import { cva, type VariantProps } from "class-variance-authority";
5+
// import { BrainIcon, ChevronDownIcon } from "lucide-react";
6+
import { MarkdownText } from "./markdown-text";
7+
// import {
8+
// Collapsible,
9+
// CollapsibleContent,
10+
// CollapsibleTrigger,
11+
// } from "@/components/ui/collapsible";
12+
// import { useScrollLock } from "@creatorem/ai-react/primitives/reasoning";
13+
import type {
14+
ReasoningGroupComponent,
15+
ReasoningMessagePartComponent,
16+
} from "@creatorem/ai-chat/types/message-part-component-types";
17+
import { useMessage } from "@creatorem/ai-chat/primitives/message";
18+
import { Accordion, AccordionContent, AccordionTrigger } from "../ui/accordion";
19+
import { View } from "react-native";
20+
import { cn } from "~/utils/cn";
21+
import { Icon } from "../ui/icon";
22+
import { Text } from "../ui/text";
23+
24+
// const ANIMATION_DURATION = 200;
25+
26+
const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
27+
variants: {
28+
variant: {
29+
default: "",
30+
outline: "rounded-lg border px-3 py-2",
31+
muted: "rounded-lg bg-muted/50 px-3 py-2",
32+
},
33+
},
34+
defaultVariants: {
35+
variant: "default",
36+
},
37+
});
38+
39+
export type ReasoningRootProps = Omit<
40+
React.ComponentProps<typeof Accordion>,
41+
"open" | "onOpenChange" | "type"
42+
> &
43+
VariantProps<typeof reasoningVariants> & {
44+
open?: boolean;
45+
onOpenChange?: (open: boolean) => void;
46+
defaultOpen?: boolean;
47+
};
48+
49+
function ReasoningRoot({
50+
className,
51+
variant,
52+
open: controlledOpen,
53+
onOpenChange: controlledOnOpenChange,
54+
defaultOpen = false,
55+
children,
56+
...props
57+
}: ReasoningRootProps) {
58+
const collapsibleRef = useRef<View>(null);
59+
60+
return (
61+
<Accordion
62+
ref={collapsibleRef}
63+
type="single"
64+
data-slot="reasoning-root"
65+
data-variant={variant}
66+
className={cn(reasoningVariants({ variant, className }))}
67+
{...props}
68+
>
69+
{children}
70+
</Accordion>
71+
);
72+
}
73+
74+
function ReasoningFade({
75+
className,
76+
...props
77+
}: React.ComponentProps<typeof View>) {
78+
return (
79+
<View
80+
className={cn(
81+
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
82+
"bg-[linear-gradient(to_top,var(--color-background),transparent)]",
83+
"fade-in-0 animate-in",
84+
"group-data-[state=open]/collapsible-content:animate-out",
85+
"group-data-[state=open]/collapsible-content:fade-out-0",
86+
"group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]",
87+
"group-data-[state=open]/collapsible-content:fill-mode-forwards",
88+
"duration-(--animation-duration)",
89+
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
90+
className,
91+
)}
92+
{...props}
93+
/>
94+
);
95+
}
96+
97+
function ReasoningTrigger({
98+
active,
99+
duration,
100+
className,
101+
...props
102+
}: React.ComponentProps<typeof AccordionTrigger> & {
103+
active?: boolean;
104+
duration?: number;
105+
}) {
106+
const durationText = duration ? ` (${duration}s)` : "";
107+
108+
return (
109+
<AccordionTrigger
110+
className={cn(
111+
"group/trigger flex max-w-[75%] items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
112+
className,
113+
)}
114+
{...props}
115+
>
116+
<Icon
117+
name="Brain"
118+
className="size-4 shrink-0"
119+
/>
120+
<View
121+
className="relative inline-block leading-none"
122+
>
123+
<Text>Reasoning{durationText}</Text>
124+
{active ? (
125+
<Text
126+
aria-hidden
127+
className="shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
128+
>
129+
Reasoning{durationText}
130+
</Text>
131+
) : null}
132+
</View>
133+
<Icon
134+
name="ChevronDown"
135+
className={cn(
136+
"mt-0.5 size-4 shrink-0",
137+
"transition-transform duration-(--animation-duration) ease-out",
138+
"group-data-[state=closed]/trigger:-rotate-90",
139+
"group-data-[state=open]/trigger:rotate-0",
140+
)}
141+
/>
142+
</AccordionTrigger>
143+
);
144+
}
145+
146+
function ReasoningContent({
147+
className,
148+
children,
149+
...props
150+
}: React.ComponentProps<typeof AccordionContent>) {
151+
return (
152+
<AccordionContent
153+
className={cn(
154+
"relative overflow-hidden text-muted-foreground text-sm outline-none",
155+
"group/collapsible-content ease-out",
156+
"data-[state=closed]:animate-collapsible-up",
157+
"data-[state=open]:animate-collapsible-down",
158+
"data-[state=closed]:fill-mode-forwards",
159+
"data-[state=closed]:pointer-events-none",
160+
"data-[state=open]:duration-(--animation-duration)",
161+
"data-[state=closed]:duration-(--animation-duration)",
162+
className,
163+
)}
164+
{...props}
165+
>
166+
{children}
167+
<ReasoningFade />
168+
</AccordionContent>
169+
);
170+
}
171+
172+
function ReasoningText({
173+
className,
174+
...props
175+
}: React.ComponentProps<typeof View>) {
176+
return (
177+
<View
178+
className={cn(
179+
"relative z-0 max-h-64 space-y-4 overflow-y-auto pt-2 pb-4 pl-6 leading-relaxed",
180+
"transform-gpu transition-[transform,opacity]",
181+
"group-data-[state=open]/collapsible-content:animate-in",
182+
"group-data-[state=closed]/collapsible-content:animate-out",
183+
"group-data-[state=open]/collapsible-content:fade-in-0",
184+
"group-data-[state=closed]/collapsible-content:fade-out-0",
185+
"group-data-[state=open]/collapsible-content:slide-in-from-top-4",
186+
"group-data-[state=closed]/collapsible-content:slide-out-to-top-4",
187+
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
188+
"group-data-[state=closed]/collapsible-content:duration-(--animation-duration)",
189+
"[&_p]:-mb-2",
190+
className,
191+
)}
192+
{...props}
193+
/>
194+
);
195+
}
196+
197+
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
198+
199+
const ReasoningGroupImpl: ReasoningGroupComponent = ({
200+
children,
201+
startIndex,
202+
endIndex,
203+
}) => {
204+
const message = useMessage();
205+
const hasReasoningText = useMemo(() => {
206+
const parts = message.parts.slice(startIndex, endIndex + 1);
207+
return parts.some(
208+
(part) =>
209+
part.type === "reasoning" &&
210+
typeof part.text === "string" &&
211+
part.text.trim().length > 0,
212+
);
213+
}, [message.parts, startIndex, endIndex]);
214+
const isReasoningStreaming = useMemo(() => {
215+
if (message.status?.type !== "running") return false;
216+
const lastIndex = message.parts.length - 1;
217+
if (lastIndex < 0) return false;
218+
const lastType = message.parts[lastIndex]?.type;
219+
if (lastType !== "reasoning") return false;
220+
return lastIndex >= startIndex && lastIndex <= endIndex;
221+
}, [message, endIndex, startIndex]);
222+
223+
if (!hasReasoningText && !isReasoningStreaming) {
224+
return null;
225+
}
226+
227+
return (
228+
<ReasoningRoot defaultOpen={isReasoningStreaming}>
229+
<ReasoningTrigger active={isReasoningStreaming} />
230+
<ReasoningContent aria-busy={isReasoningStreaming}>
231+
<ReasoningText>{children}</ReasoningText>
232+
</ReasoningContent>
233+
</ReasoningRoot>
234+
);
235+
};
236+
237+
const Reasoning = memo(
238+
ReasoningImpl,
239+
) as unknown as ReasoningMessagePartComponent & {
240+
Root: typeof ReasoningRoot;
241+
Trigger: typeof ReasoningTrigger;
242+
Content: typeof ReasoningContent;
243+
Text: typeof ReasoningText;
244+
Fade: typeof ReasoningFade;
245+
};
246+
247+
Reasoning.displayName = "Reasoning";
248+
Reasoning.Root = ReasoningRoot;
249+
Reasoning.Trigger = ReasoningTrigger;
250+
Reasoning.Content = ReasoningContent;
251+
Reasoning.Text = ReasoningText;
252+
Reasoning.Fade = ReasoningFade;
253+
254+
const ReasoningGroup = memo(ReasoningGroupImpl);
255+
ReasoningGroup.displayName = "ReasoningGroup";
256+
257+
export {
258+
Reasoning,
259+
ReasoningGroup,
260+
ReasoningRoot,
261+
ReasoningTrigger,
262+
ReasoningContent,
263+
ReasoningText,
264+
ReasoningFade,
265+
reasoningVariants,
266+
};

0 commit comments

Comments
 (0)