mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): optimize rendering with chat block reconciliation and component memoization
Implement block reconciliation to maintain object identity across renders when content hasn't changed, reducing unnecessary re-renders. Add memoization and performance optimizations to ToolCard component.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentEventBlock,
|
||||
AgentTextBlock,
|
||||
ChatBlock,
|
||||
ToolCallBlock,
|
||||
ToolPermission,
|
||||
UserTextBlock,
|
||||
} from '@/chat/types'
|
||||
|
||||
export type ChatBlocksById = Map<string, ChatBlock>
|
||||
|
||||
function indexBlocks(blocks: ChatBlock[], map: ChatBlocksById): void {
|
||||
for (const block of blocks) {
|
||||
map.set(block.id, block)
|
||||
if (block.kind === 'tool-call') {
|
||||
indexBlocks(block.children, map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function areStringArraysEqual(left?: string[] | null, right?: string[] | null): boolean {
|
||||
if (left === right) return true
|
||||
if (!left || !right) return false
|
||||
if (left.length !== right.length) return false
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areAnswersEqual(
|
||||
left?: Record<string, string[]> | null,
|
||||
right?: Record<string, string[]> | null
|
||||
): boolean {
|
||||
if (left === right) return true
|
||||
if (!left || !right) return false
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
if (leftKeys.length !== rightKeys.length) return false
|
||||
leftKeys.sort()
|
||||
rightKeys.sort()
|
||||
for (let i = 0; i < leftKeys.length; i += 1) {
|
||||
const leftKey = leftKeys[i]
|
||||
if (leftKey !== rightKeys[i]) return false
|
||||
if (!areStringArraysEqual(left[leftKey], right[leftKey])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function arePermissionsEqual(left?: ToolPermission, right?: ToolPermission): boolean {
|
||||
if (left === right) return true
|
||||
if (!left || !right) return false
|
||||
return left.id === right.id
|
||||
&& left.status === right.status
|
||||
&& left.reason === right.reason
|
||||
&& left.mode === right.mode
|
||||
&& left.decision === right.decision
|
||||
&& left.date === right.date
|
||||
&& left.createdAt === right.createdAt
|
||||
&& left.completedAt === right.completedAt
|
||||
&& areStringArraysEqual(left.allowedTools, right.allowedTools)
|
||||
&& areAnswersEqual(left.answers, right.answers)
|
||||
}
|
||||
|
||||
function getEventKey(event: AgentEvent): string {
|
||||
switch (event.type) {
|
||||
case 'switch':
|
||||
return `switch:${event.mode}`
|
||||
case 'message':
|
||||
return `message:${event.message}`
|
||||
case 'title-changed':
|
||||
return `title:${event.title}`
|
||||
case 'limit-reached':
|
||||
return `limit:${event.endsAt}`
|
||||
case 'ready':
|
||||
return 'ready'
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(event)
|
||||
} catch {
|
||||
return event.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function areAgentEventsEqual(left: AgentEvent, right: AgentEvent): boolean {
|
||||
if (left === right) return true
|
||||
return getEventKey(left) === getEventKey(right)
|
||||
}
|
||||
|
||||
function areUserTextBlocksEqual(left: UserTextBlock, right: UserTextBlock): boolean {
|
||||
return left.text === right.text
|
||||
&& left.status === right.status
|
||||
&& left.originalText === right.originalText
|
||||
&& left.localId === right.localId
|
||||
&& left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
}
|
||||
|
||||
function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): boolean {
|
||||
return left.text === right.text
|
||||
&& left.localId === right.localId
|
||||
&& left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
}
|
||||
|
||||
function areAgentEventBlocksEqual(left: AgentEventBlock, right: AgentEventBlock): boolean {
|
||||
return left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
&& areAgentEventsEqual(left.event, right.event)
|
||||
}
|
||||
|
||||
function areToolCallsEqual(left: ToolCallBlock, right: ToolCallBlock, childrenSame: boolean): boolean {
|
||||
if (!childrenSame) return false
|
||||
return left.localId === right.localId
|
||||
&& left.createdAt === right.createdAt
|
||||
&& left.meta === right.meta
|
||||
&& left.tool.id === right.tool.id
|
||||
&& left.tool.name === right.tool.name
|
||||
&& left.tool.state === right.tool.state
|
||||
&& left.tool.input === right.tool.input
|
||||
&& left.tool.result === right.tool.result
|
||||
&& left.tool.description === right.tool.description
|
||||
&& left.tool.createdAt === right.tool.createdAt
|
||||
&& left.tool.startedAt === right.tool.startedAt
|
||||
&& left.tool.completedAt === right.tool.completedAt
|
||||
&& arePermissionsEqual(left.tool.permission, right.tool.permission)
|
||||
}
|
||||
|
||||
function reconcileBlockList(blocks: ChatBlock[], prevById: ChatBlocksById): ChatBlock[] {
|
||||
let changed = false
|
||||
const reconciled = blocks.map((block) => {
|
||||
const next = reconcileBlock(block, prevById)
|
||||
if (next !== block) {
|
||||
changed = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
return changed ? reconciled : blocks
|
||||
}
|
||||
|
||||
function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock {
|
||||
const prev = prevById.get(block.id)
|
||||
|
||||
if (block.kind === 'tool-call') {
|
||||
const nextChildren = reconcileBlockList(block.children, prevById)
|
||||
const nextBlock = nextChildren === block.children
|
||||
? block
|
||||
: { ...block, children: nextChildren }
|
||||
|
||||
if (prev && prev.kind === 'tool-call') {
|
||||
const childrenSame = prev.children.length === nextChildren.length
|
||||
&& prev.children.every((child, idx) => child === nextChildren[idx])
|
||||
if (areToolCallsEqual(prev, nextBlock, childrenSame)) {
|
||||
return prev
|
||||
}
|
||||
}
|
||||
return nextBlock
|
||||
}
|
||||
|
||||
if (!prev || prev.kind !== block.kind) {
|
||||
return block
|
||||
}
|
||||
|
||||
if (block.kind === 'user-text') {
|
||||
const prevBlock = prev as UserTextBlock
|
||||
return areUserTextBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
if (block.kind === 'agent-text') {
|
||||
const prevBlock = prev as AgentTextBlock
|
||||
return areAgentTextBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
const prevBlock = prev as AgentEventBlock
|
||||
return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block
|
||||
}
|
||||
|
||||
export function reconcileChatBlocks(nextBlocks: ChatBlock[], prevById: ChatBlocksById): {
|
||||
blocks: ChatBlock[]
|
||||
byId: ChatBlocksById
|
||||
} {
|
||||
const blocks = reconcileBlockList(nextBlocks, prevById)
|
||||
const byId: ChatBlocksById = new Map()
|
||||
indexBlocks(blocks, byId)
|
||||
return { blocks, byId }
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
|
||||
import type { NormalizedMessage } from '@/chat/types'
|
||||
import type { ChatBlock, NormalizedMessage } from '@/chat/types'
|
||||
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
||||
import { reduceChatBlocks } from '@/chat/reducer'
|
||||
import { reconcileChatBlocks } from '@/chat/reconcile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||
@@ -30,9 +31,11 @@ export function SessionChat(props: {
|
||||
const { haptic } = usePlatform()
|
||||
const controlsDisabled = !props.session.active
|
||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
normalizedCacheRef.current.clear()
|
||||
blocksByIdRef.current.clear()
|
||||
}, [props.session.id])
|
||||
|
||||
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
||||
@@ -58,7 +61,18 @@ export function SessionChat(props: {
|
||||
return normalized
|
||||
}, [props.messages])
|
||||
|
||||
const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, props.session.agentState), [normalizedMessages, props.session.agentState])
|
||||
const reduced = useMemo(
|
||||
() => reduceChatBlocks(normalizedMessages, props.session.agentState),
|
||||
[normalizedMessages, props.session.agentState]
|
||||
)
|
||||
const reconciled = useMemo(
|
||||
() => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current),
|
||||
[reduced.blocks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
blocksByIdRef.current = reconciled.byId
|
||||
}, [reconciled.byId])
|
||||
|
||||
// Permission mode change handler
|
||||
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
|
||||
@@ -92,7 +106,7 @@ export function SessionChat(props: {
|
||||
|
||||
const runtime = useHappyRuntime({
|
||||
session: props.session,
|
||||
blocks: reduced.blocks,
|
||||
blocks: reconciled.blocks,
|
||||
isSending: props.isSending,
|
||||
onSendMessage: props.onSend,
|
||||
onAbort: handleAbort
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolCallBlock } from '@/chat/types'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { SessionMetadataSummary } from '@/types/api'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CodeBlock } from '@/components/CodeBlock'
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
|
||||
@@ -45,12 +45,14 @@ function truncate(text: string, maxLen: number): string {
|
||||
return text.slice(0, maxLen - 3) + '...'
|
||||
}
|
||||
|
||||
const ELAPSED_INTERVAL_MS = 1000
|
||||
|
||||
function ElapsedView(props: { from: number; active: boolean }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.active) return
|
||||
const id = setInterval(() => setNow(Date.now()), 250)
|
||||
const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [props.active])
|
||||
|
||||
@@ -96,7 +98,7 @@ function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) {
|
||||
return <span className="text-amber-600 animate-pulse">●</span>
|
||||
}
|
||||
|
||||
function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null {
|
||||
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
|
||||
if (block.tool.name !== 'Task') return null
|
||||
|
||||
const children = block.children
|
||||
@@ -106,7 +108,15 @@ function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummar
|
||||
if (children.length === 0) return null
|
||||
|
||||
const visible = children.slice(-3)
|
||||
const remaining = children.length - visible.length
|
||||
return { visible, remaining: children.length - visible.length }
|
||||
}
|
||||
|
||||
function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null {
|
||||
const summary = getTaskSummaryChildren(block)
|
||||
if (!summary) return null
|
||||
|
||||
const visible = summary.visible
|
||||
const remaining = summary.remaining
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
@@ -288,22 +298,31 @@ function DetailsIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCard(props: {
|
||||
type ToolCardProps = {
|
||||
api: ApiClient
|
||||
sessionId: string
|
||||
metadata: SessionMetadataSummary | null
|
||||
disabled: boolean
|
||||
onDone: () => void
|
||||
block: ToolCallBlock
|
||||
}) {
|
||||
const presentation = getToolPresentation({
|
||||
}
|
||||
|
||||
function ToolCardInner(props: ToolCardProps) {
|
||||
const presentation = useMemo(() => getToolPresentation({
|
||||
toolName: props.block.tool.name,
|
||||
input: props.block.tool.input,
|
||||
result: props.block.tool.result,
|
||||
childrenCount: props.block.children.length,
|
||||
description: props.block.tool.description,
|
||||
metadata: props.metadata
|
||||
})
|
||||
}), [
|
||||
props.block.tool.name,
|
||||
props.block.tool.input,
|
||||
props.block.tool.result,
|
||||
props.block.children.length,
|
||||
props.block.tool.description,
|
||||
props.metadata
|
||||
])
|
||||
|
||||
const toolName = props.block.tool.name
|
||||
const toolTitle = presentation.title
|
||||
@@ -435,3 +454,5 @@ export function ToolCard(props: {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToolCard = memo(ToolCardInner)
|
||||
|
||||
Reference in New Issue
Block a user