From 8122768a014978d34d44cc039ec9f6592e27a9a0 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 26 Dec 2025 13:30:47 +0800 Subject: [PATCH] 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. --- web/src/chat/normalize.ts | 14 ++- web/src/chat/reconcile.ts | 13 +++ web/src/chat/reducer.ts | 12 +++ web/src/chat/types.ts | 17 ++- .../messages/AssistantMessage.tsx | 3 + web/src/components/assistant-ui/reasoning.tsx | 101 ++++++++++++++++++ web/src/lib/assistant-runtime.ts | 13 +++ 7 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 web/src/components/assistant-ui/reasoning.tsx diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts index e3f2d863..f661fc04 100644 --- a/web/src/chat/normalize.ts +++ b/web/src/chat/normalize.ts @@ -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 { diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 9e9b37ea..bab31926 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -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 } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 83650786..66b5574b 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -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', diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index fe06e2af..6f0472b9 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -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 diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 13d76dfe..0f73893a 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -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 diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx new file mode 100644 index 00000000..25ebb8a0 --- /dev/null +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -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 ( + + + + ) +} + +function ShimmerDot() { + return ( + + ) +} + +/** + * Renders individual reasoning message part content with markdown support. + */ +export const Reasoning: FC = () => { + return ( + + ) +} + +/** + * Wraps consecutive reasoning parts in a collapsible container. + * Shows shimmer effect while reasoning is streaming. + */ +export const ReasoningGroup: FC = ({ 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 ( +
+ + +
+
+ {children} +
+
+
+ ) +} diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index e42327ec..1d78c7dd 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -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 {