Phong1 commited on
Commit
128e0c7
·
verified ·
1 Parent(s): fd69b5a

Upload chainlit_hf.py

Browse files
Files changed (1) hide show
  1. chainlit_hf.py +1100 -0
chainlit_hf.py ADDED
@@ -0,0 +1,1100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0 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.0 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
+ if not API_BASE_URL:
254
+ return "Error: API_BASE_URL not configured"
255
+
256
+ if not state.session_id:
257
+ return "Error: Session ID not initialized"
258
+
259
+ # Call API using httpx for async HTTP
260
+ try:
261
+ async with httpx.AsyncClient(timeout=600.0) as client:
262
+ if image_path:
263
+ # For image uploads, use form-data format as expected by API
264
+ with open(image_path, 'rb') as f:
265
+ files = {"image": f.read()}
266
+
267
+ data = {
268
+ "message": message,
269
+ "product_model_search": str(state.product_model_search).lower(),
270
+ "method": state.method,
271
+ "session_id": state.session_id,
272
+ "llm_model": state.selected_model,
273
+ "debug": "Normal",
274
+ "is_enhance_query": str(state.is_enhance_query).lower(),
275
+ "enhanced_image_retrieval": str(state.enhanced_image_retrieval).lower()
276
+ }
277
+
278
+ # Use multipart form data for image upload
279
+ files_dict = {"image": ("image.jpg", files["image"], "image/jpeg")}
280
+ resp = await client.post(
281
+ f"{API_BASE_URL}/chat_with_image",
282
+ files=files_dict,
283
+ data=data
284
+ )
285
+ else:
286
+ # For text messages, use form-data format as expected by API
287
+ data = {
288
+ "message": message,
289
+ "session_id": state.session_id,
290
+ "debug": "Normal",
291
+ "product_model_search": str(state.product_model_search).lower(),
292
+ "method": state.method,
293
+ "llm_model": state.selected_model,
294
+ "is_enhance_query": str(state.is_enhance_query).lower(),
295
+ "enhanced_image_retrieval": str(state.enhanced_image_retrieval).lower()
296
+ }
297
+ resp = await client.post(
298
+ f"{API_BASE_URL}/chat",
299
+ data=data # Form data format
300
+ )
301
+
302
+ if resp.status_code == 200:
303
+ j = resp.json()
304
+ response = j.get("response", "")
305
+ specs_advantages = j.get("specs_advantages")
306
+ solution_packages = j.get("solution_packages")
307
+ raw_documents = j.get("raw_documents") # This might be None from API
308
+ outputs = j.get("outputs")
309
+ else:
310
+ print(f"API Error: {resp.status_code} - {resp.text}")
311
+ response = f"Error: API status {resp.status_code}"
312
+ specs_advantages, solution_packages, raw_documents, outputs = None, None, None, None
313
+ except Exception as e:
314
+ print(f"Exception calling API: {e}")
315
+ response = f"Error calling API: {e}"
316
+ specs_advantages, solution_packages, raw_documents, outputs = None, None, None, None
317
+
318
+
319
+ # Update state
320
+ if specs_advantages is not None:
321
+ state.specs_advantages = specs_advantages
322
+ if solution_packages is not None:
323
+ state.solution_packages = solution_packages
324
+ if raw_documents is not None:
325
+ state.raw_documents = raw_documents
326
+ if outputs is not None:
327
+ state.outputs = outputs
328
+
329
+ # Filter products based on query
330
+ if state.specs_advantages is not None:
331
+ await ChatService.get_specific_product_from_query(message, state)
332
+
333
+ # NEW: Format response with 2-column grid for products
334
+ formatted_response = ChatService.format_product_grid(response)
335
+
336
+ return formatted_response
337
+
338
+ @staticmethod
339
+ def format_product_grid(response_text: str) -> str:
340
+ """Format product listings into 2-column grid while keeping other content intact"""
341
+ # Pattern to match: * **[Name](url)**\n\n ![alt](img_url)
342
+ pattern = r'\*\s+\*\*\[(.*?)\]\((.*?)\)\*\*\s*\n\s*!\[(.*?)\]\((.*?)\)'
343
+
344
+ matches = list(re.finditer(pattern, response_text))
345
+
346
+ if not matches:
347
+ # No product listings found, return original
348
+ return response_text
349
+
350
+ # Find boundaries of product section
351
+ first_match_start = matches[0].start()
352
+ last_match_end = matches[-1].end()
353
+
354
+ # Split into: intro + products + rest
355
+ intro_text = response_text[:first_match_start].strip()
356
+ rest_text = response_text[last_match_end:].strip()
357
+
358
+ # Extract all products
359
+ products = []
360
+ for match in matches:
361
+ products.append({
362
+ 'name': match.group(1),
363
+ 'url': match.group(2),
364
+ 'alt': match.group(3),
365
+ 'img': match.group(4)
366
+ })
367
+
368
+ # Build 2-column markdown table
369
+ grid_content = "\n\n"
370
+
371
+ for i in range(0, len(products), 2):
372
+ p1 = products[i]
373
+
374
+ if i + 1 < len(products):
375
+ p2 = products[i + 1]
376
+ # Two columns
377
+ grid_content += f"| **[{p1['name']}]({p1['url']})** | **[{p2['name']}]({p2['url']})** |\n"
378
+ grid_content += f"|:---:|:---:|\n"
379
+ grid_content += f"| ![{p1['alt']}]({p1['img']}) | ![{p2['alt']}]({p2['img']}) |\n\n"
380
+ else:
381
+ # Single column for last odd product
382
+ grid_content += f"| **[{p1['name']}]({p1['url']})** |\n"
383
+ grid_content += f"|:---:|\n"
384
+ grid_content += f"| ![{p1['alt']}]({p1['img']}) |\n\n"
385
+
386
+ # Reconstruct full response
387
+ return intro_text + grid_content + rest_text
388
+
389
+ @staticmethod
390
+ async def get_specific_product_from_query(query, state):
391
+ """Filter specs_advantages based on models found in query"""
392
+ specs_map = state.specs_advantages or {}
393
+ product_model_list = []
394
+
395
+ for prod_id, data in specs_map.items():
396
+ model = data.get("model", None)
397
+ if model is not None:
398
+ product_model_list.append(model)
399
+
400
+ found_models = []
401
+ for model in product_model_list:
402
+ pattern = re.escape(model)
403
+ if re.search(pattern, query, re.IGNORECASE):
404
+ found_models.append(model)
405
+
406
+ new_specs_advantages = {}
407
+ if found_models != []:
408
+ for prod_id, data in specs_map.items():
409
+ if data.get("model", None) in found_models:
410
+ new_specs_advantages[prod_id] = data
411
+
412
+ state.specs_advantages = new_specs_advantages
413
+
414
+
415
+ class DisplayService:
416
+ """Handles display-related operations with async HTTP calls"""
417
+
418
+ @staticmethod
419
+ async def show_specs(state: ConversationState) -> str:
420
+ """Generate specifications table"""
421
+ specs_map = state.specs_advantages
422
+ columns = ["Thông số"]
423
+ raw_data = []
424
+
425
+ if not specs_map:
426
+ return "📄 **Thông số kỹ thuật**\n\nKhông có thông số kỹ thuật nào."
427
+
428
+ print(specs_map)
429
+ for prod_id, data in specs_map.items():
430
+ spec = data.get("specification")
431
+ if spec is None or spec == "" or spec == "None":
432
+ 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!"
433
+ model = data.get("model", "")
434
+ url = data.get("url", "")
435
+
436
+ # Handle both products and solution packages
437
+ if url:
438
+ # full_name = f"**[{data['name']} {model}]({url})**"
439
+ full_name = f"**[{data['name']}]({url})**"
440
+ else:
441
+ # full_name = f"**{data['name']} {model}**"
442
+ full_name = f"**{data['name']}**"
443
+
444
+ if full_name not in columns:
445
+ columns.append(full_name)
446
+
447
+ if spec:
448
+ # Check if this is a solution package (contains markdown table)
449
+ if "### 📦" in spec:
450
+ # For solution packages, parse the markdown table properly
451
+ lines = spec.split('\n')
452
+ in_table = False
453
+ headers = []
454
+
455
+ for line in lines:
456
+ line = line.strip()
457
+ if '|' in line and '---' not in line and line.startswith('|') and line.endswith('|'):
458
+ cells = [cell.strip()
459
+ for cell in line.split('|')[1:-1]]
460
+
461
+ if not in_table:
462
+ # This is the header row
463
+ headers = cells
464
+ in_table = True
465
+ continue
466
+
467
+ # This is a data row
468
+ if len(cells) >= len(headers):
469
+ for i, header in enumerate(headers):
470
+ if i < len(cells):
471
+ param_name = header
472
+ param_value = cells[i]
473
+
474
+ existing_row = None
475
+ for row in raw_data:
476
+ if row["Thông số"] == param_name:
477
+ existing_row = row
478
+ break
479
+
480
+ if existing_row:
481
+ existing_row[full_name] = param_value
482
+ else:
483
+ new_row = {"Thông số": param_name}
484
+ for col in columns[1:]:
485
+ new_row[col] = ""
486
+ new_row[full_name] = param_value
487
+ raw_data.append(new_row)
488
+ elif in_table and (not line or not line.startswith('|')):
489
+ in_table = False
490
+ else:
491
+ # For products, parse specification items
492
+ items = re.split(r';|\n', spec)
493
+ for item in items:
494
+ if ":" in item:
495
+ key, value = item.split(':', 1)
496
+ spec_key = key.strip().capitalize()
497
+ if spec_key == "Vậtl iệu":
498
+ spec_key = "Vật liệu"
499
+
500
+ if "|" in spec_key:
501
+ spec_key = spec_key.strip().replace("|", "").capitalize()
502
+
503
+ existing_row = None
504
+ for row in raw_data:
505
+ if row["Thông số"] == spec_key:
506
+ existing_row = row
507
+ break
508
+
509
+ if existing_row:
510
+ existing_row[full_name] = value.strip() if value else ""
511
+ else:
512
+ new_row = {"Thông số": spec_key}
513
+ for col in columns[1:]:
514
+ new_row[col] = ""
515
+ new_row[full_name] = value.strip() if value else ""
516
+ raw_data.append(new_row)
517
+
518
+ if raw_data:
519
+ df = pd.DataFrame(raw_data, columns=columns)
520
+ df = df.fillna("").replace("None", "").replace("nan", "")
521
+ else:
522
+ df = pd.DataFrame(
523
+ [["Không có thông số kỹ thuật", "", ""]], columns=columns)
524
+
525
+ markdown_table = df.to_markdown(index=False)
526
+ return f"📄 **Thông số kỹ thuật**\n\n{markdown_table}"
527
+
528
+ @staticmethod
529
+ async def show_advantages(state: ConversationState) -> str:
530
+ """Generate advantages as bullet list instead of table"""
531
+ specs_map = state.specs_advantages
532
+
533
+ if not specs_map:
534
+ return "💡 **Ưu điểm nổi trội**\n\nKhông có ưu điểm nào."
535
+
536
+ content = "💡 **Ưu điểm nổi trội**\n\n"
537
+
538
+ for prod_id, data in specs_map.items():
539
+ # 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!")
540
+ adv = data.get("advantages")
541
+ if adv is None or adv == "" or adv == "None":
542
+ 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!"
543
+ model = data.get("model", "")
544
+ url = data.get("url", "")
545
+
546
+ # Handle both products and solution packages
547
+ if url:
548
+ full_name = f"**[{data['name']}]({url})**"
549
+ else:
550
+ full_name = f"**{data['name']}**"
551
+
552
+ content += f"### {full_name}\n"
553
+
554
+ # Split by newlines and create bullet points
555
+ advantages_list = [line.strip() for line in adv.split('\n') if line.strip()]
556
+ for advantage in advantages_list:
557
+ content += f"- {advantage}\n"
558
+ content += "\n"
559
+
560
+ return content
561
+
562
+ @staticmethod
563
+ async def show_solution_packages(state: ConversationState) -> str:
564
+ """Show solution packages in a structured format"""
565
+ packages = state.solution_packages
566
+
567
+ if not packages or packages == []:
568
+ return "📦 **Gói sản phẩm**\n\nKhông có gói sản phẩm nào"
569
+
570
+ markdown_table = "\n\n".join(packages)
571
+ return markdown_table
572
+
573
+ @staticmethod
574
+ async def show_all_products_table(state: ConversationState):
575
+ """Show all products table using async HTTP"""
576
+ outputs = state.outputs or {}
577
+
578
+ if not outputs:
579
+ return "Không có dữ liệu sản phẩm"
580
+
581
+ try:
582
+ # Updated to match API format - send outputs in request body
583
+ payload = {"outputs": outputs}
584
+ async with httpx.AsyncClient(timeout=60.0) as client:
585
+ resp = await client.post(f"{API_BASE_URL}/products_by_category", json=payload)
586
+
587
+ if resp.status_code == 200:
588
+ data = resp.json()
589
+ return data.get("markdown_table", "Không có dữ liệu sản phẩm")
590
+ else:
591
+ print(f"All products API error: {resp.status_code} - {resp.text}")
592
+ return "Không có dữ liệu sản phẩm"
593
+ except Exception as e:
594
+ print(f"Exception in show_all_products_table: {e}")
595
+ return f"Error: {e}"
596
+
597
+
598
+ class UIService:
599
+ """Handles UI-related operations"""
600
+
601
+ @staticmethod
602
+ def create_action_buttons(state: ConversationState):
603
+ """Create persistent action buttons"""
604
+ 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)"
605
+ method_labels = {
606
+ "dense": "🔎 Tìm kiếm: Dense",
607
+ "sparse": "🔎 Tìm kiếm: Sparse (BM25)",
608
+ "hybrid": "🔎 Tìm kiếm: Hybrid"
609
+ }
610
+ method_status = method_labels.get(state.method, "🔎 Tìm kiếm: Dense")
611
+ 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)"
612
+ 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)"
613
+
614
+ return [
615
+ cl.Action(name="show_specs", value="specs", label="📄 Thông số kỹ thuật", payload={"action": "specs"}),
616
+ cl.Action(name="show_advantages", value="advantages", label="💡 Ưu điểm nổi trội", payload={"action": "advantages"}),
617
+ cl.Action(name="show_packages", value="packages", label="📦 Gói sản phẩm", payload={"action": "packages"}),
618
+ cl.Action(name="show_all_products", value="all_products", label="🛒 Tất cả sản phẩm", payload={"action": "all_products"}),
619
+ cl.Action(name="toggle_product_search", value="toggle_search", label=search_status, payload={"action": "toggle_search"}),
620
+ cl.Action(name="change_search_method", value="change_method", label="🔎 Đổi phương thức tìm kiếm", payload={"action": "change_method"}),
621
+ cl.Action(name="toggle_enhance_query", value="toggle_enhance", label=enhance_status, payload={"action": "toggle_enhance"}),
622
+ cl.Action(name="toggle_enhanced_image_retrieval", value="toggle_enhanced_retrieval", label=enhanced_retrieval_status, payload={"action": "toggle_enhanced_retrieval"}),
623
+ cl.Action(name="change_model", value="model", label="🔄 Đổi model", payload={"action": "model"}),
624
+ ]
625
+
626
+ @staticmethod
627
+ def create_start_buttons(state: ConversationState):
628
+ """Create start buttons"""
629
+ 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)"
630
+ method_labels = {
631
+ "dense": "🔎 Tìm kiếm: Dense",
632
+ "sparse": "🔎 Tìm kiếm: Sparse (BM25)",
633
+ "hybrid": "🔎 Tìm kiếm: Hybrid"
634
+ }
635
+ method_status = method_labels.get(state.method, "🔎 Tìm kiếm: Dense")
636
+ 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)"
637
+ 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)"
638
+
639
+ return [
640
+ cl.Action(name="toggle_product_search", value="toggle_search", label=search_status, payload={"action": "toggle_search"}),
641
+ cl.Action(name="change_search_method", value="change_method", label="🔎 Đổi phương thức tìm kiếm", payload={"action": "change_method"}),
642
+ cl.Action(name="toggle_enhance_query", value="toggle_enhance", label=enhance_status, payload={"action": "toggle_enhance"}),
643
+ cl.Action(name="toggle_enhanced_image_retrieval", value="toggle_enhanced_retrieval", label=enhanced_retrieval_status, payload={"action": "toggle_enhanced_retrieval"}),
644
+ cl.Action(name="change_model", value="model", label="🔄 Đổi model", payload={"action": "model"}),
645
+ ]
646
+
647
+ @staticmethod
648
+ async def send_message_with_buttons(content: str, state: ConversationState, actions=None, author="assistant"):
649
+ """Send message with optional action buttons and author"""
650
+ if actions is None:
651
+ actions = UIService.create_action_buttons(state)
652
+ await cl.Message(
653
+ content=content,
654
+ actions=actions,
655
+ author=author
656
+ ).send()
657
+
658
+ @staticmethod
659
+ async def create_typing_animation():
660
+ """Create typing animation effect (legacy method - kept for compatibility)"""
661
+ msg = cl.Message(content="", author="assistant")
662
+ await msg.send()
663
+
664
+ # Typing animation frames
665
+ typing_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
666
+
667
+ for i in range(27): # Show animation for ~2 seconds
668
+ frame = typing_frames[i % len(typing_frames)]
669
+ msg.content = f"{frame} Đang suy nghĩ..."
670
+ await msg.update()
671
+ await asyncio.sleep(0.25)
672
+
673
+ return msg
674
+
675
+
676
+ async def run_typing_animation(msg: cl.Message):
677
+ """Run typing animation until cancelled"""
678
+ typing_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
679
+ frame_index = 0
680
+
681
+ try:
682
+ while True: # Run indefinitely until cancelled
683
+ frame = typing_frames[frame_index % len(typing_frames)]
684
+ msg.content = f"{frame} Đang suy nghĩ..."
685
+ await msg.update()
686
+ await asyncio.sleep(0.25)
687
+ frame_index += 1
688
+
689
+ except asyncio.CancelledError:
690
+ # Animation was cancelled, this is expected
691
+ print("🎬 Animation cancelled - API response received")
692
+ raise
693
+
694
+
695
+ # HELPER FUNCTIONS: Session management with proper async error handling
696
+ async def ensure_session_state() -> Optional[ConversationState]:
697
+ """Ensure session state exists, create if not"""
698
+ try:
699
+ session_id = cl.user_session.get("session_id")
700
+
701
+ if not session_id:
702
+ print(f"Lỗi: Không lấy được session id ở ensure_session_state")
703
+ return None
704
+
705
+ return await StateManager.get_or_create_session_state(session_id)
706
+
707
+ except Exception as e:
708
+ print(f"⚠️ Error ensuring session state: {e}")
709
+ return None
710
+
711
+
712
+ async def get_current_session_state() -> Optional[ConversationState]:
713
+ """Get current session state using Chainlit's session system"""
714
+ try:
715
+ # Use Chainlit's user session to get unique session ID
716
+ chainlit_session_id = cl.user_session.get("session_id")
717
+
718
+ if chainlit_session_id:
719
+ return await StateManager.get_or_create_session_state(chainlit_session_id)
720
+ else:
721
+ print("⚠️ No Chainlit session ID found")
722
+ return None
723
+ except Exception as e:
724
+ print(f"⚠️ Error getting session state: {e}")
725
+ return None
726
+
727
+
728
+ @cl.on_chat_start
729
+ async def on_chat_start():
730
+ """Initialize the chat session"""
731
+ session_id = cl.user_session.get("session_id")
732
+ if not session_id:
733
+ session_id = str(uuid.uuid4())
734
+ cl.user_session.set("session_id", session_id)
735
+ print(f"🆕 Generated new session_id: {session_id}")
736
+ else:
737
+ print(f"🔄 Reusing existing session_id: {session_id}")
738
+
739
+ app_state = await StateManager.get_or_create_session_state(session_id)
740
+
741
+ await cl.Message(
742
+ content=f"🛍️ **RangDong Sales Agent** (Session: {session_id[:8]}...)\n\n"
743
+ 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"
744
+ f"- Tìm sản phẩm bình giữ nhiệt dung tích dưới 2 lít\n"
745
+ f"- Tìm sản phẩm ổ cắm thông minh\n"
746
+ 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",
747
+ author="assistant"
748
+ ).send()
749
+
750
+ actions = UIService.create_start_buttons(app_state)
751
+ await cl.Message(
752
+ content="Sử dụng nút bên dưới để cấu hình:",
753
+ actions=actions,
754
+ author="assistant"
755
+ ).send()
756
+
757
+
758
+ @cl.on_chat_end
759
+ async def on_chat_end():
760
+ """Handle chat session end with delayed cleanup mechanism using asyncio"""
761
+ try:
762
+ session_id = cl.user_session.get("session_id")
763
+ print(f"📤 on_chat_end triggered for session {session_id}")
764
+
765
+ if session_id:
766
+ # Schedule delayed cleanup instead of immediate cleanup
767
+ # Use shorter delay (30s) since this is likely just a temporary disconnect
768
+ await StateManager.schedule_delayed_cleanup(session_id, delay_seconds=3600)
769
+ print(f"⏳ Scheduled delayed cleanup for session {session_id} (1h delay for disconnect tolerance)")
770
+ else:
771
+ print("⚠️ No session_id found in on_chat_end")
772
+ except Exception as e:
773
+ print(f"⚠️ Error during on_chat_end: {e}")
774
+
775
+
776
+ # ACTION CALLBACKS - All use ensure_session_state() for better reliability
777
+ @cl.action_callback("show_specs")
778
+ async def on_show_specs(action):
779
+ """Handle show specifications action"""
780
+ app_state = await ensure_session_state()
781
+ if app_state is None:
782
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
783
+ return
784
+
785
+ specs_content = await DisplayService.show_specs(app_state)
786
+ await UIService.send_message_with_buttons(specs_content, app_state, author="assistant")
787
+
788
+
789
+ @cl.action_callback("show_advantages")
790
+ async def on_show_advantages(action):
791
+ """Handle show advantages action"""
792
+ app_state = await ensure_session_state()
793
+ if app_state is None:
794
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
795
+ return
796
+
797
+ adv_content = await DisplayService.show_advantages(app_state)
798
+ await UIService.send_message_with_buttons(adv_content, app_state, author="assistant")
799
+
800
+
801
+ @cl.action_callback("show_packages")
802
+ async def on_show_packages(action):
803
+ """Handle show packages action"""
804
+ app_state = await ensure_session_state()
805
+ if app_state is None:
806
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
807
+ return
808
+
809
+ pkg_content = await DisplayService.show_solution_packages(app_state)
810
+ await UIService.send_message_with_buttons(pkg_content, app_state, author="assistant")
811
+
812
+ @cl.action_callback("show_all_products")
813
+ async def on_show_all_products(action):
814
+ """Handle show all products action"""
815
+ app_state = await ensure_session_state()
816
+ if app_state is None:
817
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
818
+ return
819
+
820
+ all_products_content = await DisplayService.show_all_products_table(app_state)
821
+ await UIService.send_message_with_buttons(all_products_content, app_state, author="assistant")
822
+
823
+ @cl.action_callback("toggle_product_search")
824
+ async def on_toggle_product_search(action):
825
+ """Handle toggle product model search action"""
826
+ app_state = await ensure_session_state()
827
+ if app_state is None:
828
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
829
+ return
830
+
831
+ await StateManager.toggle_product_model_search(app_state)
832
+
833
+ status_message = (
834
+ "✅ **Đã bật tìm kiếm theo mã sản phẩm**\n\n"
835
+ "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ã đó."
836
+ if app_state.product_model_search
837
+ else "✅ **Đã tắt tìm kiếm theo mã sản phẩm**\n\n"
838
+ "Hệ thống sẽ tìm kiếm sản phẩm theo cách thông thường."
839
+ )
840
+
841
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
842
+
843
+
844
+ @cl.action_callback("toggle_enhanced_image_retrieval")
845
+ async def on_toggle_enhanced_image_retrieval(action):
846
+ """Handle toggle enhanced image retrieval action"""
847
+ app_state = await ensure_session_state()
848
+ if app_state is None:
849
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
850
+ return
851
+
852
+ await StateManager.toggle_enhanced_image_retrieval(app_state)
853
+
854
+ status_message = (
855
+ "✅ **Đã bật tìm bằng ảnh nâng cao**\n\n"
856
+ "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."
857
+ if app_state.enhanced_image_retrieval
858
+ else "✅ **Đã tắt tìm bằng ảnh nâng cao**\n\n"
859
+ "Hệ thống sẽ sử dụng tìm kiếm hình ảnh thông thường (Visual Semantic Search)."
860
+ )
861
+
862
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
863
+
864
+
865
+ @cl.action_callback("toggle_enhance_query")
866
+ async def on_toggle_enhance_query(action):
867
+ """Handle toggle enhance query action"""
868
+ app_state = await ensure_session_state()
869
+ if app_state is None:
870
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
871
+ return
872
+
873
+ await StateManager.toggle_enhance_query(app_state)
874
+
875
+ status_message = (
876
+ "✅ **Đã bật tăng cường truy vấn**\n\n"
877
+ "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."
878
+ if app_state.is_enhance_query
879
+ else "✅ **Đã tắt tăng cường truy vấn**\n\n"
880
+ "Hệ thống sẽ sử dụng câu hỏi gốc của bạn mà không cải thiện."
881
+ )
882
+
883
+ await UIService.send_message_with_buttons(status_message, app_state, author="assistant")
884
+
885
+
886
+ @cl.action_callback("change_search_method")
887
+ async def on_change_search_method(action):
888
+ """Handle change search method action"""
889
+ app_state = await ensure_session_state()
890
+ if app_state is None:
891
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
892
+ return
893
+
894
+ method_actions = [
895
+ cl.Action(name="select_method_dense", value="dense", label="🔎 Dense (Mặc định)", payload={"method": "dense"}),
896
+ cl.Action(name="select_method_sparse", value="sparse", label="🔎 Sparse (BM25)", payload={"method": "sparse"}),
897
+ cl.Action(name="select_method_hybrid", value="hybrid", label="🔎 Hybrid", payload={"method": "hybrid"}),
898
+ cl.Action(name="back_to_main", value="back", label="🔙 Quay lại", payload={"action": "back"})
899
+ ]
900
+
901
+ current_method_labels = {
902
+ "dense": "Dense",
903
+ "sparse": "Sparse (BM25)",
904
+ "hybrid": "Hybrid"
905
+ }
906
+ current = current_method_labels.get(app_state.method, "Dense")
907
+
908
+ await cl.Message(
909
+ 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:",
910
+ actions=method_actions,
911
+ author="assistant"
912
+ ).send()
913
+
914
+
915
+ @cl.action_callback("select_method_dense")
916
+ async def on_select_method_dense(action):
917
+ app_state = await ensure_session_state()
918
+ if app_state is None:
919
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
920
+ return
921
+
922
+ app_state.method = "dense"
923
+ app_state.last_activity = datetime.now()
924
+ 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")
925
+
926
+
927
+ @cl.action_callback("select_method_sparse")
928
+ async def on_select_method_sparse(action):
929
+ app_state = await ensure_session_state()
930
+ if app_state is None:
931
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
932
+ return
933
+
934
+ app_state.method = "sparse"
935
+ app_state.last_activity = datetime.now()
936
+ 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")
937
+
938
+
939
+ @cl.action_callback("select_method_hybrid")
940
+ async def on_select_method_hybrid(action):
941
+ app_state = await ensure_session_state()
942
+ if app_state is None:
943
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
944
+ return
945
+
946
+ app_state.method = "hybrid"
947
+ app_state.last_activity = datetime.now()
948
+ 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")
949
+
950
+
951
+ @cl.action_callback("change_model")
952
+ async def on_change_model(action):
953
+ """Handle model change action"""
954
+ app_state = await ensure_session_state()
955
+ if app_state is None:
956
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
957
+ return
958
+
959
+ models = ["Gemini 2.0 Flash", "Gemini 2.5 Flash Lite", "Gemini 2.0 Flash Lite"]
960
+
961
+ model_actions = [
962
+ cl.Action(name=f"select_model_{i}", value=model, label=model, payload={"model": model})
963
+ for i, model in enumerate(models)
964
+ ]
965
+
966
+ model_actions.append(
967
+ cl.Action(name="back_to_main", value="back", label="🔙 Quay lại", payload={"action": "back"})
968
+ )
969
+
970
+ await cl.Message(
971
+ 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:",
972
+ actions=model_actions,
973
+ author="assistant"
974
+ ).send()
975
+
976
+
977
+ @cl.action_callback("back_to_main")
978
+ async def on_back_to_main(action):
979
+ """Handle back to main menu action"""
980
+ app_state = await ensure_session_state()
981
+ if app_state is None:
982
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
983
+ return
984
+
985
+ actions = UIService.create_action_buttons(app_state)
986
+ await cl.Message(
987
+ content="📋 **Menu chính**\n\nSử dụng các nút bên dưới để:",
988
+ actions=actions,
989
+ author="assistant"
990
+ ).send()
991
+
992
+
993
+ @cl.action_callback("select_model_0")
994
+ async def on_select_model_0(action):
995
+ app_state = await ensure_session_state()
996
+ if app_state is None:
997
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
998
+ return
999
+
1000
+ await StateManager.change_model(app_state, "Gemini 2.0 Flash")
1001
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.0 Flash**", app_state, author="assistant")
1002
+
1003
+
1004
+ @cl.action_callback("select_model_1")
1005
+ async def on_select_model_1(action):
1006
+ app_state = await ensure_session_state()
1007
+ if app_state is None:
1008
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1009
+ return
1010
+
1011
+ await StateManager.change_model(app_state, "Gemini 2.5 Flash Lite")
1012
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.5 Flash Lite**", app_state, author="assistant")
1013
+
1014
+
1015
+ @cl.action_callback("select_model_2")
1016
+ async def on_select_model_2(action):
1017
+ app_state = await ensure_session_state()
1018
+ if app_state is None:
1019
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1020
+ return
1021
+
1022
+ await StateManager.change_model(app_state, "Gemini 2.0 Flash Lite")
1023
+ await UIService.send_message_with_buttons("✅ Đã chuyển sang **Gemini 2.0 Flash Lite**", app_state, author="assistant")
1024
+
1025
+
1026
+ # DEBUG ENDPOINTS (optional - for monitoring session status)
1027
+ @cl.action_callback("debug_sessions")
1028
+ async def on_debug_sessions(action):
1029
+ """Debug action to show session status (can be added to debug builds)"""
1030
+ try:
1031
+ status = await StateManager.get_session_status()
1032
+ debug_content = "🔍 **Debug: Session Status**\n\n"
1033
+
1034
+ if not status:
1035
+ debug_content += "No active sessions."
1036
+ else:
1037
+ for session_id, info in status.items():
1038
+ debug_content += f"**Session: {session_id[:8]}...**\n"
1039
+ debug_content += f"- Pending cleanup: {info['pending_cleanup']}\n"
1040
+ debug_content += f"- Has task: {info['has_task']}\n"
1041
+ debug_content += f"- Last activity: {info['last_activity']}\n"
1042
+ debug_content += f"- Model: {info['selected_model']}\n"
1043
+ debug_content += f"- Product search: {info['product_model_search']}\n"
1044
+ debug_content += f"- Method: {info.get('method', 'dense')}\n\n"
1045
+
1046
+ await cl.Message(content=debug_content, author="assistant").send()
1047
+ except Exception as e:
1048
+ await cl.Message(content=f"Debug error: {e}", author="assistant").send()
1049
+
1050
+
1051
+ @cl.on_message
1052
+ async def main(message: cl.Message):
1053
+ """Main message handler with concurrent animation and API call"""
1054
+ app_state = await ensure_session_state()
1055
+ if app_state is None:
1056
+ await cl.Message(content="Error: Session state not found", author="assistant").send()
1057
+ return
1058
+
1059
+ # Handle images if present
1060
+ image_path = None
1061
+ if message.elements:
1062
+ for element in message.elements:
1063
+ if isinstance(element, cl.Image):
1064
+ image_path = element.path
1065
+ break
1066
+
1067
+ # Create initial message for animation
1068
+ msg = cl.Message(content="", author="assistant")
1069
+ await msg.send()
1070
+
1071
+ # Create concurrent tasks for animation and API call
1072
+ animation_task = asyncio.create_task(run_typing_animation(msg))
1073
+ api_task = asyncio.create_task(ChatService.respond_to_chat(app_state, message.content, image_path))
1074
+
1075
+ try:
1076
+ # Wait for API response (this will complete first usually)
1077
+ response = await api_task
1078
+
1079
+ # Cancel animation task since we have the response
1080
+ animation_task.cancel()
1081
+
1082
+ # Wait a bit for graceful animation cancellation
1083
+ try:
1084
+ await asyncio.wait_for(animation_task, timeout=0.1)
1085
+ except (asyncio.CancelledError, asyncio.TimeoutError):
1086
+ pass
1087
+
1088
+ except Exception as e:
1089
+ # If API fails, cancel animation and show error
1090
+ animation_task.cancel()
1091
+ try:
1092
+ await asyncio.wait_for(animation_task, timeout=0.1)
1093
+ except (asyncio.CancelledError, asyncio.TimeoutError):
1094
+ pass
1095
+ response = f"Error: {e}"
1096
+
1097
+ # Update message with final response and buttons
1098
+ msg.content = response
1099
+ msg.actions = UIService.create_action_buttons(app_state)
1100
+ await msg.update()