Chat UI Patterns
// React Chat Component
function ChatUI() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const handleSend = async () => {
// Add user message
setMessages(prev => [...prev, { role: 'user', content: input }]);
// Stream response
const response = await fetch('/api/query', {
method: 'POST',
body: JSON.stringify({ query: input }),
headers: { 'Content-Type': 'application/json' }
});
const reader = response.body.getReader();
let assistantMessage = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantMessage += new TextDecoder().decode(value);
setMessages(prev => [
...prev.slice(0, -1),
{ role: 'assistant', content: assistantMessage }
]);
}
};
return (
<div className="chat">
{messages.map((msg, i) => (
<div key={i} className={msg.role}>
{msg.content}
</div>
))}
<input onChange={e => setInput(e.target.value)} />
<button onClick={handleSend}>Send</button>
</div>
);
}
Streaming Responses
Server-Sent Events (SSE): Stream text chunks as they generate.
// Server (Python)
@app.post("/api/query")
async def stream_query(query: str):
async def generate():
# Retrieve docs
docs = await retrieve(query)
# Stream LLM response
async for chunk in llm.stream(query, docs):
yield f"data: {json.dumps({'text': chunk})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
// Client (JS)
const eventSource = new EventSource('/api/query?q=question');
eventSource.onmessage = (event) => {
const { text } = JSON.parse(event.data);
document.getElementById('response').textContent += text;
};
Benefit: User sees answer immediately, doesn't wait for full LLM response.
Source Attribution & Trust
// Show retrieved sources
const response = {
answer: "Self-employed people must file quarterly...",
sources: [
{
title: "Tax Guide 2024",
url: "https://...",
excerpt: "Self-employed people must...",
relevance: 0.95
},
{
title: "IRS FAQ",
url: "https://...",
excerpt: "Estimated taxes are...",
relevance: 0.82
}
],
confidence: 0.92
};
// UI
<div className="sources">
<h3>Sources ({response.sources.length})</h3>
{response.sources.map(src => (
<a key={src.title} href={src.url}>
{src.title}
<span className="relevance">{Math.round(src.relevance * 100)}%</span>
</a>
))}
<span className="confidence">Answer confidence: {Math.round(response.confidence * 100)}%</span>
</div>
Trust building: Show where information came from, confidence score, relevance percentages.
Feedback Loop
// Feedback buttons
<div className="feedback">
<button onClick={() => submitFeedback('helpful')}>👍 Helpful</button>
<button onClick={() => submitFeedback('not_helpful')}>👎 Not helpful</button>
<textarea placeholder="Comments..." />
</div>
// Server logs feedback for improvement
async def submit_feedback(query_id, feedback, comments):
db.insert({
"query_id": query_id,
"feedback": feedback,
"comments": comments,
"timestamp": time.time()
})
# Use feedback to retrain/improve RAG
Use feedback to: identify bad answers, retrain embeddings, improve prompts.
Notes
SSE is almost always the right streaming choice
Server-Sent Events work over plain HTTP/1.1, survive proxy re-buffering (with X-Accel-Buffering: no), and reconnect automatically. WebSockets add bidirectional complexity that RAG streaming doesn't need — you're always pushing tokens from server to client. Use WebSockets only if you need low-latency user interruptions (e.g., a stop-generation button that halts the LLM call server-side).
Send source citations as a separate event, not inline
Mixing citation metadata into the streamed token stream forces the client to parse structured data out of natural-language text — fragile and slow. Instead, stream the answer tokens first, then emit a final sources SSE event with a JSON array of {title, url, snippet} objects. The UI renders them only after the answer is complete, matching user expectations.
Loading states prevent users from re-submitting
RAG queries take 1–4 seconds end-to-end. Without explicit loading feedback, users click the submit button again, triggering duplicate requests that can cause rate-limit errors or doubled responses. Disable the submit button immediately on click, show a skeleton or spinner, and re-enable only after the full response (including sources) arrives.
Persist user feedback before the page unloads
Thumbs-up/down feedback submitted just before navigation (back button, new tab) can be silently lost if the POST request is cancelled. Use navigator.sendBeacon() for feedback submissions — it queues the payload to send even as the page is unloading, ensuring no evaluation signal is dropped.
RAG Frontend Integration FAQ
How do I stream RAG responses to the browser?
Use server-sent events (SSE) or WebSockets. The backend streams LLM output tokens as they arrive; the frontend appends each token to the UI. FastAPI and Django Channels both support streaming HTTP responses.
How do I display RAG source citations in a UI?
Return retrieved chunk metadata (source URL, page number, document title) alongside the answer. Render them as footnote links or a collapsible "Sources" panel. Let users click through to verify the answer.
How do I build a feedback loop for RAG quality improvement?
Add thumbs-up/thumbs-down buttons after each answer. Log the query, retrieved chunks, answer, and user vote to a database. Use negative examples to identify retrieval failures and retune your chunking or embedding strategy.
Should I show retrieved chunks to users in a RAG UI?
Showing highlighted excerpts from source documents significantly increases user trust. Implement a "See sources" toggle that reveals the top-3 retrieved chunks with the relevant passage highlighted.
What frontend frameworks work well for RAG chatbots?
React with Vercel AI SDK (useChat hook) or Next.js with streaming support are the most common choices. For internal tools, Streamlit and Gradio let you build a working RAG UI in under 50 lines of Python.