mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user