Skip to content

Commit 079b6b1

Browse files
committed
feat: now reasoning output is rendered in the UI
1 parent 79a063e commit 079b6b1

6 files changed

Lines changed: 215 additions & 30 deletions

File tree

src/components/Chat.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Message } from 'ai'
77
import { useLocalStorage } from '../hooks/useLocalStorage'
88
import { type Servers } from '../lib/schemas'
99
import { ToolCallMessage } from './ToolCallMessage'
10+
import { ReasoningMessage } from './ReasoningMessage'
1011
import { useModel } from '../contexts/ModelContext'
1112

1213
// Streamed event type
@@ -22,6 +23,15 @@ type StreamEvent =
2223
arguments?: unknown
2324
}
2425
| { type: 'user'; id: string; content: string }
26+
| {
27+
type: 'reasoning'
28+
effort: string
29+
summary: string | null
30+
model?: string
31+
serviceTier?: string
32+
temperature?: number
33+
topP?: number
34+
}
2535

2636
export function Chat() {
2737
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -61,6 +71,22 @@ export function Chat() {
6171
try {
6272
const toolState = JSON.parse(line.slice(2))
6373

74+
if (toolState.type === 'reasoning') {
75+
setStreamBuffer((prev) => [
76+
...prev,
77+
{
78+
type: 'reasoning',
79+
effort: toolState.effort,
80+
summary: toolState.summary,
81+
model: toolState.model,
82+
serviceTier: toolState.serviceTier,
83+
temperature: toolState.temperature,
84+
topP: toolState.topP,
85+
},
86+
])
87+
return
88+
}
89+
6490
if ('delta' in toolState) {
6591
try {
6692
toolState.delta =
@@ -191,6 +217,19 @@ export function Chat() {
191217
args={event}
192218
/>
193219
)
220+
} else if ('type' in event && event.type === 'reasoning') {
221+
return (
222+
<ReasoningMessage
223+
key={`reasoning-${event.effort}-${event.summary || ''}`}
224+
effort={event.effort}
225+
summary={event.summary}
226+
model={event.model}
227+
serviceTier={event.serviceTier}
228+
temperature={event.temperature}
229+
topP={event.topP}
230+
isLoading={streaming && idx === renderEvents.length - 1}
231+
/>
232+
)
194233
} else if ('type' in event && event.type === 'assistant') {
195234
const assistantEvent = event as Extract<
196235
StreamEvent,

src/components/ChatMessage.tsx

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { cn } from '../lib/utils'
22
import type { Message } from '../mcp/client'
33
import { formatTimestamp } from '../lib/utils'
44
import { Bot, User, CheckCircle2, Clock, AlertCircle } from 'lucide-react'
5-
import ReactMarkdown from 'react-markdown'
5+
import { MarkdownContent } from './MarkdownContent'
66

77
type ChatMessageProps = {
88
message: Message
@@ -52,35 +52,7 @@ export function ChatMessage({ message, isLoading }: ChatMessageProps) {
5252
)}
5353
>
5454
<div className="prose prose-sm dark:prose-invert max-w-none break-words break-all whitespace-pre-wrap">
55-
<ReactMarkdown
56-
components={{
57-
pre: ({ node, ...props }) => (
58-
<pre
59-
className="overflow-x-auto whitespace-pre-wrap break-words break-all"
60-
{...props}
61-
/>
62-
),
63-
code: ({ node, ...props }) => (
64-
<code
65-
className="break-words break-all whitespace-pre-wrap"
66-
{...props}
67-
/>
68-
),
69-
a: ({ href, children, ...props }) => (
70-
<a
71-
href={href}
72-
className="break-words break-all"
73-
target="_blank"
74-
rel="noopener noreferrer"
75-
{...props}
76-
>
77-
{children}
78-
</a>
79-
),
80-
}}
81-
>
82-
{message.content}
83-
</ReactMarkdown>
55+
<MarkdownContent content={message.content} />
8456
</div>
8557
</div>
8658

src/components/MarkdownContent.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import ReactMarkdown from 'react-markdown'
2+
3+
type MarkdownContentProps = {
4+
content: string
5+
}
6+
7+
export function MarkdownContent({ content }: MarkdownContentProps) {
8+
return (
9+
<div className="prose prose-sm dark:prose-invert max-w-none break-words break-all whitespace-pre-wrap">
10+
<ReactMarkdown
11+
components={{
12+
pre: ({ node, ...props }) => (
13+
<pre
14+
className="overflow-x-auto whitespace-pre-wrap break-words break-all"
15+
{...props}
16+
/>
17+
),
18+
code: ({ node, ...props }) => (
19+
<code
20+
className="break-words break-all whitespace-pre-wrap"
21+
{...props}
22+
/>
23+
),
24+
a: ({ href, children, ...props }) => (
25+
<a
26+
href={href}
27+
className="break-words break-all"
28+
target="_blank"
29+
rel="noopener noreferrer"
30+
{...props}
31+
>
32+
{children}
33+
</a>
34+
),
35+
}}
36+
>
37+
{content}
38+
</ReactMarkdown>
39+
</div>
40+
)
41+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Brain } from 'lucide-react'
2+
import { cn } from '../lib/utils'
3+
import { MarkdownContent } from './MarkdownContent'
4+
5+
type ReasoningMessageProps = {
6+
effort: string
7+
summary: string | null
8+
model?: string
9+
serviceTier?: string
10+
temperature?: number
11+
topP?: number
12+
isLoading?: boolean
13+
}
14+
15+
export function ReasoningMessage({
16+
effort,
17+
summary,
18+
model,
19+
serviceTier,
20+
temperature,
21+
topP,
22+
isLoading,
23+
}: ReasoningMessageProps) {
24+
return (
25+
<div className="flex w-full max-w-full gap-2 py-2 animate-in fade-in justify-start">
26+
<div
27+
className={cn(
28+
'flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300',
29+
isLoading && 'animate-[pulse_1.5s_ease-in-out_infinite] opacity-80',
30+
)}
31+
>
32+
<Brain className="h-5 w-5" />
33+
</div>
34+
35+
<div className="flex flex-col space-y-1 items-start w-full sm:w-[85%] md:w-[75%] lg:w-[65%]">
36+
<div className="rounded-2xl px-4 py-2 text-sm w-full bg-purple-50 text-purple-900 dark:bg-purple-950 dark:text-purple-100">
37+
<div className="font-medium mb-1">Reasoning</div>
38+
<div className="text-xs space-y-1">
39+
{effort && <div>Effort: {effort}</div>}
40+
{summary && (
41+
<div className="prose prose-sm dark:prose-invert max-w-none break-words break-all whitespace-pre-wrap">
42+
<MarkdownContent content={summary} />
43+
</div>
44+
)}
45+
{model && <div>Model: {model}</div>}
46+
{serviceTier && <div>Service Tier: {serviceTier}</div>}
47+
<div className="flex gap-4">
48+
{temperature !== undefined && (
49+
<div>Temperature: {temperature}</div>
50+
)}
51+
{topP !== undefined && <div>Top P: {topP}</div>}
52+
</div>
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
)
58+
}

src/lib/streaming.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function streamText(
2121
}
2222

2323
let buffer = ''
24+
let reasoningSummaryBuffer = ''
2425

2526
const flush = () => {
2627
if (buffer) {
@@ -75,6 +76,48 @@ export function streamText(
7576
}
7677
}
7778
break
79+
80+
case 'response.content_part.added':
81+
case 'response.content_part.done':
82+
if (chunk.part?.type === 'output_text' && chunk.part.text) {
83+
buffer += chunk.part.text
84+
flush()
85+
}
86+
break
87+
88+
case 'response.reasoning.delta':
89+
if (typeof chunk.delta === 'string') {
90+
controller.enqueue(
91+
encoder.encode(
92+
`t:${JSON.stringify({
93+
type: 'reasoning',
94+
effort: chunk.effort,
95+
summary: chunk.delta,
96+
model: chunk.model,
97+
serviceTier: chunk.service_tier,
98+
temperature: chunk.temperature,
99+
topP: chunk.top_p,
100+
})}\n`,
101+
),
102+
)
103+
}
104+
break
105+
106+
case 'response.created':
107+
case 'response.in_progress':
108+
if (chunk.response?.reasoning) {
109+
controller.enqueue(
110+
encoder.encode(
111+
`t:${JSON.stringify({
112+
type: 'reasoning',
113+
effort: chunk.response.reasoning.effort,
114+
summary: chunk.response.reasoning.summary,
115+
})}\n`,
116+
),
117+
)
118+
}
119+
break
120+
78121
case 'response.mcp_call.failed':
79122
console.error('[TOOL CALL FAILED]', chunk)
80123

@@ -163,6 +206,31 @@ export function streamText(
163206
}
164207
break
165208

209+
case 'response.reasoning_summary_text.delta':
210+
if (typeof chunk.delta === 'string') {
211+
reasoningSummaryBuffer += chunk.delta
212+
}
213+
break
214+
215+
case 'response.reasoning_summary_text.done':
216+
if (reasoningSummaryBuffer) {
217+
controller.enqueue(
218+
encoder.encode(
219+
`t:${JSON.stringify({
220+
type: 'reasoning',
221+
effort: chunk.effort,
222+
summary: reasoningSummaryBuffer,
223+
model: chunk.model,
224+
serviceTier: chunk.service_tier,
225+
temperature: chunk.temperature,
226+
topP: chunk.top_p,
227+
})}\n`,
228+
),
229+
)
230+
reasoningSummaryBuffer = ''
231+
}
232+
break
233+
166234
default:
167235
break
168236
}

src/routes/api/chat.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ export const APIRoute = createAPIFileRoute('/api/chat')({
7979
tools,
8080
input,
8181
stream: true,
82+
...(model.startsWith('o3') || model.startsWith('o4')
83+
? {
84+
reasoning: {
85+
summary: 'detailed',
86+
},
87+
}
88+
: {}),
8289
})
8390

8491
return streamText(answer)

0 commit comments

Comments
 (0)