Skip to content

Commit b35bda8

Browse files
committed
feat: stream intermediate agent reasoning and token-by-token final answers
1 parent bf9d1db commit b35bda8

4 files changed

Lines changed: 108 additions & 43 deletions

File tree

backend/app/rag/agent.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,10 @@ def generate_answer_stream(
233233

234234
for step in executor.stream({"input": question, "chat_history": formatted_history}):
235235
if "actions" in step:
236-
continue
236+
for action in step["actions"]:
237+
log_content = getattr(action, "log", "")
238+
if log_content:
239+
yield f"data: {json.dumps({'type': 'thought', 'data': log_content.strip()})}\n\n"
237240

238241
elif "intermediate_steps" in step:
239242
if not sources_sent and getattr(pdf_tool, "last_sources", []):
@@ -251,14 +254,27 @@ def generate_answer_stream(
251254
yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
252255
sources_sent = True
253256

257+
for agent_step in step["intermediate_steps"]:
258+
if isinstance(agent_step, tuple) and len(agent_step) >= 2:
259+
observation = agent_step[1]
260+
obs_str = str(observation)
261+
if len(obs_str) > 1000:
262+
obs_str = obs_str[:1000] + "... (truncated)"
263+
yield f"data: {json.dumps({'type': 'thought', 'data': f'Observation: {obs_str}'})}\n\n"
264+
254265
elif "output" in step:
255266
full_answer = step["output"]
256267
try:
257268
clean_answer = parse_agent_output(full_answer)
258269
except OutputParserError as e:
259270
logger.warning(f"Rejected malformed streamed LLM output: {e}")
260271
clean_answer = MALFORMED_OUTPUT_MESSAGE
261-
yield f"data: {json.dumps({'type': 'token', 'data': clean_answer})}\n\n"
272+
273+
import time
274+
chunk_size = 4
275+
for i in range(0, len(clean_answer), chunk_size):
276+
yield f"data: {json.dumps({'type': 'token', 'data': clean_answer[i:i+chunk_size]})}\n\n"
277+
time.sleep(0.01)
262278

263279
except Exception as e:
264280
logger.error(f"Agent streaming error: {e}")

frontend/src/components/chat/ChatPanel.tsx

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -275,36 +275,38 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
275275
reject(new Error("WebSocket connection timeout"));
276276
}, 800);
277277

278+
const ensureAssistantCreated = (initialThoughts?: string[], initialSources?: SourceChunk[]) => {
279+
if (!assistantCreated) {
280+
assistantCreated = true;
281+
setIsTyping(false);
282+
const assistantMsg: ChatMsg = {
283+
id: assistantId,
284+
role: "assistant",
285+
content: "",
286+
sources: initialSources || [],
287+
isStreaming: true,
288+
thoughts: initialThoughts || [],
289+
};
290+
setMessages((prev) => [...prev, assistantMsg]);
291+
}
292+
};
293+
278294
ws.onmessage = (ev) => {
279295
clearTimeout(connectTimeout);
280296
try {
281297
const event = JSON.parse(ev.data);
282298
if (event.type === "token") {
283-
if (!assistantCreated) {
284-
assistantCreated = true;
285-
setIsTyping(false);
286-
287-
const assistantMsg: ChatMsg = {
288-
id: assistantId,
289-
role: "assistant",
290-
content: event.data as string,
291-
sources: [],
292-
isStreaming: true,
293-
};
294-
295-
setMessages((prev) => [...prev, assistantMsg]);
296-
} else {
297-
setMessages((prev) =>
298-
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m))
299-
);
300-
}
299+
ensureAssistantCreated();
300+
setMessages((prev) =>
301+
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m))
302+
);
301303
} else if (event.type === "sources") {
304+
ensureAssistantCreated(undefined, event.data as SourceChunk[]);
302305
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, sources: event.data as SourceChunk[] } : m)));
303306
} else if (event.type === "thought") {
304-
// Append thoughts as a temporary assistant note (optional UI handling)
305-
// For simplicity, add to assistant message content in brackets
307+
ensureAssistantCreated([event.data as string]);
306308
setMessages((prev) =>
307-
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + `\n[thought] ${event.data}` } : m))
309+
prev.map((m) => (m.id === assistantId ? { ...m, thoughts: [...(m.thoughts || []), event.data as string] } : m))
308310
);
309311
} else if (event.type === "error") {
310312
setIsTyping(false);
@@ -354,28 +356,37 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
354356
session_id: activeSessionId,
355357
}, abortController.signal);
356358

359+
let sseAssistantCreated = false;
360+
const ensureSseAssistantCreated = (initialThoughts?: string[], initialSources?: SourceChunk[]) => {
361+
if (!sseAssistantCreated) {
362+
sseAssistantCreated = true;
363+
setIsTyping(false);
364+
const assistantMsg: ChatMsg = {
365+
id: assistantId,
366+
role: "assistant",
367+
content: "",
368+
sources: initialSources || [],
369+
isStreaming: true,
370+
thoughts: initialThoughts || [],
371+
};
372+
setMessages((prev) => [...prev, assistantMsg]);
373+
}
374+
};
375+
357376
for await (const event of stream) {
358377
if (event.type === "token") {
359-
if (!assistantCreated) {
360-
assistantCreated = true;
361-
setIsTyping(false);
362-
363-
const assistantMsg: ChatMsg = {
364-
id: assistantId,
365-
role: "assistant",
366-
content: event.data as string,
367-
sources: [],
368-
isStreaming: true,
369-
};
370-
371-
setMessages((prev) => [...prev, assistantMsg]);
372-
} else {
373-
setMessages((prev) =>
374-
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m))
375-
);
376-
}
378+
ensureSseAssistantCreated();
379+
setMessages((prev) =>
380+
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m))
381+
);
377382
} else if (event.type === "sources") {
383+
ensureSseAssistantCreated(undefined, event.data as SourceChunk[]);
378384
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, sources: event.data as SourceChunk[] } : m)));
385+
} else if (event.type === "thought") {
386+
ensureSseAssistantCreated([event.data as string]);
387+
setMessages((prev) =>
388+
prev.map((m) => (m.id === assistantId ? { ...m, thoughts: [...(m.thoughts || []), event.data as string] } : m))
389+
);
379390
} else if (event.type === "error") {
380391
setIsTyping(false);
381392
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: `Error: ${event.data}`, isStreaming: false } : m)));

frontend/src/components/chat/MessageBubble.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import rehypeHighlight from "rehype-highlight";
77
import remarkGfm from "remark-gfm";
88
import type { ChatMsg } from "@/store/chat-store";
99
import { api } from "@/lib/api";
10-
import { Brain, User, Copy, Check, Share2, Link2, X, Play, Pause, GitBranch } from "lucide-react";
10+
import { Brain, User, Copy, Check, Share2, Link2, X, Play, Pause, GitBranch, ChevronDown } from "lucide-react";
1111
import { buttonVariants } from "@/components/ui/button";
1212
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
1313
import { cn } from "@/lib/utils";
@@ -64,6 +64,13 @@ export default function MessageBubble({ message }: Props) {
6464
const [shared, setShared] = useState(false);
6565
const [shareFailed, setShareFailed] = useState(false);
6666
const [isSpeaking, setIsSpeaking] = useState(false);
67+
const [showThoughts, setShowThoughts] = useState(false);
68+
69+
useEffect(() => {
70+
if (message.isStreaming && message.thoughts && message.thoughts.length > 0) {
71+
setShowThoughts(true);
72+
}
73+
}, [message.isStreaming, message.thoughts]);
6774
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6875
const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6976
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
@@ -296,6 +303,36 @@ const handleBranch = () => {
296303
</>
297304
)}
298305

306+
{message.thoughts && message.thoughts.length > 0 && (
307+
<div className="mb-3 border border-border/60 rounded-lg bg-muted/40 overflow-hidden text-xs">
308+
<button
309+
type="button"
310+
onClick={() => setShowThoughts((prev) => !prev)}
311+
className="w-full flex items-center justify-between px-3 py-2 text-muted-foreground hover:text-foreground font-medium transition-colors"
312+
>
313+
<div className="flex items-center gap-1.5">
314+
<Brain className="w-3.5 h-3.5 animate-pulse text-primary" />
315+
<span>{message.isStreaming ? "Thinking..." : "View reasoning steps"}</span>
316+
</div>
317+
<ChevronDown
318+
className={cn(
319+
"w-3.5 h-3.5 transition-transform duration-200",
320+
showThoughts && "transform rotate-180"
321+
)}
322+
/>
323+
</button>
324+
{showThoughts && (
325+
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2 max-h-60 overflow-y-auto font-mono text-[11px] leading-relaxed text-muted-foreground">
326+
{message.thoughts.map((thought, idx) => (
327+
<div key={idx} className="whitespace-pre-wrap border-l-2 border-primary/30 pl-2">
328+
{thought}
329+
</div>
330+
))}
331+
</div>
332+
)}
333+
</div>
334+
)}
335+
299336
<div className={`prose-chat ${fontSizeClass} ${message.content ? "pr-20" : ""}`}>
300337
{message.content ? (
301338
<ReactMarkdown

frontend/src/store/chat-store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ export interface SourceChunk {
2222

2323
export interface ChatMsg {
2424
branch_id?: string;
25-
parent_message_id?: string;
25+
parent_message_id?: string;
2626
id: string;
2727
role: "user" | "assistant";
2828
content: string;
2929
sources: SourceChunk[];
3030
feedback?: "up" | "down" | null;
3131
isStreaming?: boolean;
32+
thoughts?: string[];
3233
}
3334

3435
export interface ChatSession {

0 commit comments

Comments
 (0)