The /stream-query endpoint previously emitted only tool-lifecycle events plus one final complete message, so the answer still appeared all at once. Handle on_chat_model_stream events to emit incremental `content` deltas, accumulate them for persistence fallback, and consume them on the frontend to build a live assistant bubble (reconciled with the authoritative final text). Hide the redundant loading indicator once tokens start arriving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.6 KiB
Streaming Implementation Summary
Overview
The /stream-query endpoint uses Server-Sent Events (SSE) to give real-time feedback
during AI query processing: tool-execution status and token-by-token streaming of the
assistant's answer.
Backend (blueprints/conversation/__init__.py)
POST /api/conversation/stream-query
Streams SSE frames (data: {json}\n\n) by iterating main_agent.astream_events(..., version="v2").
Event types emitted to the client:
tool_start— a tool began ({ "type": "tool_start", "tool": <name> }), fromon_tool_start.tool_end— a tool finished, fromon_tool_end.content— an incremental answer chunk ({ "type": "content", "delta": <text> }), fromon_chat_model_stream. Chunks are accumulated server-side intostreamed.response— the final, authoritative message ({ "type": "response", "message": <full text> }). Sourced fromon_chain_end(falling back to the accumulatedstreamedtokens). This is the text that is persisted to the DB viaadd_message_to_conversation(..., speaker="simba").error— an error message.- The stream terminates with
data: [DONE].
The user's message is persisted before streaming begins; the assistant message is persisted once the stream completes.
POST /api/conversation/query (legacy, non-streaming)
Backwards-compatible endpoint that returns the complete response in one JSON payload
({ "response": <text> }). Kept for compatibility; the frontend uses /stream-query.
Frontend
API service (src/api/conversationService.ts)
streamQuery(query, conversation_id, onEvent, signal?, imageKey?)— POSTs to/stream-queryand drives the response through_readSSEStream()._readSSEStream()— reads theReadableStream, splits on\n\n, parsesdata:frames intoSSEEventobjects, and stops on[DONE].SSEEventunion:tool_start | tool_end | content | response | error.
Chat state (src/hooks/useChat.ts)
sendMessage() consumes the stream and maintains a unified messages array:
tool_start→ appends atoolbubble (mapped to a friendly label viaTOOL_MESSAGES) and resets the streaming pointer so any text after a tool call starts a fresh bubble.content→ on the first delta, appends a newsimbabubble and flipsstreamingActiveRef/streaming; subsequent deltas append to that bubble's text.response→ overwrites the livesimbabubble with the authoritative message (guaranteeing parity with what was persisted), or appends it if nothing streamed (e.g. an instant answer).error→ surfaces a user-facing error.
A streaming boolean is exposed so the UI can hide the redundant loading indicator once tokens
start arriving.
Components
ChatScreen.tsx— rendersmessages(ToolBubble/AnswerBubble/QuestionBubble) and shows the standalone loading-dotsAnswerBubbleonly before the first token ((isLoading && !streaming) || messagesLoading).AnswerBubble.tsx— renders the assistant message as markdown, or animated loading dots whenloadingis set.ToolBubble.tsx— renders tool-status messages as inline badges.
User Experience
- User sends a message.
- Loading dots appear.
- Tool status bubbles appear as tools run (e.g. "Searching Simba's records...").
- The answer streams in word-by-word into a single assistant bubble.
- On completion the bubble is reconciled with the persisted final text.
Testing
# Backend
make test # or: .venv/bin/python -m pytest tests/
# Frontend
cd raggr-frontend && yarn build
# Full stack (manual)
docker compose up --build