feat: add reasoning block support to chat system

Add comprehensive support for handling reasoning blocks throughout the chat pipeline, including normalization, reconciliation, type definitions, and UI rendering. Implements collapsible reasoning group component with auto-expand during streaming and shimmer indicator.
This commit is contained in:
weishu
2025-12-26 13:35:27 +08:00
parent 5d5023883e
commit 8122768a01
7 changed files with 171 additions and 2 deletions
+13 -1
View File
@@ -281,7 +281,7 @@ function normalizeAgentRecord(
const data = isObject(content.data) ? content.data : null
if (!data || typeof data.type !== 'string') return null
if ((data.type === 'message' || data.type === 'reasoning') && typeof data.message === 'string') {
if (data.type === 'message' && typeof data.message === 'string') {
return {
id: messageId,
localId,
@@ -293,6 +293,18 @@ function normalizeAgentRecord(
}
}
if (data.type === 'reasoning' && typeof data.message === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }],
meta
}
}
if (data.type === 'tool-call' && typeof data.callId === 'string') {
const uuid = asString(data.id) ?? messageId
return {
+13
View File
@@ -1,6 +1,7 @@
import type {
AgentEvent,
AgentEventBlock,
AgentReasoningBlock,
AgentTextBlock,
ChatBlock,
CliOutputBlock,
@@ -106,6 +107,13 @@ function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): b
&& left.meta === right.meta
}
function areAgentReasoningBlocksEqual(left: AgentReasoningBlock, right: AgentReasoningBlock): boolean {
return left.text === right.text
&& left.localId === right.localId
&& left.createdAt === right.createdAt
&& left.meta === right.meta
}
function areCliOutputBlocksEqual(left: CliOutputBlock, right: CliOutputBlock): boolean {
return left.text === right.text
&& left.localId === right.localId
@@ -187,6 +195,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock {
return areCliOutputBlocksEqual(prevBlock, block) ? prevBlock : block
}
if (block.kind === 'agent-reasoning') {
const prevBlock = prev as AgentReasoningBlock
return areAgentReasoningBlocksEqual(prevBlock, block) ? prevBlock : block
}
const prevBlock = prev as AgentEventBlock
return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block
}
+12
View File
@@ -420,6 +420,18 @@ function reduceTimeline(
continue
}
if (c.type === 'reasoning') {
blocks.push({
kind: 'agent-reasoning',
id: `${msg.id}:${idx}`,
localId: msg.localId,
createdAt: msg.createdAt,
text: c.text,
meta: msg.meta
})
continue
}
if (c.type === 'summary') {
blocks.push({
kind: 'agent-event',
+16 -1
View File
@@ -51,6 +51,12 @@ export type NormalizedAgentContent =
uuid: string
parentUUID: string | null
}
| {
type: 'reasoning'
text: string
uuid: string
parentUUID: string | null
}
| ToolUse
| ToolResult
| { type: 'summary'; summary: string }
@@ -122,6 +128,15 @@ export type AgentTextBlock = {
meta?: unknown
}
export type AgentReasoningBlock = {
kind: 'agent-reasoning'
id: string
localId: string | null
createdAt: number
text: string
meta?: unknown
}
export type CliOutputBlock = {
kind: 'cli-output'
id: string
@@ -150,4 +165,4 @@ export type ToolCallBlock = {
meta?: unknown
}
export type ChatBlock = UserTextBlock | AgentTextBlock | CliOutputBlock | ToolCallBlock | AgentEventBlock
export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock | AgentEventBlock
@@ -1,5 +1,6 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage'
import { CliOutputBlock } from '@/components/CliOutputBlock'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
@@ -10,6 +11,8 @@ const TOOL_COMPONENTS = {
const MESSAGE_PART_COMPONENTS = {
Text: MarkdownText,
Reasoning: Reasoning,
ReasoningGroup: ReasoningGroup,
tools: TOOL_COMPONENTS
} as const
@@ -0,0 +1,101 @@
import { useState, useEffect, type FC, type PropsWithChildren } from 'react'
import { useMessage } from '@assistant-ui/react'
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
import { cn } from '@/lib/utils'
import { defaultComponents, MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text'
function ChevronIcon(props: { className?: string; open?: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'transition-transform duration-200',
props.open ? 'rotate-90' : '',
props.className
)}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function ShimmerDot() {
return (
<span className="inline-block w-1.5 h-1.5 bg-current rounded-full animate-pulse" />
)
}
/**
* Renders individual reasoning message part content with markdown support.
*/
export const Reasoning: FC = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS}
components={defaultComponents}
className={cn('aui-reasoning-content min-w-0 max-w-full break-words text-sm text-[var(--app-hint)]')}
/>
)
}
/**
* Wraps consecutive reasoning parts in a collapsible container.
* Shows shimmer effect while reasoning is streaming.
*/
export const ReasoningGroup: FC<PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false)
// Check if reasoning is still streaming
const message = useMessage()
const isStreaming = message.status?.type === 'running'
&& message.content.length > 0
&& message.content[message.content.length - 1]?.type === 'reasoning'
// Auto-expand while streaming
useEffect(() => {
if (isStreaming) {
setIsOpen(true)
}
}, [isStreaming])
return (
<div className="aui-reasoning-group my-2">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-1.5 text-xs font-medium',
'text-[var(--app-hint)] hover:text-[var(--app-fg)]',
'transition-colors cursor-pointer select-none'
)}
>
<ChevronIcon open={isOpen} />
<span>Reasoning</span>
{isStreaming && (
<span className="flex items-center gap-1 ml-1 text-[var(--app-hint)]">
<ShimmerDot />
</span>
)}
</button>
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-in-out',
isOpen ? 'max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className="pl-4 pt-2 border-l-2 border-[var(--app-border)] ml-0.5">
{children}
</div>
</div>
</div>
)
}
+13
View File
@@ -58,6 +58,19 @@ function toThreadMessageLike(block: ChatBlock): ThreadMessageLike {
}
}
if (block.kind === 'agent-reasoning') {
const messageId = `assistant:${block.id}`
return {
role: 'assistant',
id: messageId,
createdAt: new Date(block.createdAt),
content: [{ type: 'reasoning', text: block.text }],
metadata: {
custom: { kind: 'assistant' } satisfies HappyChatMessageMetadata
}
}
}
if (block.kind === 'agent-event') {
const messageId = `event:${block.id}`
return {