Skip to content

Commit 9dbda64

Browse files
authored
Merge pull request #14 from yoziru/add-thinking-block
add thinking block
2 parents 96918ef + c09d3dd commit 9dbda64

File tree

2 files changed

+135
-4
lines changed

2 files changed

+135
-4
lines changed

src/components/chat/chat-list.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import CodeDisplayBlock from "../code-display-block";
77
import Markdown from "react-markdown";
88
import remarkGfm from "remark-gfm";
99
import { Message } from "ai";
10+
import ThinkBlock from "../think-block";
1011

1112
interface ChatListProps {
1213
messages: Message[];
@@ -22,6 +23,47 @@ const MessageToolbar = () => (
2223
</div>
2324
);
2425

26+
// Utility to process <think> tags
27+
function processThinkTags(content: string, isLoading: boolean, message: Message | undefined, prevUserMessage: Message | undefined) {
28+
const thinkOpen = content.indexOf("<think>");
29+
const thinkClose = content.indexOf("</think>");
30+
31+
// If <think> is present and </think> is not, show live thinking content
32+
if (thinkOpen !== -1 && thinkClose === -1) {
33+
// Show everything before <think>, then the ThinkBlock component with live mode
34+
const before = content.slice(0, thinkOpen);
35+
const thinkContent = content.slice(thinkOpen + 7); // everything after <think>
36+
return [
37+
before,
38+
<ThinkBlock key="live-think" content={thinkContent.trim()} live={true} />,
39+
];
40+
}
41+
42+
// If both <think> and </think> are present, show collapsible block
43+
if (thinkOpen !== -1 && thinkClose !== -1) {
44+
const before = content.slice(0, thinkOpen);
45+
const thinkContent = content.slice(thinkOpen + 7, thinkClose);
46+
const after = content.slice(thinkClose + 8);
47+
// Calculate duration if timestamps are available
48+
let duration;
49+
if (message?.createdAt && prevUserMessage?.createdAt) {
50+
const start = new Date(prevUserMessage.createdAt).getTime();
51+
const end = new Date(message.createdAt).getTime();
52+
if (!isNaN(start) && !isNaN(end) && end > start) {
53+
duration = ((end - start) / 1000).toFixed(2) + " seconds";
54+
}
55+
}
56+
return [
57+
before,
58+
<ThinkBlock key="collapsible-think" content={thinkContent.trim()} duration={duration} />,
59+
after,
60+
];
61+
}
62+
63+
// No <think> tag, return as is
64+
return [content];
65+
}
66+
2567
export default function ChatList({ messages, isLoading }: ChatListProps) {
2668
const bottomRef = useRef<HTMLDivElement>(null);
2769

@@ -102,10 +144,17 @@ export default function ChatList({ messages, isLoading }: ChatListProps) {
102144
{/* Check if the message content contains a code block */}
103145
{message.content.split("```").map((part, index) => {
104146
if (index % 2 === 0) {
105-
return (
106-
<Markdown key={index} remarkPlugins={[remarkGfm]}>
107-
{part}
108-
</Markdown>
147+
// Find previous user message
148+
const prevUserMessage = messages.slice(0, messages.indexOf(message)).reverse().find(m => m.role === "user");
149+
// Process <think> tags before rendering Markdown
150+
return processThinkTags(part, isLoading, message, prevUserMessage).map((segment, segIdx) =>
151+
typeof segment === "string" ? (
152+
<Markdown key={index + "-" + segIdx} remarkPlugins={[remarkGfm]}>
153+
{segment}
154+
</Markdown>
155+
) : (
156+
segment // This is the <Thinking /> component
157+
)
109158
);
110159
} else {
111160
return (

src/components/think-block.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import Markdown from "react-markdown";
3+
import remarkGfm from "remark-gfm";
4+
5+
interface ThinkBlockProps {
6+
content: string;
7+
live?: boolean;
8+
duration?: string;
9+
}
10+
11+
export default function ThinkBlock({ content, live = false, duration }: ThinkBlockProps) {
12+
// Live timer state
13+
const [seconds, setSeconds] = useState(0);
14+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
15+
// Collapsible state
16+
const [open, setOpen] = useState(live ? true : false);
17+
18+
useEffect(() => {
19+
if (live) {
20+
intervalRef.current = setInterval(() => {
21+
setSeconds((s) => +(s + 0.1).toFixed(2));
22+
}, 100);
23+
return () => {
24+
if (intervalRef.current) clearInterval(intervalRef.current);
25+
};
26+
}
27+
}, [live]);
28+
29+
// Always open in live mode, collapsible in non-live mode
30+
const isOpen = live ? true : open;
31+
32+
return (
33+
<div className={"my-4 rounded-xl border border-dashed border-blue-400 bg-background/50"}>
34+
<div className="flex items-center px-4 pt-2 pb-1 select-none">
35+
{/* Chevron (button for collapsible, static for live) */}
36+
{live ? (
37+
<span className="mr-2 inline-block transition-transform rotate-0" style={{ width: 20, height: 20 }}>
38+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
39+
<path d="M7 8l3 3 3-3" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
40+
</svg>
41+
</span>
42+
) : (
43+
<button
44+
className="mr-2 flex items-center justify-center focus:outline-none transition-transform"
45+
onClick={() => setOpen((o) => !o)}
46+
aria-expanded={open}
47+
style={{ height: 24, width: 24 }}
48+
>
49+
<span
50+
className={`inline-block transition-transform duration-200 ${open ? "rotate-0" : "-rotate-90"}`}
51+
style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 20, width: 20 }}
52+
>
53+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
54+
<path d="M7 8l3 3 3-3" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
55+
</svg>
56+
</span>
57+
</button>
58+
)}
59+
{/* Label */}
60+
<span className="text-blue-400 font-medium text-sm mr-3">Thoughts</span>
61+
{/* Header */}
62+
{live ? (
63+
<span className="italic text-sm text-muted-foreground flex items-center gap-2">
64+
Thinking for {seconds.toFixed(2)} seconds
65+
<span className="ml-1 inline-block align-middle">
66+
<span className="w-4 h-4 border-2 border-blue-300 border-t-transparent rounded-full inline-block animate-spin"></span>
67+
</span>
68+
</span>
69+
) : (
70+
<span className="italic text-sm text-muted-foreground">
71+
{duration ? `Thought for ${duration}` : "Thought"}
72+
</span>
73+
)}
74+
</div>
75+
{isOpen && (
76+
<div className="px-6 pb-4 pt-1 text-sm text-muted-foreground">
77+
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
78+
</div>
79+
)}
80+
</div>
81+
);
82+
}

0 commit comments

Comments
 (0)