Skip to content

Commit dcb6209

Browse files
committed
Merge branch 'claude/zen-greider'
2 parents e69f640 + 099e9a9 commit dcb6209

File tree

4 files changed

+289
-61
lines changed

4 files changed

+289
-61
lines changed

backend/app.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from flask import Flask, request, jsonify
1+
from flask import Flask, request, jsonify, Response, stream_with_context
22
from flask_cors import CORS
33
from openai import OpenAI
44
import os
@@ -243,6 +243,121 @@ def chat():
243243
'success': False
244244
}), 500
245245

246+
@app.route('/api/chat/stream', methods=['POST'])
247+
def chat_stream():
248+
"""Streaming chat endpoint using SSE"""
249+
try:
250+
data = request.get_json()
251+
message = data.get('message', '')
252+
253+
if not message:
254+
return jsonify({'error': 'Message is required'}), 400
255+
256+
user_id = get_user_id()
257+
log_message(user_id, message, is_user=True)
258+
259+
if not api_key:
260+
return jsonify({'error': 'OpenAI API key not configured', 'success': False}), 500
261+
262+
if not rag_system:
263+
return jsonify({'error': 'RAG system not initialized', 'success': False}), 500
264+
265+
personal_info = rag_system.get_personal_info()
266+
profile_summary = rag_system.get_summary_document()
267+
268+
# Refine query (non-streaming step)
269+
query_refiner_prompt = f"""
270+
You are a world-class AI research assistant. Your task is to refine a user's question into a highly effective search query for a vector database.
271+
The user is asking about a person named {personal_info['name']}.
272+
Here is a high-level summary of their profile:
273+
---
274+
{profile_summary}
275+
---
276+
Based on this summary and the user's original question, generate a concise and focused search query.
277+
Do not answer the user's question, only generate the search query.
278+
279+
User's Original Question: "{message}"
280+
Refined Search Query:
281+
"""
282+
try:
283+
query_refiner_response = client.chat.completions.create(
284+
model="gpt-4o-mini",
285+
messages=[{"role": "system", "content": query_refiner_prompt}],
286+
temperature=0,
287+
max_tokens=100,
288+
)
289+
refined_query = query_refiner_response.choices[0].message.content.strip()
290+
except Exception:
291+
refined_query = message
292+
293+
try:
294+
relevant_context = rag_system.search_relevant_context(refined_query, k=4)
295+
except Exception:
296+
relevant_context = "Unable to retrieve relevant information from the knowledge base."
297+
298+
final_answer_prompt = f"""You are a helpful and professional AI assistant representing {personal_info['name']}.
299+
Your goal is to provide a comprehensive and accurate answer based on the provided information.
300+
301+
First, here is a high-level summary of {personal_info['name']}'s profile for your general understanding:
302+
<SUMMARY>
303+
{profile_summary}
304+
</SUMMARY>
305+
306+
Now, here is the user's question and the specific, detailed information retrieved from the knowledge base to help you answer it:
307+
<USER_QUESTION>
308+
{message}
309+
</USER_QUESTION>
310+
311+
<DETAILED_CONTEXT>
312+
{relevant_context}
313+
</DETAILED_CONTEXT>
314+
315+
INSTRUCTIONS:
316+
- Synthesize the information from both the SUMMARY and the DETAILED_CONTEXT to formulate your final answer.
317+
- Answer the user's question directly and accurately based *only* on the information provided.
318+
- If the detailed context does not contain the answer, you can rely on the summary. If neither contains the answer, state that you don't have enough information.
319+
- Always respond in the same language as the user's question and respond as if you are {personal_info['name']}.
320+
"""
321+
322+
# Capture user_id in closure before streaming
323+
captured_user_id = user_id
324+
captured_message = message
325+
326+
def generate():
327+
full_response = []
328+
try:
329+
stream = client.chat.completions.create(
330+
model="gpt-4o-mini",
331+
messages=[{"role": "system", "content": final_answer_prompt}],
332+
temperature=0.5,
333+
max_tokens=1000,
334+
stream=True,
335+
)
336+
for chunk in stream:
337+
content = chunk.choices[0].delta.content
338+
if content:
339+
full_response.append(content)
340+
yield f"data: {json.dumps({'content': content})}\n\n"
341+
342+
log_message(captured_user_id, captured_message, is_user=False, response="".join(full_response))
343+
yield f"data: {json.dumps({'done': True})}\n\n"
344+
345+
except Exception as e:
346+
yield f"data: {json.dumps({'error': str(e)})}\n\n"
347+
348+
return Response(
349+
stream_with_context(generate()),
350+
mimetype='text/event-stream',
351+
headers={
352+
'Cache-Control': 'no-cache',
353+
'X-Accel-Buffering': 'no',
354+
},
355+
)
356+
357+
except Exception as e:
358+
return jsonify({'error': str(e), 'success': False}), 500
359+
360+
246361
@app.route('/api/health', methods=['GET'])
247362
def health_check():
248363
"""Health check endpoint"""

src/components/ChatSection.tsx

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useRef } from "react";
2-
import { AlertCircle } from "lucide-react";
2+
import { AlertCircle, Download } from "lucide-react";
33
import MessageBubble from "./MessageBubble";
4-
import { sendMessage, checkHealth } from "@/utils/api";
4+
import { sendMessageStream, checkHealth } from "@/utils/api";
55
import { toast } from "sonner";
66
import type { Message } from "@/types/chat";
77

@@ -65,7 +65,7 @@ const ChatSection = () => {
6565
const [inputValue, setInputValue] = useState("");
6666
const [isLoading, setIsLoading] = useState(false);
6767
const [isServerOnline, setIsServerOnline] = useState(true);
68-
const [typingMessageId, setTypingMessageId] = useState<string | null>(null);
68+
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
6969
const messagesEndRef = useRef<HTMLDivElement>(null);
7070
const isInitialLoad = useRef(true);
7171
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -107,14 +107,33 @@ const ChatSection = () => {
107107
return () => clearInterval(interval);
108108
}, []);
109109

110+
const handleExport = () => {
111+
if (messages.length <= 1) {
112+
toast.info("Nothing to export yet — start a conversation first!");
113+
return;
114+
}
115+
const lines = messages.map((m) => {
116+
const time = m.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
117+
return m.isUser
118+
? `**[${time}] You:** ${m.text}`
119+
: `**[${time}] AI:** ${m.text}`;
120+
});
121+
const content = `# Chat Export\n\n_Exported on ${new Date().toLocaleString()}_\n\n---\n\n${lines.join("\n\n---\n\n")}`;
122+
const blob = new Blob([content], { type: "text/markdown" });
123+
const url = URL.createObjectURL(blob);
124+
const a = document.createElement("a");
125+
a.href = url;
126+
a.download = `chat-${new Date().toISOString().slice(0, 10)}.md`;
127+
a.click();
128+
URL.revokeObjectURL(url);
129+
};
130+
110131
const handleSendMessage = async (messageText: string) => {
111132
if (!messageText.trim()) return;
112133
if (!isServerOnline) {
113134
toast.error("AI server is currently offline. Please try again later.");
114135
return;
115136
}
116-
// Finish any ongoing typewriter immediately
117-
setTypingMessageId(null);
118137

119138
historyRef.current = [messageText, ...historyRef.current];
120139
historyIndexRef.current = -1;
@@ -132,36 +151,55 @@ const ChatSection = () => {
132151
setIsLoading(true);
133152
requestAnimationFrame(scrollChatToBottom);
134153

135-
try {
136-
const response = await sendMessage(messageText);
137-
const aiId = crypto.randomUUID();
138-
const aiMessage: Message = {
139-
id: aiId,
140-
text: response.success
141-
? response.response
142-
: response.response || "Sorry, I'm having trouble connecting right now. Please try again later.",
143-
isUser: false,
144-
timestamp: new Date(),
145-
};
146-
if (!response.success) toast.error("Failed to get response. Please try again.");
147-
setMessages((prev) => [...prev, aiMessage]);
148-
setTypingMessageId(aiId);
149-
} catch (error) {
150-
console.error("Error getting AI response:", error);
151-
toast.error("Failed to get response. Please try again later.");
152-
setMessages((prev) => [
153-
...prev,
154-
{
155-
id: crypto.randomUUID(),
156-
text: "Sorry, I'm having trouble connecting right now. Please try again later.",
157-
isUser: false,
158-
timestamp: new Date(),
159-
},
160-
]);
161-
} finally {
162-
setIsLoading(false);
163-
inputRef.current?.focus();
164-
}
154+
const aiId = crypto.randomUUID();
155+
156+
await sendMessageStream(
157+
messageText,
158+
// onChunk: first chunk creates the message, subsequent ones append
159+
(chunk) => {
160+
setIsLoading(false);
161+
setMessages((prev) => {
162+
const existing = prev.find((m) => m.id === aiId);
163+
if (existing) {
164+
return prev.map((m) => (m.id === aiId ? { ...m, text: m.text + chunk } : m));
165+
}
166+
// First chunk — add message and mark it streaming
167+
setStreamingMessageId(aiId);
168+
requestAnimationFrame(scrollChatToBottom);
169+
return [
170+
...prev,
171+
{ id: aiId, text: chunk, isUser: false, timestamp: new Date() },
172+
];
173+
});
174+
},
175+
// onDone
176+
() => {
177+
setIsLoading(false);
178+
setStreamingMessageId(null);
179+
inputRef.current?.focus();
180+
},
181+
// onError
182+
(error) => {
183+
console.error("Stream error:", error);
184+
setIsLoading(false);
185+
setStreamingMessageId(null);
186+
toast.error("Failed to get response. Please try again later.");
187+
setMessages((prev) => {
188+
const hasAiMessage = prev.some((m) => m.id === aiId);
189+
if (hasAiMessage) return prev; // partial response already shown, keep it
190+
return [
191+
...prev,
192+
{
193+
id: aiId,
194+
text: "Sorry, I'm having trouble connecting right now. Please try again later.",
195+
isUser: false,
196+
timestamp: new Date(),
197+
},
198+
];
199+
});
200+
inputRef.current?.focus();
201+
}
202+
);
165203
};
166204

167205
const handleKeyPress = (e: React.KeyboardEvent) => {
@@ -222,28 +260,52 @@ const ChatSection = () => {
222260
{isServerOnline ? "online" : "offline"}
223261
</span>
224262
</span>
225-
{/* Server status dot */}
226-
<span
227-
style={{
228-
fontSize: "11px",
229-
color: isServerOnline ? "var(--term-green)" : "var(--term-red)",
230-
display: "flex",
231-
alignItems: "center",
232-
gap: "4px",
233-
}}
234-
>
263+
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
264+
{/* Server status dot */}
235265
<span
236266
style={{
237-
width: "6px",
238-
height: "6px",
239-
borderRadius: "50%",
240-
backgroundColor: isServerOnline ? "var(--term-green)" : "var(--term-red)",
241-
display: "inline-block",
242-
animation: isServerOnline ? "neuralPulse 2s ease-in-out infinite" : "none",
267+
fontSize: "11px",
268+
color: isServerOnline ? "var(--term-green)" : "var(--term-red)",
269+
display: "flex",
270+
alignItems: "center",
271+
gap: "4px",
243272
}}
244-
/>
245-
{isServerOnline ? "server:online" : "server:offline"}
246-
</span>
273+
>
274+
<span
275+
style={{
276+
width: "6px",
277+
height: "6px",
278+
borderRadius: "50%",
279+
backgroundColor: isServerOnline ? "var(--term-green)" : "var(--term-red)",
280+
display: "inline-block",
281+
animation: isServerOnline ? "neuralPulse 2s ease-in-out infinite" : "none",
282+
}}
283+
/>
284+
{isServerOnline ? "server:online" : "server:offline"}
285+
</span>
286+
{/* Export button */}
287+
<button
288+
onClick={handleExport}
289+
title="Export chat as Markdown"
290+
style={{
291+
background: "transparent",
292+
border: "1px solid var(--term-border)",
293+
color: "var(--term-dim)",
294+
cursor: "pointer",
295+
padding: "2px 6px",
296+
display: "flex",
297+
alignItems: "center",
298+
gap: "4px",
299+
fontSize: "11px",
300+
fontFamily: "inherit",
301+
transition: "color 0.15s, border-color 0.15s",
302+
}}
303+
className="chat-quick-btn"
304+
>
305+
<Download size={11} />
306+
export
307+
</button>
308+
</div>
247309
</div>
248310

249311
{/* Header command */}
@@ -296,8 +358,7 @@ const ChatSection = () => {
296358
<div key={message.id} className="msg-slide-up">
297359
<MessageBubble
298360
message={message}
299-
isTyping={typingMessageId === message.id}
300-
onTypingComplete={() => setTypingMessageId(null)}
361+
isStreaming={streamingMessageId === message.id}
301362
/>
302363
</div>
303364
))}

src/components/MessageBubble.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Message } from "@/types/chat";
66
interface MessageBubbleProps {
77
message: Message;
88
isTyping?: boolean;
9+
isStreaming?: boolean;
910
onTypingComplete?: () => void;
1011
}
1112

@@ -46,7 +47,7 @@ const markdownComponents = {
4647
),
4748
};
4849

49-
const MessageBubble = ({ message, isTyping = false, onTypingComplete }: MessageBubbleProps) => {
50+
const MessageBubble = ({ message, isTyping = false, isStreaming = false, onTypingComplete }: MessageBubbleProps) => {
5051
const time = message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
5152
const onCompleteRef = useRef(onTypingComplete);
5253
onCompleteRef.current = onTypingComplete;
@@ -77,9 +78,10 @@ const MessageBubble = ({ message, isTyping = false, onTypingComplete }: MessageB
7778
);
7879
}
7980

80-
// Determine what text to render
81-
const renderText = isTyping ? displayed : message.text;
82-
const showCursor = isTyping && !done;
81+
// Streaming: render message.text directly (chunks provide the animation)
82+
// Typing: use typewriter effect on the full text
83+
const renderText = isStreaming ? message.text : (isTyping ? displayed : message.text);
84+
const showCursor = isStreaming || (isTyping && !done);
8385

8486
return (
8587
<div ref={ref} style={{ marginBottom: "12px" }}>

0 commit comments

Comments
 (0)