diff --git a/libs/room/src/service/ai_streaming.rs b/libs/room/src/service/ai_streaming.rs index 76931cf..738fe0a 100644 --- a/libs/room/src/service/ai_streaming.rs +++ b/libs/room/src/service/ai_streaming.rs @@ -43,6 +43,7 @@ pub async fn process_message_ai_streaming( let initial_event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id, + seq: 0, content: String::new(), done: false, error: None, @@ -81,9 +82,11 @@ pub async fn process_message_ai_streaming( agent::chat::AiChunkType::ToolCall => "tool_call", agent::chat::AiChunkType::ToolResult => "tool_result", }; + let seq = chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id, + seq, content: chunk.content, done: chunk.done, error: None, @@ -91,7 +94,6 @@ pub async fn process_message_ai_streaming( chunk_type: Some(chunk_type_str.to_string()), }; room_manager.broadcast_stream_chunk(event).await; - chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } }) as Pin + Send>> }; @@ -263,6 +265,7 @@ pub async fn process_message_ai_streaming( let event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id: room_id_inner, + seq: 0, content: String::new(), done: true, error: Some(e.to_string()), diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 54d9af1..134fbee 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -48,7 +48,7 @@ function parseSavedChunks(raw: string | null | undefined): Array<{ type: string; return null; } -/** Render ordered stream chunks: consecutive thinking tokens are merged into one collapsible block, answer tokens rendered inline. tool_call is hidden. */ +/** Render ordered stream chunks: consecutive thinking tokens are merged into one collapsible block, answer tokens rendered inline. tool_call and tool_result hidden from UI. */ function OrderedStreamChunks({ chunks, onMentionClick, @@ -59,10 +59,10 @@ function OrderedStreamChunks({ /** Show blinking cursor — only during active streaming */ showCursor?: boolean; }) { - // Group consecutive same-type chunks (tool_call hidden) + // Group consecutive same-type chunks (tool_call/tool_result hidden) const groups: Array<{ type: 'thinking' | 'answer'; content: string }> = []; for (const chunk of chunks) { - if (chunk.type === 'tool_call') continue; + if (chunk.type === 'tool_call' || chunk.type === 'tool_result') continue; const cType = chunk.type === 'thinking' ? 'thinking' : 'answer'; const last = groups[groups.length - 1]; if (last && last.type === cType) { @@ -123,7 +123,7 @@ function ThinkingBlock({ content }: { content: string }) { ); } -// Sender colors — AI Studio clean palette +/** Sender colors — AI Studio clean palette */ const SENDER_COLORS: Record = { system: '#9ca3af', ai: '#1c7ded', diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index 3035a12..418575e 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -575,7 +575,7 @@ export function RoomProvider({ ]); } }, - onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => { + onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; seq?: number; content: string; display_name?: string; chunk_type?: string }) => { console.debug('[RoomContext] onAiStreamChunk', chunk.chunk_type, chunk.done ? '(done)' : '', 'msg:', chunk.message_id); const isToolCall = chunk.chunk_type === 'tool_call' || chunk.chunk_type === 'tool_result'; @@ -622,11 +622,17 @@ export function RoomProvider({ setActiveAiStream({ message_id: chunk.message_id, display_name: chunk.display_name }); } - // Append chunk to ordered list — preserves think/answer/tool interleaving. + // Insert chunk by seq for strict ordering — even if WS delivers out of order. setStreamingChunks((prev) => { const next = new Map(prev); - const existing = next.get(chunk.message_id) ?? []; - const newChunks = [...existing, { type: chunk.chunk_type ?? 'answer', content: chunk.content }]; + type Chunk = { type: string; content: string; seq?: number }; + const existing: Chunk[] = next.get(chunk.message_id) ?? []; + const seq = chunk.seq ?? existing.length; + const newChunk: Chunk = { type: chunk.chunk_type ?? 'answer', content: chunk.content, seq }; + const insertIdx = existing.findIndex(c => c.seq != null && c.seq > seq); + const newChunks: Chunk[] = insertIdx === -1 + ? [...existing, newChunk] + : [...existing.slice(0, insertIdx), newChunk, ...existing.slice(insertIdx)]; next.set(chunk.message_id, newChunks); // Sync ref for done handler access streamingChunksRef.current = new Map(next);