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:
parent
5b3a6700be
commit
ddd24bfb6d
@ -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()),
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user