fix(streaming): add seq field for strict chunk ordering

- Add seq: u64 to RoomMessageStreamChunkEvent
- Frontend sorts by seq on insert for ordered replay
- Initial event now includes seq: 0
This commit is contained in:
ZhenYi 2026-04-28 09:42:41 +08:00
parent 5b3a6700be
commit ddd24bfb6d
3 changed files with 18 additions and 9 deletions

View File

@ -43,6 +43,7 @@ pub async fn process_message_ai_streaming(
let initial_event = RoomMessageStreamChunkEvent { let initial_event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id, message_id: streaming_msg_id,
room_id, room_id,
seq: 0,
content: String::new(), content: String::new(),
done: false, done: false,
error: None, error: None,
@ -81,9 +82,11 @@ pub async fn process_message_ai_streaming(
agent::chat::AiChunkType::ToolCall => "tool_call", agent::chat::AiChunkType::ToolCall => "tool_call",
agent::chat::AiChunkType::ToolResult => "tool_result", agent::chat::AiChunkType::ToolResult => "tool_result",
}; };
let seq = chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let event = RoomMessageStreamChunkEvent { let event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id, message_id: streaming_msg_id,
room_id, room_id,
seq,
content: chunk.content, content: chunk.content,
done: chunk.done, done: chunk.done,
error: None, error: None,
@ -91,7 +94,6 @@ pub async fn process_message_ai_streaming(
chunk_type: Some(chunk_type_str.to_string()), chunk_type: Some(chunk_type_str.to_string()),
}; };
room_manager.broadcast_stream_chunk(event).await; room_manager.broadcast_stream_chunk(event).await;
chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
} }
}) as Pin<Box<dyn std::future::Future<Output = ()> + Send>> }) as Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}; };
@ -263,6 +265,7 @@ pub async fn process_message_ai_streaming(
let event = RoomMessageStreamChunkEvent { let event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id, message_id: streaming_msg_id,
room_id: room_id_inner, room_id: room_id_inner,
seq: 0,
content: String::new(), content: String::new(),
done: true, done: true,
error: Some(e.to_string()), error: Some(e.to_string()),

View File

@ -48,7 +48,7 @@ function parseSavedChunks(raw: string | null | undefined): Array<{ type: string;
return null; 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({ function OrderedStreamChunks({
chunks, chunks,
onMentionClick, onMentionClick,
@ -59,10 +59,10 @@ function OrderedStreamChunks({
/** Show blinking cursor — only during active streaming */ /** Show blinking cursor — only during active streaming */
showCursor?: boolean; 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 }> = []; const groups: Array<{ type: 'thinking' | 'answer'; content: string }> = [];
for (const chunk of chunks) { 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 cType = chunk.type === 'thinking' ? 'thinking' : 'answer';
const last = groups[groups.length - 1]; const last = groups[groups.length - 1];
if (last && last.type === cType) { 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<string, string> = { const SENDER_COLORS: Record<string, string> = {
system: '#9ca3af', system: '#9ca3af',
ai: '#1c7ded', ai: '#1c7ded',

View File

@ -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); 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'; 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 }); 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) => { setStreamingChunks((prev) => {
const next = new Map(prev); const next = new Map(prev);
const existing = next.get(chunk.message_id) ?? []; type Chunk = { type: string; content: string; seq?: number };
const newChunks = [...existing, { type: chunk.chunk_type ?? 'answer', content: chunk.content }]; 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); next.set(chunk.message_id, newChunks);
// Sync ref for done handler access // Sync ref for done handler access
streamingChunksRef.current = new Map(next); streamingChunksRef.current = new Map(next);