benroshan commited on
Commit
e0662b3
Β·
1 Parent(s): 76f2b0b

docs: streaming responses design spec

Browse files
docs/superpowers/specs/2026-06-24-streaming-responses-design.md ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Streaming Responses β€” Design Spec
2
+ **Date:** 2026-06-24
3
+ **Status:** Approved
4
+
5
+ ---
6
+
7
+ ## Problem
8
+
9
+ `POST /api/chat` blocks 8–15s before returning a full JSON response. Users see a blank spinner. Streaming tokens as they arrive reduces perceived latency to ~1s first token.
10
+
11
+ ---
12
+
13
+ ## Approach
14
+
15
+ SSE (Server-Sent Events) over `POST /api/chat`. Backend returns `StreamingResponse(text/event-stream)`. Frontend uses `fetch` + `ReadableStream` (not `EventSource` β€” EventSource doesn't support POST bodies).
16
+
17
+ Answer tokens stream first. Sources + metadata arrive in a final `done` event after answer completes.
18
+
19
+ ---
20
+
21
+ ## SSE Event Format
22
+
23
+ ```
24
+ data: {"type":"token","content":"The "}
25
+
26
+ data: {"type":"token","content":"answer is..."}
27
+
28
+ data: {"type":"done","sources":[...],"retrieval_method":"hybrid+rerank+web"}
29
+
30
+ data: {"type":"error","message":"LLM call failed"}
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Data Flow
36
+
37
+ ```
38
+ POST /api/chat
39
+ β”‚
40
+ β”œβ”€ condense_question() ← sync, ~200ms, existing fn
41
+ β”œβ”€ search_web() ← sync, ~300ms, existing fn
42
+ β”œβ”€ retriever.invoke() ← sync, ~100ms, inside stream_query_with_web
43
+ β”‚
44
+ └─ stream_query_with_web() ← async generator
45
+ β”‚
46
+ β”œβ”€ llm.astream(messages) β†’ yield token events
47
+ β”œβ”€ collect full answer
48
+ β”œβ”€ memory.save_context()
49
+ β”œβ”€ eval_log.append()
50
+ └─ yield done event with sources
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Backend Changes
56
+
57
+ ### `server/chain.py`
58
+ Add `stream_query_with_web()` as an async generator alongside existing `run_query_with_web()`.
59
+
60
+ ```python
61
+ async def stream_query_with_web(
62
+ retriever, memory, question: str, web_sources: list[dict]
63
+ ) -> AsyncGenerator[dict, None]:
64
+ # 1. RAG retrieval (same as run_query_with_web)
65
+ # 2. Build messages (same as run_query_with_web)
66
+ # 3. async for chunk in llm.astream(messages): yield {"type":"token","content":chunk.content}
67
+ # 4. After loop: memory.save_context(), yield {"type":"done","sources":rag_docs}
68
+ ```
69
+
70
+ - `run_query_with_web` kept unchanged (used by eval script).
71
+ - Memory saved after full answer assembled (not mid-stream).
72
+ - On exception: yield `{"type":"error","message":str(e)}`.
73
+
74
+ ### `server/routes/chat.py`
75
+ Replace `return {...}` with `StreamingResponse`.
76
+
77
+ ```python
78
+ async def generate():
79
+ # condense + search (sync, before streaming starts)
80
+ # call stream_query_with_web
81
+ # for each event: yield f"data: {json.dumps(event)}\n\n"
82
+ # on done event: augment with web_sources, retrieval_method, citation_index
83
+
84
+ return StreamingResponse(generate(), media_type="text/event-stream")
85
+ ```
86
+
87
+ - No-docs case: yield error event instead of raising `HTTPException` (HTTPException inside a StreamingResponse generator is swallowed).
88
+ - `gc.collect()` + `log_memory_mb()` calls moved to after generator completes (inside `generate()` finally block).
89
+
90
+ ---
91
+
92
+ ## Frontend Changes
93
+
94
+ ### `frontend/src/api.js`
95
+ Add `streamChat(question, workspace, {onToken, onDone, onError})` function.
96
+
97
+ ```javascript
98
+ async function streamChat(question, workspace, { onToken, onDone, onError }) {
99
+ const response = await fetch(`${API_BASE}/api/chat?workspace=${workspace}`, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ question }),
103
+ });
104
+ const reader = response.body.getReader();
105
+ const decoder = new TextDecoder();
106
+ let buffer = '';
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+ buffer += decoder.decode(value, { stream: true });
111
+ const lines = buffer.split('\n');
112
+ buffer = lines.pop();
113
+ for (const line of lines) {
114
+ if (!line.startsWith('data: ')) continue;
115
+ const event = JSON.parse(line.slice(6));
116
+ if (event.type === 'token') onToken(event.content);
117
+ else if (event.type === 'done') onDone(event);
118
+ else if (event.type === 'error') onError(event.message);
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ Old `sendMessage` function removed or replaced.
125
+
126
+ ### `frontend/src/components/ChatArea.jsx`
127
+ On submit:
128
+ 1. Add user message to state immediately.
129
+ 2. Add empty assistant message with `loading: true`.
130
+ 3. Call `streamChat()`:
131
+ - `onToken`: append content to that message via `setMessages`.
132
+ - `onDone`: set sources, retrieval_method, loading=false.
133
+ - `onError`: set error text, loading=false.
134
+
135
+ ```javascript
136
+ const msgId = crypto.randomUUID();
137
+ setMessages(prev => [...prev,
138
+ { role: 'user', content: question },
139
+ { id: msgId, role: 'assistant', content: '', sources: [], loading: true }
140
+ ]);
141
+
142
+ await streamChat(question, workspace, {
143
+ onToken: (t) => setMessages(prev => prev.map(m =>
144
+ m.id === msgId ? { ...m, content: m.content + t } : m
145
+ )),
146
+ onDone: (evt) => setMessages(prev => prev.map(m =>
147
+ m.id === msgId ? { ...m, sources: evt.sources, retrieval_method: evt.retrieval_method, loading: false } : m
148
+ )),
149
+ onError: (msg) => setMessages(prev => prev.map(m =>
150
+ m.id === msgId ? { ...m, content: msg || 'Error generating response.', loading: false } : m
151
+ )),
152
+ });
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Files Changed
158
+
159
+ | File | Change |
160
+ |------|--------|
161
+ | `server/chain.py` | Add `stream_query_with_web()` async generator |
162
+ | `server/routes/chat.py` | Return `StreamingResponse`, move gc/log to finally block |
163
+ | `frontend/src/api.js` | Add `streamChat()`, remove old `sendMessage` |
164
+ | `frontend/src/components/ChatArea.jsx` | Stream tokens into message state |
165
+
166
+ ---
167
+
168
+ ## Edge Cases
169
+
170
+ | Case | Handling |
171
+ |------|----------|
172
+ | No documents uploaded | Yield error event (HTTPException can't be raised inside StreamingResponse generator) |
173
+ | LLM error mid-stream | try/except in generator β†’ yield error event, partial answer shown |
174
+ | User navigates away | Reader cancelled, generator stops, memory not saved (acceptable) |
175
+ | Empty token chunks | `if not chunk.content: continue` guard in stream loop |
176
+
177
+ ---
178
+
179
+ ## What Stays Unchanged
180
+
181
+ - `run_query_with_web()` in chain.py β€” kept for eval script compatibility
182
+ - `condense_question()` β€” unchanged, called sync before streaming
183
+ - Memory architecture β€” `save_context()` still called, just deferred to post-stream
184
+ - Source scoring (similarity, BM25, RRF, rerank) β€” unchanged, arrives in `done` event
185
+ - `DELETE /api/chat/memory` β€” unchanged