From 0939aa240baea335e5e406635f50ac9ac392c03a Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sun, 26 Apr 2026 13:10:51 +0800 Subject: [PATCH] fix(frontend): ordered chunk rendering + initial scroll-to-bottom - OrderedStreamChunks renders think/answer interleaved per arrival order - parseSavedChunks parses stored __chunks__ JSON on page refresh - Tool call chunks hidden from frontend display - Fix streaming join('') instead of join('\n') to avoid per-token newlines - Fix MessageList scroll-to-bottom using virtualizer.scrollToIndex - Remove unused streamingContent/streamingThinkingContent state - Add retryable error patterns for HTTP connection issues --- src/components/room/message/MessageBubble.tsx | 210 +++++++++++++----- src/components/room/message/MessageList.tsx | 64 +++--- src/contexts/room-context.tsx | 160 +++++-------- 3 files changed, 246 insertions(+), 188 deletions(-) diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 3b023c7..5d4f450 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -19,6 +19,107 @@ import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender' import { MessageReactions } from './MessageReactions'; import { ReactionPicker } from './ReactionPicker'; +/** Parse thinking text from stored thinking_content (may be __chunks__ JSON or plain text). */ +function parseThinkingText(raw: string): string { + if (!raw) return ''; + try { + const parsed = JSON.parse(raw) as { __chunks__?: Array<{ type: string; content: string }> }; + if (parsed.__chunks__) { + return parsed.__chunks__ + .filter((c) => c.type === 'thinking') + .map((c) => c.content) + .join(''); + } + } catch { + // Not JSON — plain text, use as-is + } + return raw; +} + +/** Parse ordered chunks from stored thinking_content JSON. Returns null if not in __chunks__ format. */ +function parseSavedChunks(raw: string | null | undefined): Array<{ type: string; content: string }> | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { __chunks__?: Array<{ type: string; content: string }> }; + if (parsed.__chunks__) return parsed.__chunks__; + } catch { + // Not JSON — legacy plain text + } + return null; +} + +/** Render ordered stream chunks: consecutive thinking tokens are merged into one collapsible block, answer tokens rendered inline. tool_call is hidden. */ +function OrderedStreamChunks({ + chunks, + onMentionClick, +}: { + chunks: Array<{ type: string; content: string }>; + onMentionClick?: (type: string, id: string, label: string) => void; +}) { + // Group consecutive same-type chunks (tool_call hidden) + const groups: Array<{ type: 'thinking' | 'answer'; content: string }> = []; + for (const chunk of chunks) { + if (chunk.type === 'tool_call') continue; + const cType = chunk.type === 'thinking' ? 'thinking' : 'answer'; + const last = groups[groups.length - 1]; + if (last && last.type === cType) { + last.content += chunk.content; + } else { + groups.push({ type: cType, content: chunk.content }); + } + } + + return ( + <> + {groups.map((group, i) => + group.type === 'thinking' ? ( + + ) : ( + + ), + )} + {/* Streaming cursor */} + + + ); +} + +/** Collapsible thinking block with auto-expand. */ +function ThinkingBlock({ content }: { content: string }) { + const [expanded, setExpanded] = useState(false); + return ( +
+ + {expanded && ( +
+ {content} +
+ )} +
+ ); +} + // Sender colors — AI Studio clean palette const SENDER_COLORS: Record = { system: '#9ca3af', @@ -81,7 +182,7 @@ export const MessageBubble = memo(function MessageBubble({ const isEdited = !!message.edited_at; useTheme(); const { user } = useUser(); - const { wsClient, streamingMessages, streamingThinkingContent, members, pins, pinMessage, unpinMessage } = useRoom(); + const { wsClient, streamingChunks, members, pins, pinMessage, unpinMessage } = useRoom(); const avatarUrl = (() => { if (message.sender_type === 'ai') return undefined; const member = members.find(m => m.user === message.sender_id); @@ -93,15 +194,10 @@ export const MessageBubble = memo(function MessageBubble({ const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-'); const isPinned = pins.some(p => p.message === message.id); - const displayContent = isStreaming && streamingMessages?.has(message.id) - ? streamingMessages.get(message.id)! - : message.content; - - // Thinking/reasoning content: from streamingThinkingContent while live, or stored thinking_content on message - const thinkingContent = isStreaming && streamingThinkingContent?.has(message.id) - ? streamingThinkingContent.get(message.id)! - : (message.thinking_content ?? ''); const [thinkingExpanded, setThinkingExpanded] = useState(false); + const thinkingContent = isStreaming && streamingChunks?.has(message.id) + ? streamingChunks.get(message.id)!.filter(c => c.type === 'thinking').map(c => c.content).join('') + : parseThinkingText(message.thinking_content ?? ''); const handleMentionClick = useCallback( (type: string, id: string, label: string) => { @@ -138,7 +234,7 @@ export const MessageBubble = memo(function MessageBubble({ } }, [roomId, message.id, wsClient]); - const textContent = displayContent; + const textContent = message.content; const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => { return total + Math.max(1, Math.ceil(line.trim().length / 90)); }, 0); @@ -312,51 +408,63 @@ export const MessageBubble = memo(function MessageBubble({
{message.content_type === 'text' || message.content_type === 'Text' ? (
- {/* Thinking/reasoning section — collapsible, DeepSeek-style */} - {thinkingContent && ( -
- - {thinkingExpanded && ( -
- {thinkingContent} -
- )} -
- )} - {/* Answer content — always visible */} - {displayContent && ( -