Phong1 commited on
Commit
1f335cf
·
verified ·
1 Parent(s): abf8637

Upload chainlit_hf.py

Browse files
Files changed (1) hide show
  1. chainlit_hf.py +1105 -0
chainlit_hf.py ADDED
@@ -0,0 +1,1105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import time
3
+ import chainlit as cl
4
+ import pandas as pd
5
+ import httpx
6
+ import asyncio
7
+ from typing import Dict, List, Any, Optional, Callable
8
+ from dataclasses import dataclass, field
9
+ import os
10
+ import uuid
11
+ from datetime import datetime, timedelta
12
+
13
+
14
+ API_BASE_URL = os.getenv("API_BASE_URL")
15
+
16
+
17
+ @dataclass
18
+ class ConversationState:
19
+ """Data class to hold conversation state"""
20
+ session_id: Optional[str] = None
21
+ specs_advantages: Dict[str, Any] = field(default_factory=dict)
22
+ solution_packages: List[str] = field(default_factory=list)
23
+ raw_documents: Optional[Dict[str, Any]] = None
24
+ outputs: Optional[Dict[str, Any]] = None
25
+ selected_model: str = "Gemini 2.5 Flash"
26
+ product_model_search: bool = False
27
+ method: str = "dense" # "dense", "sparse", "hybrid"
28
+ is_enhance_query: bool = False # New field for query enhancement toggle
29
+ enhanced_image_retrieval: bool = False # New field for enhanced image retrieval toggle
30
+ # New fields for delayed cleanup - now using asyncio
31
+ pending_cleanup: bool = False
32
+ cleanup_task: Optional[asyncio.Task] = None
33
+ last_activity: datetime = field(default_factory=datetime.now)
34
+
35
+ def reset(self):
36
+ """Reset state to initial values"""
37
+ self.session_id = None
38
+ self.specs_advantages = {}
39
+ self.solution_packages = []
40
+ self.raw_documents = None
41
+ self.outputs = None
42
+ self.selected_model = "Gemini 2.5 Flash"
43
+ self.product_model_search = False
44
+ self.method = "dense"
45
+ self.is_enhance_query = False
46
+ self.enhanced_image_retrieval = False
47
+ # Reset cleanup fields but don't touch tasks
48
+ self.pending_cleanup = False
49
+ self.last_activity = datetime.now()
50
+
51
+ def cancel_cleanup_task(self):
52
+ """Cancel pending cleanup task if exists"""
53
+ if self.cleanup_task and not self.cleanup_task.done():
54
+ self.cleanup_task.cancel()
55
+ self.cleanup_task = None
56
+ print(f"🚫 Cancelled cleanup task for session: {self.session_id}")
57
+
58
+
59
+ class StateManager:
60
+ """Manages conversation state operations with per-session isolation and delayed cleanup"""
61
+
62
+ # CLASS-LEVEL session storage for isolation between different browser sessions
63
+ _session_states: Dict[str, ConversationState] = {}
64
+ _lock = asyncio.Lock() # Async lock for consistency
65
+
66
+ @staticmethod
67
+ async def get_or_create_session_state(session_id: str) -> ConversationState:
68
+ """Get existing session state or create new one"""
69
+ async with StateManager._lock:
70
+ if session_id not in StateManager._session_states:
71
+ state = ConversationState()
72
+ state.session_id = session_id
73
+ StateManager._session_states[session_id] = state
74
+ print(f"🆕 Created new session state for: {session_id}")
75
+ else:
76
+ state = StateManager._session_states[session_id]
77
+ print(f"🔄 Retrieved existing session state for: {session_id}")
78
+
79
+ # CRITICAL: If session was pending cleanup, cancel it because user is active again
80
+ if state.pending_cleanup:
81
+ state.cancel_cleanup_task()
82
+ state.pending_cleanup = False
83
+ print(f"♻️ User activity detected! Cancelled pending cleanup for: {session_id}")
84
+
85
+ # Update activity timestamp
86
+ state.last_activity = datetime.now()
87
+ return state
88
+
89
+ @staticmethod
90
+ async def schedule_delayed_cleanup(session_id: str, delay_seconds: int = 3600):
91
+ """Schedule delayed cleanup for a session using asyncio (default 1 hour for disconnect tolerance)"""
92
+ async with StateManager._lock:
93
+ if session_id not in StateManager._session_states:
94
+ print(f"⚠️ Cannot schedule cleanup for non-existent session: {session_id}")
95
+ return
96
+
97
+ state = StateManager._session_states[session_id]
98
+
99
+ # Cancel existing task if any
100
+ state.cancel_cleanup_task()
101
+
102
+ # Mark as pending cleanup
103
+ state.pending_cleanup = True
104
+
105
+ # Schedule new cleanup using asyncio
106
+ async def delayed_cleanup():
107
+ try:
108
+ await asyncio.sleep(delay_seconds)
109
+ print(f"⏰ Executing delayed cleanup for session: {session_id}")
110
+ await StateManager._perform_actual_cleanup(session_id)
111
+ except asyncio.CancelledError:
112
+ print(f"🚫 Cleanup task cancelled for session: {session_id}")
113
+ raise
114
+ except Exception as e:
115
+ print(f"❌ Error in delayed cleanup for {session_id}: {e}")
116
+
117
+ state.cleanup_task = asyncio.create_task(delayed_cleanup())
118
+
119
+ print(f"⏱️ Scheduled cleanup in {delay_seconds}s for session: {session_id} (likely disconnect)")
120
+
121
+ @staticmethod
122
+ async def _perform_actual_cleanup(session_id: str):
123
+ """Perform the actual cleanup after delay"""
124
+ async with StateManager._lock:
125
+ if session_id not in StateManager._session_states:
126
+ print(f"⚠️ Session already cleaned or doesn't exist: {session_id}")
127
+ return
128
+
129
+ state = StateManager._session_states[session_id]
130
+
131
+ # Double-check if session is still pending cleanup (user might have sent message)
132
+ if not state.pending_cleanup:
133
+ print(f"🚫 Cleanup cancelled - user activity detected for: {session_id}")
134
+ return
135
+
136
+ # Perform API cleanup using httpx
137
+ try:
138
+ if API_BASE_URL:
139
+ payload = {
140
+ "reset_cache": True,
141
+ "reset_model": False,
142
+ "session_id": session_id
143
+ }
144
+ async with httpx.AsyncClient(timeout=30.0) as client:
145
+ response = await client.post(f"{API_BASE_URL}/clear_memory", json=payload)
146
+ print(f"Clear memory response for {session_id}: {response.status_code}")
147
+ except Exception as e:
148
+ print(f"Warning: clear_memory failed for {session_id}: {e}")
149
+
150
+ # Remove from memory
151
+ del StateManager._session_states[session_id]
152
+ print(f"🗑️ Successfully cleaned up session: {session_id}")
153
+
154
+ @staticmethod
155
+ async def cleanup_session_immediate(session_id: str):
156
+ """Immediate cleanup (for testing or forced cleanup)"""
157
+ async with StateManager._lock:
158
+ if session_id in StateManager._session_states:
159
+ state = StateManager._session_states[session_id]
160
+ state.cancel_cleanup_task()
161
+ await StateManager._perform_actual_cleanup(session_id)
162
+
163
+ @staticmethod
164
+ async def clear_chat_state(state: ConversationState):
165
+ """Clear all conversation history and reset state via API (but keep session alive)"""
166
+ if state.session_id is not None and API_BASE_URL:
167
+ try:
168
+ payload = {
169
+ "reset_cache": True,
170
+ "reset_model": False,
171
+ "session_id": state.session_id
172
+ }
173
+ async with httpx.AsyncClient(timeout=30.0) as client:
174
+ response = await client.post(f"{API_BASE_URL}/clear_memory", json=payload)
175
+ print(f"Clear memory response: {response.status_code}")
176
+ except Exception as e:
177
+ print(f"Warning: clear_memory failed: {e}")
178
+
179
+ # Reset state but keep session_id and don't trigger cleanup
180
+ session_id = state.session_id
181
+ state.reset()
182
+ state.session_id = session_id
183
+
184
+ @staticmethod
185
+ async def change_model(state: ConversationState, model_name: str):
186
+ """Change the selected model"""
187
+ state.selected_model = model_name
188
+ state.last_activity = datetime.now()
189
+
190
+ @staticmethod
191
+ async def toggle_product_model_search(state: ConversationState):
192
+ """Toggle product model search mode"""
193
+ state.product_model_search = not state.product_model_search
194
+ state.last_activity = datetime.now()
195
+
196
+ @staticmethod
197
+ async def toggle_enhance_query(state: ConversationState):
198
+ """Toggle query enhancement mode"""
199
+ state.is_enhance_query = not state.is_enhance_query
200
+ state.last_activity = datetime.now()
201
+
202
+ @staticmethod
203
+ async def toggle_enhanced_image_retrieval(state: ConversationState):
204
+ """Toggle enhanced image retrieval mode"""
205
+ state.enhanced_image_retrieval = not state.enhanced_image_retrieval
206
+ state.last_activity = datetime.now()
207
+
208
+ @staticmethod
209
+ async def cycle_search_method(state: ConversationState):
210
+ """Cycle search method: dense -> sparse -> hybrid -> dense"""
211
+ if state.method == "dense":
212
+ state.method = "sparse"
213
+ elif state.method == "sparse":
214
+ state.method = "hybrid"
215
+ else:
216
+ state.method = "dense"
217
+ state.last_activity = datetime.now()
218
+
219
+ @staticmethod
220
+ async def get_session_status() -> Dict[str, Dict[str, Any]]:
221
+ """Get status of all sessions (for debugging)"""
222
+ async with StateManager._lock:
223
+ status = {}
224
+ for session_id, state in StateManager._session_states.items():
225
+ status[session_id] = {
226
+ "pending_cleanup": state.pending_cleanup,
227
+ "has_task": state.cleanup_task is not None and not state.cleanup_task.done(),
228
+ "last_activity": state.last_activity.isoformat(),
229
+ "selected_model": state.selected_model,
230
+ "product_model_search": state.product_model_search,
231
+ "method": state.method,
232
+ "is_enhance_query": state.is_enhance_query,
233
+ "enhanced_image_retrieval": state.enhanced_image_retrieval
234
+ }
235
+ return status
236
+
237
+
238
+ class ChatService:
239
+ """Handles chat-related operations with async HTTP calls"""
240
+
241
+ @staticmethod
242
+ async def respond_to_chat(
243
+ state: ConversationState,
244
+ message: str,
245
+ image_path: Optional[str] = None
246
+ ) -> str:
247
+ """Handle chat responses with image support using async HTTP"""
248
+ print(f"🔄 === DEBUG STATE ===\nChat request with model: {state.selected_model}, Product Model Search: {state.product_model_search}, Method: {state.method}, Session ID: {state.session_id}")
249
+
250
+ # Update activity timestamp - this is KEY to prevent cleanup during active use
251
+ state.last_activity = datetime.now()
252
+
253
+ start = time.perf_counter()
254
+
255
+ if not API_BASE_URL:
256
+ return "Error: API_BASE_URL not configured"
257
+
258
+ if not state.session_id:
259
+ return "Error: Session ID not initialized"
260
+
261
+ # Call API using httpx for async HTTP
262
+ try:
263
+ async with httpx.AsyncClient(timeout=600.0) as client:
264
+ if image_path:
265
+ # For image uploads, use form-data format as expected by API
266
+ with open(image_path, 'rb') as f:
267
+ files = {"image": f.read()}
268
+
269
+ data = {
270
+ "message": message,
271
+ "product_model_search": str(state.product_model_search).lower(),
272
+ "method": state.method,
273
+ "session_id": state.session_id,
274
+ "llm_model": state.selected_model,
275
+ "debug": "Normal",
276
+ "is_enhance_query": str(state.is_enhance_query).lower(),
277
+ "enhanced_image_retrieval": str(state.enhanced_image_retrieval).lower()
278
+ }
279
+
280
+ # Use multipart form data for image upload
281
+ files_dict = {"image": ("image.jpg", files["image"], "image/jpeg")}
282
+ resp = await client.post(
283
+ f"{API_BASE_URL}/chat_with_image",
284
+ files=files_dict,
285
+ data=data
286
+ )
287
+ else:
288
+ # For text messages, use form-data format as expected by API
289
+ data = {
290
+ "message": message if message and message.strip() else " ",
291
+ "session_id": state.session_id,
292
+ "debug": "Normal",
293
+ "product_model_search": str(state.product_model_search).lower(),
294
+ "method": state.method,
295
+ "llm_model": state.selected_model,
296
+ "is_enhance_query": str(state.is_enhance_query).lower(),
297
+ "enhanced_image_retrieval": str(state.enhanced_image_retrieval).lower()
298
+ }
299
+ resp = await client.post(
300
+ f"{API_BASE_URL}/chat",
301
+ data=data # Form data format
302
+ )
303
+
304
+ if resp.status_code == 200:
305
+ j = resp.json()
306
+ response = j.get("response", "")
307
+ specs_advantages = j.get("specs_advantages")
308
+ solution_packages = j.get("solution_packages")
309
+ raw_documents = j.get("raw_documents") # This might be None from API
310
+ outputs = j.get("outputs")
311
+ else:
312
+ print(f"API Error: {resp.status_code} - {resp.text}")
313
+ response = f"Error: API status {resp.status_code}"
314
+ specs_advantages, solution_packages, raw_documents, outputs = None, None, None, None
315
+ except Exception as e:
316
+ print(f"Exception calling API: {e}")
317
+ response = f"Error calling API: {e}"
318
+ specs_advantages, solution_packages, raw_documents, outputs = None, None, None, None
319
+
320
+ end = time.perf_counter()
321
+
322
+ # Update state
323
+ if specs_advantages is not None:
324
+ state.specs_advantages = specs_advantages
325
+ if solution_packages is not None:
326
+ state.solution_packages = solution_packages
327
+ if raw_documents is not None:
328
+ state.raw_documents = raw_documents
329
+ if outputs is not None:
330
+ state.outputs = outputs
331
+
332
+ # Filter products based on query
333
+ if state.specs_advantages is not None:
334
+ await ChatService.get_specific_product_from_query(message, state)
335
+
336
+ # NEW: Format response with 2-column grid for products
337
+ formatted_response = ChatService.format_product_grid(response)
338
+
339
+ return formatted_response + f"\n\n*Thời gian xử lí: {end - start:.6f}s*"
340
+
341
+ @staticmethod
342
+ def format_product_grid(response_text: str) -> str:
343
+ """Format product listings into 2-column grid while keeping other content intact"""
344
+ # Pattern to match: * **[Name](url)**\n\n ![alt](img_url)
345
+ pattern = r'\*\s+\*\*\[(.*?)\]\((.*?)\)\*\*\s*\n\s*!\[(.*?)\]\((.*?)\)'
346
+
347
+ matches = list(re.finditer(pattern, response_text))
348
+
349
+ if not matches:
350
+ # No product listings found, return original
351
+ return response_text
352
+
353
+ # Find boundaries of product section
354
+ first_match_start = matches[0].start()
355
+ last_match_end = matches[-1].end()
356
+
357
+ # Split into: intro + products + rest
358
+ intro_text = response_text[:first_match_start].strip()
359
+ rest_text = response_text[last_match_end:].strip()
360
+
361
+ # Extract all products
362
+ products = []
363
+ for match in matches:
364
+ products.append({
365
+ 'name': match.group(1),
366
+ 'url': match.group(2),
367
+ 'alt': match.group(3),
368
+ 'img': match.group(4)
369
+ })
370
+
371
+ # Build 2-column markdown table
372
+ grid_content = "\n\n"
373
+
374
+ for i in range(0, len(products), 2):
375
+ p1 = products[i]
376
+
377
+ if i + 1 < len(products):
378
+ p2 = products[i + 1]
379
+ # Two columns
380
+ grid_content += f"| **[{p1['name']}]({p1['url']})** | **[{p2['name']}]({p2['url']})** |\n"
381
+ grid_content += f"|:---:|:---:|\n"
382
+ grid_content += f"| ![{p1['alt']}]({p1['img']}) | ![{p2['alt']}]({p2['img']}) |\n\n"
383
+ else:
384
+ # Single column for last odd product
385
+ grid_content += f"| **[{p1['name']}]({p1['url']})** |\n"
386
+ grid_content += f"|:---:|\n"
387
+ grid_content += f"| ![{p1['alt']}]({p1['img']}) |\n\n"
388
+
389
+ # Reconstruct full response
390
+ return intro_text + grid_content + rest_text
391
+
392
+ @staticmethod
393
+ async def get_specific_product_from_query(query, state):
394
+ """Filter specs_advantages based on models found in query"""
395
+ specs_map = state.specs_advantages or {}
396
+ product_model_list = []
397
+
398
+ for prod_id, data in specs_map.items():
399
+ model = data.get("model", None)
400
+ if model is not None:
401
+ product_model_list.append(model)
402
+
403
+ found_models = []
404
+ for model in product_model_list:
405
+ pattern = re.escape(model)
406
+ if re.search(pattern, query, re.IGNORECASE):
407
+ found_models.append(model)
408
+
409
+ new_specs_advantages = {}
410
+ if found_models != []:
411
+ for prod_id, data in specs_map.items():
412
+ if data.get("model", None) in found_models:
413
+ new_specs_advantages[prod_id] = data
414
+
415
+ state.specs_advantages = new_specs_advantages
416
+
417
+
418
+ class DisplayService:
419
+ """Handles display-related operations with async HTTP calls"""
420
+
421
+ @staticmethod
422
+ async def show_specs(state: ConversationState) -> str:
423
+ """Generate specifications table"""
424
+ specs_map = state.specs_advantages
425
+ columns = ["Thông số"]
426
+ raw_data = []
427
+
428
+ if not specs_map:
429
+ return "📄 **Thông số kỹ thuật**\n\nKhông có thông số kỹ thuật nào."
430
+
431
+ print(specs_map)
432
+ for prod_id, data in specs_map.items():
433
+ spec = data.get("specification")
434
+ if spec is None or spec == "" or spec == "None":
435
+ spec = "Hiện tại trong dữ liệu chưa có thông tin về thông số kĩ thuật của sản phẩm này!"
436
+ model = data.get("model", "")
437
+ url = data.get("url", "")
438
+
439
+ # Handle both products and solution packages
440
+ if url:
441
+ # full_name = f"**[{data['name']} {model}]({url})**"
442
+ full_name = f"**[{data['name']}]({url})**"
443
+ else:
444
+ # full_name = f"**{data['name']} {model}**"
445
+ full_name = f"**{data['name']}**"
446
+
447
+ if full_name not in columns:
448
+ columns.append(full_name)
449
+
450
+ if spec:
451
+ # Check if this is a solution package (contains markdown table)
452
+ if "### 📦" in spec:
453
+ # For solution packages, parse the markdown table properly
454
+ lines = spec.split('\n')
455
+ in_table = False
456
+ headers = []
457
+
458
+ for line in lines:
459
+ line = line.strip()
460
+ if '|' in line and '---' not in line and line.startswith('|') and line.endswith('|'):
461
+ cells = [cell.strip()
462
+ for cell in line.split('|')[1:-1]]
463
+
464
+ if not in_table:
465
+ # This is the header row
466
+ headers = cells
467
+ in_table = True
468
+ continue
469
+
470
+ # This is a data row
471
+ if len(cells) >= len(headers):
472
+ for i, header in enumerate(headers):
473
+ if i < len(cells):
474
+ param_name = header
475
+ param_value = cells[i]
476
+
477
+ existing_row = None
478
+ for row in raw_data:
479
+ if row["Thông số"] == param_name:
480
+ existing_row = row
481
+ break
482
+
483
+ if existing_row:
484
+ existing_row[full_name] = param_value
485
+ else:
486
+ new_row = {"Thông số": param_name}
487
+ for col in columns[1:]:
488
+ new_row[col] = ""
489
+ new_row[full_name] = param_value
490
+ raw_data.append(new_row)
491
+ elif in_table and (not line or not line.startswith('|')):
492
+ in_table = False
493
+ else:
494
+ # For products, parse specification items
495
+ items = re.split(r';|\n', spec)
496
+ for item in items:
497
+ if ":" in item:
498
+ key, value = item.split(':', 1)
499
+ spec_key = key.strip().capitalize()
500
+ if spec_key == "Vậtl iệu":
501
+ spec_key = "Vật liệu"
502
+
503
+ if "|" in spec_key:
504
+ spec_key = spec_key.strip().replace("|", "").capitalize()
505
+
506
+ existing_row = None
507
+ for row in raw_data:
508
+ if row["Thông số"] == spec_key:
509
+ existing_row = row
510
+ break
511
+
512
+ if existing_row:
513
+ existing_row[full_name] = value.strip() if value else ""
514
+ else:
515
+ new_row = {"Thông số": spec_key}
516
+ for col in columns[1:]:
517
+ new_row[col] = ""
518
+ new_row[full_name] = value.strip() if value else ""
519
+ raw_data.append(new_row)
520
+
521
+ if raw_data:
522
+ df = pd.DataFrame(raw_data, columns=columns)
523
+ df = df.fillna("").replace("None", "").replace("nan", "")
524
+ else:
525
+ df = pd.DataFrame(
526
+ [["Không có thông số kỹ thuật", "", ""]], columns=columns)
527
+
528
+ markdown_table = df.to_markdown(index=False)
529
+ return f"📄 **Thông số kỹ thuật**\n\n{markdown_table}"
530
+
531
+ @staticmethod
532
+ async def show_advantages(state: ConversationState) -> str:
533
+ """Generate advantages as bullet list instead of table"""
534
+ specs_map = state.specs_advantages
535
+
536
+ if not specs_map:
537
+ return "💡 **Ưu điểm nổi trội**\n\nKhông có ưu điểm nào."
538
+
539
+ content = "💡 **Ưu điểm nổi trội**\n\n"
540
+
541
+ for prod_id, data in specs_map.items():
542
+ # adv = data.get("advantages", "Hiện tại trong dữ liệu chưa có thông tin về ưu điểm nổi trội của sản phẩm này!")
543
+ adv = data.get("advantages")
544
+ if adv is None or adv == "" or adv == "None":
545
+ adv = "Hiện tại trong dữ liệu chưa có thông tin về ưu điểm nổi trội của sản phẩm này!"
546
+ model = data.get("model", "")
547
+ url = data.get("url", "")
548
+
549
+ # Handle both products and solution packages
550
+ if url:
551
+ full_name = f"**[{data['name']}]({url})**"
552
+ else:
553
+ full_name = f"**{data['name']}**"
554
+
555
+ content += f"### {full_name}\n"
556
+
557
+ # Split by newlines and create bullet points
558
+ advantages_list = [line.strip() for line in adv.split('\n') if line.strip()]
559
+ for advantage in advantages_list:
560
+ content += f"- {advantage}\n"
561
+ content += "\n"
562
+
563
+ return content
564
+
565
+ @staticmethod
566
+ async def show_solution_packages(state: ConversationState) -> str:
567
+ """Show solution packages in a structured format"""
568
+ packages = state.solution_packages
569
+
570
+ if not packages or packages == []:
571
+ return "📦 **Gói sản phẩm**\n\nKhông có gói sản phẩm nào"
572
+
573
+ markdown_table = "\n\n".join(packages)
574
+ return markdown_table
575
+
576
+ @staticmethod
577
+ async def show_all_products_table(state: ConversationState):
578
+ """Show all products table using async HTTP"""
579
+ outputs = state.outputs or {}
580
+
581
+ if not outputs:
582
+ return "Không có dữ liệu sản phẩm"
583
+
584
+ try:
585
+ # Updated to match API format - send outputs in request body
586
+ payload = {"outputs": outputs}
587
+ async with httpx.AsyncClient(timeout=60.0) as client:
588
+ resp = await client.post(f"{API_BASE_URL}/products_by_category", json=payload)
589
+
590
+ if resp.status_code == 200:
591
+ data = resp.json()
592
+ return data.get("markdown_table", "Không có dữ liệu sản phẩm")
593
+ else:
594
+ print(f"All products API error: {resp.status_code} - {resp.text}")
595
+ return "Không có dữ liệu sản phẩm"
596
+ except Exception as e:
597
+ print(f"Exception in show_all_products_table: {e}")
598
+ return f"Error: {e}"
599
+
600
+
601
+ class UIService:
602
+ """Handles UI-related operations"""
603
+
604
+ @staticmethod
605
+ def create_action_buttons(state: ConversationState):
606
+ """Create persistent action buttons"""
607
+ search_status = "🔍 Tìm theo mã sản phẩm (Đang tắt)" if not state.product_model_search else "🔍 Tìm theo mã sản phẩm (Đang bật)"
608
+ method_labels = {
609
+ "dense": "🔎 Tìm kiếm: Dense",
610
+ "sparse": "🔎 Tìm kiếm: Sparse (BM25)",
611
+ "hybrid": "🔎 Tìm kiếm: Hybrid"
612
+ }
613
+ method_status = method_labels.get(state.method, "🔎 Tìm kiếm: Dense")
614
+ enhance_status = "🧠 Tăng cường truy vấn (Đang tắt)" if not state.is_enhance_query else "🧠 Tăng cường truy vấn (Đang bật)"
615
+ enhanced_retrieval_status = "🖼️ Tìm bằng ảnh nâng cao (Đang tắt)" if not state.enhanced_image_retrieval else "🖼️ Tìm bằng ảnh nâng cao (Đang bật)"
616
+
617
+ return [
618
+ cl.Action(name="show_specs", value="specs", label="📄 Thông số kỹ thuật", payload={"action": "specs"}),
619
+ cl.Action(name="show_advantages", value="advantages", label="💡 Ưu điểm nổi trội", payload={"action": "advantages"}),
620
+ cl.Action(name="show_packages", value="packages", label="📦 Gói sản phẩm", payload={"action": "packages"}),
621
+ cl.Action(name="show_all_products", value="all_products", label="🛒 Tất cả sản phẩm", payload={"action": "all_products"}),
622
+ cl.Action(name="toggle_product_search", value="toggle_search", label=search_status, payload={"action": "toggle_search"}),
623
+ cl.Action(name="change_search_method", value="change_method", label="🔎 Đổi phương thức tìm kiếm", payload={"action": "change_method"}),
624
+ cl.Action(name="toggle_enhance_query", value="toggle_enhance", label=enhance_status, payload={"action": "toggle_enhance"}),
625
+ cl.Action(name="toggle_enhanced_image_retrieval", value="toggle_enhanced_retrieval", label=enhanced_retrieval_status, payload={"action": "toggle_enhanced_retrieval"}),
626
+ cl.Action(name="change_model", value="model", label="🔄 Đổi model", payload={"action": "model"}),
627
+ ]
628
+
629
+ @staticmethod
630
+ def create_start_buttons(state: ConversationState):
631
+ """Create start buttons"""
632
+ search_status = "🔍 Tìm theo mã sản phẩm (Đang tắt)" if not state.product_model_search else "🔍 Tìm theo mã sản phẩm (Đang bật)"
633
+ method_labels = {
634
+ "dense": "🔎 Tìm kiếm: Dense",
635
+ "sparse": "🔎 Tìm kiếm: Sparse (BM25)",
636
+ "hybrid": "🔎 Tìm kiếm: Hybrid"
637
+ }
638
+ method_status = method_labels.get(state.method, "🔎 Tìm kiếm: Dense")
639
+ enhance_status = "🧠 Tăng cường truy vấn (Đang tắt)" if not state.is_enhance_query else "🧠 Tăng cường truy vấn (Đang bật)"
640
+ enhanced_retrieval_status = "🖼️ Tìm bằng ảnh nâng cao (Đang tắt)" if not state.enhanced_image_retrieval else "🖼️ Tìm bằng ảnh nâng cao (Đang bật)"
641
+
642
+ return [
643
+ cl.Action(name="toggle_product_search", value="toggle_search", label=search_status, payload={"action": "toggle_search"}),
644
+ cl.Action(name="change_search_method", value="change_method", label="🔎 Đổi phương thức tìm kiếm", payload={"action": "change_method"}),
645
+ cl.Action(name="toggle_enhance_query", value="toggle_enhance", label=enhance_status, payload={"action": "toggle_enhance"}),
646
+ cl.Action(name="toggle_enhanced_image_retrieval", value="toggle_enhanced_retrieval", label=enhanced_retrieval_status, payload={"action": "toggle_enhanced_retrieval"}),
647
+ cl.Action(name="change_model", value="model", label="🔄 Đổi model", payload={"action": "model"}),
648
+ ]
649
+
650
+ @staticmethod
651
+ async def send_message_with_buttons(content: str, state: ConversationState, actions=None, author="assistant"):
652
+ """Send message with optional action buttons and author"""
653
+ if actions is None:
654
+ actions = UIService.create_action_buttons(state)
655
+ await cl.Message(
656
+ content=content,
657
+ actions=actions,
658
+ author=author
659
+ ).send()
660
+
661
+ @staticmethod
662
+ async def create_typing_animation():
663
+ """Create typing animation effect (legacy method - kept for compatibility)"""
664
+ msg = cl.Message(content="", author="assistant")
665
+ await msg.send()
666
+
667
+ # Typing animation frames
668
+ typing_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
669
+
670
+ for i in range(27): # Show animation for ~2 seconds
671
+ frame = typing_frames[i % len(typing_frames)]
672
+ msg.content = f"{frame} Đang suy nghĩ..."
673
+ await msg.update()
674
+ await asyncio.sleep(0.25)
675
+
676
+ return msg
677
+
678
+
679
+ async def run_typing_animation(msg: cl.Message):
680
+ """Run typing animation until cancelled"""
681
+ typing_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
682
+ frame_index = 0
683
+
684
+ try:
685
+ while True: # Run indefinitely until cancelled
686
+ frame = typing_frames[frame_index % len(typing_frames)]
687
+ msg.content = f"{frame} Đang suy nghĩ..."
688
+ await msg.update()
689
+ await asyncio.sleep(0.25)
690
+ frame_index += 1
691
+
692
+ except asyncio.CancelledError:
693
+ # Animation was cancelled, this is expected
694
+ print("🎬 Animation cancelled - API response received")
695
+ raise
696
+
697
+
698
+ # HELPER FUNCTIONS: Session management with proper async error handling
699
+ async def ensure_session_state() -> Optional[ConversationState]:
700
+ """Ensure session state exists, create if not"""
701
+ try:
702
+ session_id = cl.user_session.get("session_id")
703
+
704
+ if not session_id:
705
+ print(f"Lỗi: Không lấy được session id ở ensure_session_state")
706
+ return None
707
+
708
+ return await StateManager.get_or_create_session_state(session_id)
709
+
710
+ except Exception as e:
711
+ print(f"⚠️ Error ensuring session state: {e}")
712
+ return None
713
+
714
+
715
+ async def get_current_session_state() -> Optional[ConversationState]:
716
+ """Get current session state using Chainlit's session system"""
717
+ try:
718
+ # Use Chainlit's user session to get unique session ID
719
+ chainlit_session_id = cl.user_session.get("session_id")
720
+
721
+ if chainlit_session_id:
722
+ return await StateManager.get_or_create_session_state(chainlit_session_id)
723
+ else:
724
+ print("⚠️ No Chainlit session ID found")
725
+ return None
726
+ except Exception as e:
727
+ print(f"⚠️ Error getting session state: {e}")
728
+ return None
729
+
730
+
731
+ @cl.on_chat_start
732
+ async def on_chat_start():
733
+ """Initialize the chat session"""
734
+ session_id = cl.user_session.get("session_id")
735
+ if not session_id:
736
+ session_id = str(uuid.uuid4())
737
+ cl.user_session.set("session_id", session_id)
738
+ print(f"🆕 Generated new session_id: {session_id}")
739
+ else:
740
+ print(f"🔄 Reusing existing session_id: {session_id}")
741
+
742
+ app_state = await StateManager.get_or_create_session_state(session_id)
743
+
744
+ await cl.Message(
745
+ content=f"🛍️ **RangDong Sales Agent** (Session: {session_id[:8]}...)\n\n"
746
+ f"Xin chào! Tôi có thể giúp bạn tìm kiếm và tư vấn sản phẩm RangDong. Hãy thử các câu hỏi mẫu:\n\n"
747
+ f"- Tìm sản phẩm bình giữ nhiệt dung tích dưới 2 lít\n"
748
+ f"- Tìm sản phẩm ổ cắm thông minh\n"
749
+ f"- Tư vấn cho tôi đèn học chống cận cho con gái của tôi học lớp 6",
750
+ author="assistant"
751
+ ).send()
752
+
753
+ actions = UIService.create_start_buttons(app_state)
754
+ await cl.Message(
755
+ content="Sử dụng nút bên dưới để cấu hình:",
756
+ actions=actions,
757
+ author="assistant"
758
+ ).send()
759
+
760
+
761
+ @cl.on_chat_end
762
+ async def on_chat_end():
763
+ """Handle chat session end with delayed cleanup mechanism using asyncio"""
764
+ try:
765
+ session_id = cl.user_session.get("session_id")
766
+ print(f"📤 on_chat_end triggered for session {session_id}")
767
+
768
+ if session_id:
769
+ # Schedule delayed cleanup instead of immediate cleanup
770
+ # Use shorter delay (30s) since this is likely just a temporary disconnect
771
+ await StateManager.schedule_delayed_cleanup(session_id, delay_seconds=3600)
772
+ print(f"⏳ Scheduled delayed cleanup for session {session_id} (1h delay for disconnect tolerance)")
773
+ else:
774
+ print("⚠️ No session_id found in on_chat_end")
775
+ except Exception as e:
776
+ print(f"⚠️ Error during on_chat_end: {e}")
777
+
778
+
779
+ # ACTION CALLBACKS - All use ensure_session_state() for better reliability
780
+ @cl.action_callback("show_specs")
781
+ async def on_show_specs(action):
782
+ """Handle show specifications action"""
783
+ app_state = await ensure_session_state()
784
+ if app_state is None:
785
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
786
+ return
787
+
788
+ specs_content = await DisplayService.show_specs(app_state)
789
+ await UIService.send_message_with_buttons(specs_content, app_state, author="assistant")
790
+
791
+
792
+ @cl.action_callback("show_advantages")
793
+ async def on_show_advantages(action):
794
+ """Handle show advantages action"""
795
+ app_state = await ensure_session_state()
796
+ if app_state is None:
797
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
798
+ return
799
+
800
+ adv_content = await DisplayService.show_advantages(app_state)
801
+ await UIService.send_message_with_buttons(adv_content, app_state, author="assistant")
802
+
803
+
804
+ @cl.action_callback("show_packages")
805
+ async def on_show_packages(action):
806
+ """Handle show packages action"""
807
+ app_state = await ensure_session_state()
808
+ if app_state is None:
809
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
810
+ return
811
+
812
+ pkg_content = await DisplayService.show_solution_packages(app_state)
813
+ await UIService.send_message_with_buttons(pkg_content, app_state, author="assistant")
814
+
815
+ @cl.action_callback("show_all_products")
816
+ async def on_show_all_products(action):
817
+ """Handle show all products action"""
818
+ app_state = await ensure_session_state()
819
+ if app_state is None:
820
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
821
+ return
822
+
823
+ all_products_content = await DisplayService.show_all_products_table(app_state)
824
+ await UIService.send_message_with_buttons(all_products_content, app_state, author="assistant")
825
+
826
+ @cl.action_callback("toggle_product_search")
827
+ async def on_toggle_product_search(action):
828
+ """Handle toggle product model search action"""
829
+ app_state = await ensure_session_state()
830
+ if app_state is None:
831
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
832
+ return
833
+
834
+ await StateManager.toggle_product_model_search(app_state)
835
+
836
+ status_message = (
837
+ "✅ **Đã bật tìm kiếm theo mã sản phẩm**\n\n"
838
+ "Khi bạn nhắc đến mã/model cụ thể trong câu hỏi, hệ thống sẽ tìm kiếm chính xác theo mã đó."
839
+ if app_state.product_model_search
840
+ else "✅ **Đã tắt tìm kiếm theo mã sản phẩm**\n\n"
841
+ "Hệ thống sẽ tìm kiếm sản phẩm theo cách thông thường."
842
+ )
843
+
844
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
845
+
846
+
847
+ @cl.action_callback("toggle_enhanced_image_retrieval")
848
+ async def on_toggle_enhanced_image_retrieval(action):
849
+ """Handle toggle enhanced image retrieval action"""
850
+ app_state = await ensure_session_state()
851
+ if app_state is None:
852
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
853
+ return
854
+
855
+ await StateManager.toggle_enhanced_image_retrieval(app_state)
856
+
857
+ status_message = (
858
+ "✅ **Đã bật tìm bằng ảnh nâng cao**\n\n"
859
+ "Hệ thống sẽ sử dụng Gemini để phân tích kỹ hình ảnh và tạo từ khóa tìm kiếm chi tiết."
860
+ if app_state.enhanced_image_retrieval
861
+ else "✅ **Đã tắt tìm bằng ảnh nâng cao**\n\n"
862
+ "Hệ thống sẽ sử dụng tìm kiếm hình ảnh thông thường (Visual Semantic Search)."
863
+ )
864
+
865
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
866
+
867
+
868
+ @cl.action_callback("toggle_enhance_query")
869
+ async def on_toggle_enhance_query(action):
870
+ """Handle toggle enhance query action"""
871
+ app_state = await ensure_session_state()
872
+ if app_state is None:
873
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
874
+ return
875
+
876
+ await StateManager.toggle_enhance_query(app_state)
877
+
878
+ status_message = (
879
+ "✅ **Đã bật tăng cường truy vấn**\n\n"
880
+ "Hệ thống sẽ tự động cải thiện và mở rộng câu hỏi của bạn để tìm kiếm chính xác hơn."
881
+ if app_state.is_enhance_query
882
+ else "✅ **Đã tắt tăng cường truy vấn**\n\n"
883
+ "Hệ thống sẽ sử dụng câu hỏi gốc của bạn mà không cải thiện."
884
+ )
885
+
886
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
887
+
888
+
889
+ @cl.action_callback("change_search_method")
890
+ async def on_change_search_method(action):
891
+ """Handle change search method action"""
892
+ app_state = await ensure_session_state()
893
+ if app_state is None:
894
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
895
+ return
896
+
897
+ method_actions = [
898
+ cl.Action(name="select_method_dense", value="dense", label="🔎 Dense (Mặc định)", payload={"method": "dense"}),
899
+ cl.Action(name="select_method_sparse", value="sparse", label="🔎 Sparse (BM25)", payload={"method": "sparse"}),
900
+ cl.Action(name="select_method_hybrid", value="hybrid", label="🔎 Hybrid", payload={"method": "hybrid"}),
901
+ cl.Action(name="back_to_main", value="back", label="🔙 Quay lại", payload={"action": "back"})
902
+ ]
903
+
904
+ current_method_labels = {
905
+ "dense": "Dense",
906
+ "sparse": "Sparse (BM25)",
907
+ "hybrid": "Hybrid"
908
+ }
909
+ current = current_method_labels.get(app_state.method, "Dense")
910
+
911
+ await cl.Message(
912
+ content=f"**Model hiện tại**: {app_state.selected_model}\n**Tìm kiếm theo mã**: {'Đang bật' if app_state.product_model_search else 'Đang tắt'}\n**Phương thức tìm kiếm**: {current}\n**Tăng cường truy vấn**: {'Đang bật' if app_state.is_enhance_query else 'Đang tắt'}\n\nChọn phương thức tìm kiếm mới:",
913
+ actions=method_actions,
914
+ author="assistant"
915
+ ).send()
916
+
917
+
918
+ @cl.action_callback("select_method_dense")
919
+ async def on_select_method_dense(action):
920
+ app_state = await ensure_session_state()
921
+ if app_state is None:
922
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
923
+ return
924
+
925
+ app_state.method = "dense"
926
+ app_state.last_activity = datetime.now()
927
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Dense Search**\n\nHệ thống sẽ sử dụng tìm kiếm semantic vector thông thường.", app_state, author="assistant")
928
+
929
+
930
+ @cl.action_callback("select_method_sparse")
931
+ async def on_select_method_sparse(action):
932
+ app_state = await ensure_session_state()
933
+ if app_state is None:
934
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
935
+ return
936
+
937
+ app_state.method = "sparse"
938
+ app_state.last_activity = datetime.now()
939
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Sparse Search (BM25)**\n\nHệ thống sẽ sử dụng tìm kiếm từ khóa BM25.", app_state, author="assistant")
940
+
941
+
942
+ @cl.action_callback("select_method_hybrid")
943
+ async def on_select_method_hybrid(action):
944
+ app_state = await ensure_session_state()
945
+ if app_state is None:
946
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
947
+ return
948
+
949
+ app_state.method = "hybrid"
950
+ app_state.last_activity = datetime.now()
951
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Hybrid Search**\n\nHệ thống sẽ kết hợp cả Dense và Sparse vector.", app_state, author="assistant")
952
+
953
+
954
+ @cl.action_callback("change_model")
955
+ async def on_change_model(action):
956
+ """Handle model change action"""
957
+ app_state = await ensure_session_state()
958
+ if app_state is None:
959
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
960
+ return
961
+
962
+ models = ["Gemini 2.5 Flash", "Gemini 2.5 Flash Lite"]
963
+
964
+ model_actions = [
965
+ cl.Action(name=f"select_model_{i}", value=model, label=model, payload={"model": model})
966
+ for i, model in enumerate(models)
967
+ ]
968
+
969
+ model_actions.append(
970
+ cl.Action(name="back_to_main", value="back", label="🔙 Quay lại", payload={"action": "back"})
971
+ )
972
+
973
+ await cl.Message(
974
+ content=f"**Model hiện tại**: {app_state.selected_model}\n**Tìm kiếm theo mã**: {'Đang bật' if app_state.product_model_search else 'Đang tắt'}\n**Phương thức tìm kiếm**: {app_state.method}\n**Tăng cường truy vấn**: {'Đang bật' if app_state.is_enhance_query else 'Đang tắt'}\n\nChọn model mới:",
975
+ actions=model_actions,
976
+ author="assistant"
977
+ ).send()
978
+
979
+
980
+ @cl.action_callback("back_to_main")
981
+ async def on_back_to_main(action):
982
+ """Handle back to main menu action"""
983
+ app_state = await ensure_session_state()
984
+ if app_state is None:
985
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
986
+ return
987
+
988
+ actions = UIService.create_action_buttons(app_state)
989
+ await cl.Message(
990
+ content="📋 **Menu chính**\n\nSử dụng các nút bên dưới để:",
991
+ actions=actions,
992
+ author="assistant"
993
+ ).send()
994
+
995
+
996
+ @cl.action_callback("select_model_0")
997
+ async def on_select_model_0(action):
998
+ app_state = await ensure_session_state()
999
+ if app_state is None:
1000
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1001
+ return
1002
+
1003
+ await StateManager.change_model(app_state, "Gemini 2.5 Flash")
1004
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.5 Flash**", app_state, author="assistant")
1005
+
1006
+
1007
+ @cl.action_callback("select_model_1")
1008
+ async def on_select_model_1(action):
1009
+ app_state = await ensure_session_state()
1010
+ if app_state is None:
1011
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1012
+ return
1013
+
1014
+ await StateManager.change_model(app_state, "Gemini 2.5 Flash Lite")
1015
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.5 Flash Lite**", app_state, author="assistant")
1016
+
1017
+
1018
+ @cl.action_callback("select_model_2")
1019
+ async def on_select_model_2(action):
1020
+ app_state = await ensure_session_state()
1021
+ if app_state is None:
1022
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1023
+ return
1024
+
1025
+ await StateManager.change_model(app_state, "Gemini 2.5 Flash Lite")
1026
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.5 Flash Lite**", app_state, author="assistant")
1027
+
1028
+
1029
+ # DEBUG ENDPOINTS (optional - for monitoring session status)
1030
+ @cl.action_callback("debug_sessions")
1031
+ async def on_debug_sessions(action):
1032
+ """Debug action to show session status (can be added to debug builds)"""
1033
+ try:
1034
+ status = await StateManager.get_session_status()
1035
+ debug_content = "🔍 **Debug: Session Status**\n\n"
1036
+
1037
+ if not status:
1038
+ debug_content += "No active sessions."
1039
+ else:
1040
+ for session_id, info in status.items():
1041
+ debug_content += f"**Session: {session_id[:8]}...**\n"
1042
+ debug_content += f"- Pending cleanup: {info['pending_cleanup']}\n"
1043
+ debug_content += f"- Has task: {info['has_task']}\n"
1044
+ debug_content += f"- Last activity: {info['last_activity']}\n"
1045
+ debug_content += f"- Model: {info['selected_model']}\n"
1046
+ debug_content += f"- Product search: {info['product_model_search']}\n"
1047
+ debug_content += f"- Method: {info.get('method', 'dense')}\n\n"
1048
+
1049
+ await cl.Message(content=debug_content, author="assistant").send()
1050
+ except Exception as e:
1051
+ await cl.Message(content=f"Debug error: {e}", author="assistant").send()
1052
+
1053
+
1054
+ @cl.on_message
1055
+ async def main(message: cl.Message):
1056
+ """Main message handler with concurrent animation and API call"""
1057
+ app_state = await ensure_session_state()
1058
+ if app_state is None:
1059
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1060
+ return
1061
+
1062
+ # Handle images if present
1063
+ image_path = None
1064
+ if message.elements:
1065
+ for element in message.elements:
1066
+ if isinstance(element, cl.Image):
1067
+ image_path = element.path
1068
+ break
1069
+
1070
+ user_message = message.content if message.content and message.content.strip() else " "
1071
+
1072
+ # Create initial message for animation
1073
+ msg = cl.Message(content="", author="assistant")
1074
+ await msg.send()
1075
+
1076
+ # Create concurrent tasks for animation and API call
1077
+ animation_task = asyncio.create_task(run_typing_animation(msg))
1078
+ api_task = asyncio.create_task(ChatService.respond_to_chat(app_state, user_message, image_path))
1079
+
1080
+ try:
1081
+ # Wait for API response (this will complete first usually)
1082
+ response = await api_task
1083
+
1084
+ # Cancel animation task since we have the response
1085
+ animation_task.cancel()
1086
+
1087
+ # Wait a bit for graceful animation cancellation
1088
+ try:
1089
+ await asyncio.wait_for(animation_task, timeout=0.1)
1090
+ except (asyncio.CancelledError, asyncio.TimeoutError):
1091
+ pass
1092
+
1093
+ except Exception as e:
1094
+ # If API fails, cancel animation and show error
1095
+ animation_task.cancel()
1096
+ try:
1097
+ await asyncio.wait_for(animation_task, timeout=0.1)
1098
+ except (asyncio.CancelledError, asyncio.TimeoutError):
1099
+ pass
1100
+ response = f"Error: {e}"
1101
+
1102
+ # Update message with final response and buttons
1103
+ msg.content = response
1104
+ msg.actions = UIService.create_action_buttons(app_state)
1105
+ await msg.update()