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 {
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<Box<dyn std::future::Future<Output = ()> + 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()),

View File

@ -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<string, string> = {
system: '#9ca3af',
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);
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);