Skip to content

Commit b31418e

Browse files
committed
feat(chat): migrate bookseller chat to WebSockets for real-time streaming
1 parent 33b9e3e commit b31418e

5 files changed

Lines changed: 259 additions & 37 deletions

File tree

‎backend/ai_service.py‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,113 @@ def _build_flat_prompt(system: str, msgs: list) -> str:
508508
logger.error(f"generate_chat failed: {type(e).__name__}: {e}", exc_info=True)
509509

510510
return None
511+
512+
def generate_chat_stream(self, system_prompt: str, messages: list, max_tokens: Optional[int] = None):
513+
"""Yield chunks of text from the LLM for streaming."""
514+
if not self.is_available():
515+
yield "The candles are flickering and my connection to the ether seems troubled today. "
516+
return
517+
518+
if max_tokens is None:
519+
max_tokens = self.config.get('gemini_max_tokens', 600)
520+
521+
# Per-model context window limits (conservative estimates)
522+
_MODEL_CONTEXT_LIMITS = {
523+
'llama-3.1-8b-instant': 8192,
524+
'llama-3.3-70b-versatile': 32768,
525+
'gpt-3.5-turbo': 4096,
526+
'gpt-4': 8192,
527+
'gpt-4o': 16384,
528+
'models/gemini-2.0-flash-lite': 32768,
529+
'models/gemini-1.5-flash': 32768,
530+
}
531+
532+
def _get_context_limit(model_name: str) -> int:
533+
return _MODEL_CONTEXT_LIMITS.get(model_name, 4096)
534+
535+
try:
536+
# --- Gemini ---
537+
if self.gemini_client and (self.preferred_llm == 'gemini' or not self.groq_client):
538+
try:
539+
from google.genai import types
540+
context_limit = _get_context_limit(self.config['gemini_model'])
541+
trimmed = self.trim_history_to_token_budget(
542+
system_prompt, messages, max_tokens, context_limit
543+
)
544+
545+
flat_prompt = system_prompt + "\n\n"
546+
for m in trimmed:
547+
role = "Elara" if m.get("role") == "assistant" else "Customer"
548+
flat_prompt += f"{role}: {m.get('content', '')}\n"
549+
flat_prompt += "Elara:"
550+
551+
response = self.gemini_client.models.generate_content_stream(
552+
model=self.config['gemini_model'],
553+
contents=flat_prompt,
554+
)
555+
for chunk in response:
556+
if chunk.text:
557+
yield chunk.text
558+
return
559+
except Exception as e:
560+
logger.warning(f"Gemini streaming chat failed, falling back: {e}")
561+
562+
# --- Groq ---
563+
if self.groq_client:
564+
try:
565+
context_limit = _get_context_limit(self.config['groq_model'])
566+
trimmed = self.trim_history_to_token_budget(
567+
system_prompt, messages, max_tokens, context_limit
568+
)
569+
570+
groq_messages = [{"role": "system", "content": system_prompt}] + [
571+
{"role": m.get("role", "user"), "content": m.get("content", "")}
572+
for m in trimmed
573+
]
574+
response = self.groq_client.chat.completions.create(
575+
model=self.config['groq_model'],
576+
messages=groq_messages,
577+
max_tokens=min(max_tokens, self.config['groq_max_tokens']),
578+
temperature=self.config['groq_temperature'],
579+
stream=True
580+
)
581+
for chunk in response:
582+
content = chunk.choices[0].delta.content
583+
if content:
584+
yield content
585+
return
586+
except Exception as e:
587+
logger.warning(f"Groq streaming chat failed, falling back: {e}")
588+
589+
# --- OpenAI ---
590+
if self.openai_client:
591+
from openai import OpenAI
592+
client = OpenAI(api_key=os.getenv('OPENAI_API_KEY'))
593+
context_limit = _get_context_limit(self.config['openai_model'])
594+
trimmed = self.trim_history_to_token_budget(
595+
system_prompt, messages, max_tokens, context_limit
596+
)
597+
598+
oai_messages = [{"role": "system", "content": system_prompt}] + [
599+
{"role": m.get("role", "user"), "content": m.get("content", "")}
600+
for m in trimmed
601+
]
602+
response = client.chat.completions.create(
603+
model=self.config['openai_model'],
604+
messages=oai_messages,
605+
max_tokens=min(max_tokens, self.config['openai_max_tokens']),
606+
temperature=self.config['openai_temperature'],
607+
stream=True
608+
)
609+
for chunk in response:
610+
content = chunk.choices[0].delta.content
611+
if content:
612+
yield content
613+
return
614+
615+
except Exception as e:
616+
logger.error(f"generate_chat_stream failed: {type(e).__name__}: {e}", exc_info=True)
617+
yield "An error occurred while peering into the books. Please ask again."
511618

512619
def generate_text(self, prompt: str, max_tokens: Optional[int] = None, retry_count: int = 0) -> Optional[str]:
513620
"""Generate text using available LLM service with retry logic."""
@@ -653,7 +760,7 @@ def _generate_with_gemini(self, prompt: str, max_tokens: int) -> Optional[str]:
653760
llm_service = LLMService()
654761

655762
__all__ = ['generate_book_note', 'get_ai_recommendations', 'get_category_books',
656-
'get_book_mood_tags_safe', 'generate_chat_response', 'llm_service',
763+
'get_book_mood_tags_safe', 'generate_chat_response', 'generate_chat_response_stream', 'llm_service',
657764
'LLMService', 'PromptTemplates']
658765

659766

@@ -944,6 +1051,40 @@ def generate_chat_response(user_message: str, conversation_history: list = []) -
9441051
# SMART FALLBACK: A variety of poetic responses that adapt to keywords
9451052
msg_lower = user_message.lower()
9461053

1054+
if any(k in msg_lower for k in ['rain', 'melancholy', 'sad', 'quiet']):
1055+
return reply or "The spirits are quiet... Let's talk again when they wake."
1056+
1057+
def generate_chat_response_stream(user_message: str, conversation_history: list = []):
1058+
"""
1059+
Yield an emotionally rich, persona-driven chat response from Elara.
1060+
"""
1061+
if not llm_service.is_available():
1062+
logger.warning("generate_chat_response_stream: No LLM available")
1063+
yield "The candles are flickering and my connection to the ether seems troubled today. "
1064+
yield "Come back in a moment — the books are waiting, and so am I."
1065+
return
1066+
1067+
messages = []
1068+
for msg in (conversation_history or []):
1069+
role = msg.get("type", msg.get("role", "user"))
1070+
if role in ("bookseller", "assistant"):
1071+
role = "assistant"
1072+
else:
1073+
role = "user"
1074+
content = msg.get("content", "")
1075+
if content:
1076+
messages.append({"role": role, "content": content})
1077+
1078+
messages.append({"role": "user", "content": user_message})
1079+
1080+
yield from llm_service.generate_chat_stream(
1081+
system_prompt=_WISE_BOOKSELLER_SYSTEM_PROMPT,
1082+
messages=messages,
1083+
)
1084+
1085+
# SMART FALLBACK: A variety of poetic responses that adapt to keywords
1086+
msg_lower = user_message.lower()
1087+
9471088
if any(k in msg_lower for k in ['rain', 'melancholy', 'sad', 'quiet']):
9481089
return "The rain has a way of turning the heart into a library of its own. I've gathered these quiet, thoughtful volumes for your pensive mood."
9491090
elif any(k in msg_lower for k in ['adventure', 'journey', 'travel', 'exciting']):

‎backend/app.py‎

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from flask import Flask, request, jsonify, redirect, url_for
55
from flask_cors import CORS
6+
from flask_socketio import SocketIO, emit
67
from flask_jwt_extended import (
78
JWTManager, create_access_token, jwt_required,
89
get_jwt_identity, set_access_cookies, unset_jwt_cookies
@@ -42,7 +43,7 @@
4243

4344
# Environment variables are now loaded centrally in backend/config.py
4445
from config import app_config, setup_logging, validate_required_env_vars
45-
from ai_service import generate_book_note, get_ai_recommendations, get_category_books, get_book_mood_tags_safe, generate_chat_response, llm_service, get_vibe_recommendations
46+
from ai_service import generate_book_note, get_ai_recommendations, get_category_books, get_book_mood_tags_safe, generate_chat_response, generate_chat_response_stream, llm_service, get_vibe_recommendations
4647
from models import db, User, Book, ShelfItem, BookNote, ReadingGoal, ReadingStats, Collection, CollectionItem, PriceHistory, PriceAlert, Review, register_user, login_user
4748
from price_tracker import get_price_tracker
4849
from cache_service import cache_service
@@ -224,6 +225,9 @@ def _apply_security_headers(response):
224225
resources={r"/api/*": {"origins": _cors_origins}},
225226
)
226227

228+
# Initialize SocketIO
229+
socketio = SocketIO(app, cors_allowed_origins=_cors_origins)
230+
227231
# Initialize cache service
228232
cache_service.init_app(app)
229233

@@ -1100,20 +1104,28 @@ def handle_generate_note():
11001104
@app.route('/api/v1/chat', methods=['POST'])
11011105
@limiter.limit("10 per minute")
11021106
def handle_chat():
1103-
"""Handle chat messages and generate bookseller responses."""
1107+
"""Legacy chat endpoint (deprecated in favor of WebSockets)."""
1108+
return jsonify({
1109+
"success": False,
1110+
"error": "This endpoint is deprecated. Please use the WebSockets interface for Literary Chat."
1111+
}), 426
1112+
1113+
@socketio.on('chat_message')
1114+
def handle_socket_chat(data):
1115+
"""Handle chat messages via WebSockets and stream bookseller responses."""
11041116
from exceptions import (
11051117
LLMCircuitBreakerOpenError, AIServiceException,
11061118
ValidationException, InvalidInputError
11071119
)
11081120
from error_responses import handle_exception
11091121

11101122
try:
1111-
data = request.get_json()
1112-
1123+
# Validate using existing ChatRequest rules (manual dict pass)
11131124
is_valid, validated_data = validate_request(ChatRequest, data)
11141125
if not is_valid:
1115-
return jsonify(validated_data), 400
1116-
1126+
emit('chat_error', {"error": "Validation failed", "details": validated_data})
1127+
return
1128+
11171129
user_message = validated_data.message
11181130
conversation_history = validated_data.history or []
11191131

@@ -1124,39 +1136,28 @@ def handle_chat():
11241136
else:
11251137
validated_history.append(msg)
11261138

1127-
# Generate contextual response based on conversation history
1128-
response = generate_chat_response(user_message, validated_history)
1129-
1130-
# Try to get book recommendations based on the message
1139+
# We need a streaming version of generate_chat_response from ai_service
1140+
# For now, if the streaming generator exists we use it, otherwise fallback
11311141
recommendations = get_ai_recommendations(user_message)
11321142

1133-
# =========================================================================
1134-
# TIMESTAMP STANDARDIZATION
1135-
# =========================================================================
1136-
# Ensure that the timestamp returned to the client is explicitly set to
1137-
# UTC using timezone-aware objects. This prevents subtle bugs where server
1138-
# locale or deployment environments might skew the time by relying on
1139-
# naive datetime.now() calls. This is a critical fix for ensuring
1140-
# consistent client-side formatting regardless of geographical region.
1141-
# =========================================================================
1142-
1143-
return success_response(
1144-
data={
1145-
"response": response,
1146-
"recommendations": recommendations,
1147-
"timestamp": datetime.now(timezone.utc).isoformat()
1148-
}
1149-
)
1143+
# Start streaming back the LLM output
1144+
full_response = ""
1145+
for chunk in generate_chat_response_stream(user_message, validated_history):
1146+
if chunk:
1147+
full_response += chunk
1148+
emit('chat_stream', {'chunk': chunk})
1149+
1150+
# When generation is complete, emit the final payload with metadata
1151+
emit('chat_complete', {
1152+
"success": True,
1153+
"response": full_response,
1154+
"recommendations": recommendations,
1155+
"timestamp": datetime.now(timezone.utc).isoformat()
1156+
})
11501157

1151-
except (LLMCircuitBreakerOpenError, AIServiceException) as e:
1152-
logger.error(f"AI service error in handle_chat: {e}", exc_info=True)
1153-
return handle_exception(e, "handle_chat")
1154-
except (ValidationException, InvalidInputError) as e:
1155-
logger.warning(f"Validation error in handle_chat: {e}")
1156-
return handle_exception(e, "handle_chat")
11571158
except Exception as e:
1158-
logger.error(f"Unexpected error in handle_chat: {type(e).__name__}: {e}", exc_info=True)
1159-
return handle_exception(e, "handle_chat")
1159+
logger.error(f"Error in WebSocket handle_chat: {type(e).__name__}: {e}", exc_info=True)
1160+
emit('chat_error', {"error": "An error occurred while generating a response. Please try again."})
11601161

11611162
@app.route('/api/v1/health', methods=['GET'])
11621163
def health_check():
@@ -2611,6 +2612,6 @@ def delete_price_alert(alert_id):
26112612
logger.info(" POST /api/v1/chat - Chat with bookseller")
26122613
logger.info(" GET /api/v1/health - Health check")
26132614

2614-
app.run(debug=server_config.debug, port=server_config.port, host=server_config.host)
2615+
socketio.run(app, debug=server_config.debug, port=server_config.port, host=server_config.host)
26152616

26162617

‎backend/requirements.txt‎

-472 Bytes
Binary file not shown.

‎frontend/js/chat.js‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,32 @@ class ChatInterface {
1010

1111
this.conversationHistory = [];
1212
this.isProcessing = false;
13+
14+
this.socket = null;
15+
this.currentMessageContent = "";
16+
this.currentMessageBubble = null;
1317

1418
this.init();
1519
}
1620

1721
init() {
22+
// Init socket.io
23+
if (typeof io !== 'undefined') {
24+
const moodApiBase = window.MOOD_API_BASE || 'http://127.0.0.1:5000';
25+
const baseUrl = moodApiBase.replace(/\/api\/v1\/?$/, '');
26+
this.socket = io(baseUrl);
27+
28+
this.socket.on('chat_stream', (data) => {
29+
if (this.currentMessageBubble) {
30+
this.hideTypingIndicator(); // Hide indicator as soon as text flows
31+
this.currentMessageContent += data.chunk;
32+
this.currentMessageBubble.innerHTML = '';
33+
this.renderTextContent(this.currentMessageBubble, this.currentMessageContent, true);
34+
this.scrollToBottom();
35+
}
36+
});
37+
}
38+
1839
// Load conversation history from localStorage
1940
this.loadConversationHistory();
2041

@@ -164,6 +185,63 @@ Tell me: what is stirring in you today?`,
164185
// Show typing indicator
165186
this.showTypingIndicator();
166187

188+
// --- WebSockets Path ---
189+
if (this.socket && this.socket.connected) {
190+
this.currentMessageContent = "";
191+
const booksellerMessage = {
192+
type: 'bookseller',
193+
content: "",
194+
books: null,
195+
timestamp: new Date().toISOString()
196+
};
197+
this.conversationHistory.push(booksellerMessage);
198+
const messageDiv = this.renderMessage(booksellerMessage);
199+
this.currentMessageBubble = messageDiv.querySelector('.message-bubble');
200+
201+
const history = this._buildTokenBudgetHistory(message);
202+
this.socket.emit('chat_message', { message: message, history: history });
203+
204+
const onComplete = async (data) => {
205+
this.socket.off('chat_complete', onComplete);
206+
this.socket.off('chat_error', onError);
207+
208+
booksellerMessage.content = data.response;
209+
// Try to get actual books from Google Books API
210+
const books = await this.searchGoogleBooks(message);
211+
if (books && books.length > 0) {
212+
booksellerMessage.books = books;
213+
const bookRec = this.createBookRecommendations(books);
214+
this.currentMessageBubble.appendChild(bookRec);
215+
}
216+
this.saveConversationHistory();
217+
this.scrollToBottom();
218+
};
219+
220+
const onError = (data) => {
221+
this.socket.off('chat_complete', onComplete);
222+
this.socket.off('chat_error', onError);
223+
this.hideTypingIndicator();
224+
225+
// Remove the empty message we created
226+
this.conversationHistory.pop();
227+
if (messageDiv) messageDiv.remove();
228+
229+
const errorMessage = {
230+
type: 'bookseller',
231+
content: "The candles are flickering and something seems amiss with my connection to the literary spirits. Give me a moment — the books are waiting, and so is the perfect story for you.",
232+
timestamp: new Date().toISOString()
233+
};
234+
this.conversationHistory.push(errorMessage);
235+
this.renderMessage(errorMessage);
236+
this.saveConversationHistory();
237+
};
238+
239+
this.socket.on('chat_complete', onComplete);
240+
this.socket.on('chat_error', onError);
241+
return;
242+
}
243+
244+
// --- HTTP Fallback Path ---
167245
try {
168246
// Get AI response
169247
const response = await this.getBooksellerResponse(message);
@@ -501,6 +579,7 @@ Tell me: what is stirring in you today?`,
501579

502580
this.chatMessages.appendChild(messageDiv);
503581
this.scrollToBottom();
582+
return messageDiv;
504583
}
505584

506585
createBookRecommendations(books) {

0 commit comments

Comments
 (0)