SergioI1991 commited on
Commit
9bc627e
·
verified ·
1 Parent(s): 9320b06

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +885 -424
app.py CHANGED
@@ -1,424 +1,885 @@
1
- from flask import Flask, request, send_file, abort, jsonify, url_for, render_template, Response
2
- from flask_cors import CORS
3
- import pandas as pd
4
- from sentence_transformers import SentenceTransformer, util
5
- import torch
6
- from dataclasses import dataclass
7
- from typing import List, Dict, Tuple, Optional, Any, Iterator
8
- from collections import deque
9
- import os
10
- import logging
11
- import atexit
12
- from threading import Thread, Lock
13
- import time
14
- from datetime import datetime
15
- from uuid import uuid4 as generate_uuid
16
- import csv as csv_lib
17
- import functools
18
- import json
19
- import re
20
- import subprocess
21
- import sys
22
- import sqlite3
23
- import io
24
- from dotenv import load_dotenv
25
-
26
- # Cargar variables de entorno (API Keys, etc.)
27
- load_dotenv()
28
-
29
- # Importaciones de tus módulos de lógica de IA (deben estar en la misma carpeta)
30
- try:
31
- from llm_handling import (
32
- initialize_and_get_rag_system,
33
- KnowledgeRAG,
34
- groq_bot_instance,
35
- RAG_SOURCES_DIR,
36
- RAG_STORAGE_PARENT_DIR,
37
- RAG_CHUNKED_SOURCES_FILENAME,
38
- get_answer_from_context
39
- )
40
- from system_prompts import QA_FORMATTER_PROMPT
41
- except ImportError:
42
- logging.error("No se pudieron importar los módulos llm_handling.py o system_prompts.py")
43
-
44
- # Configuración de Logs
45
- logging.basicConfig(
46
- level=logging.INFO,
47
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
48
- handlers=[
49
- logging.FileHandler("app_hybrid_rag.log"),
50
- logging.StreamHandler()
51
- ]
52
- )
53
- logger = logging.getLogger(__name__)
54
-
55
- # --- Configuración de la Aplicación ---
56
- ADMIN_USERNAME = os.getenv('FLASK_ADMIN_USERNAME', 'admin')
57
- ADMIN_PASSWORD = os.getenv('FLASK_ADMIN_PASSWORD', 'admin')
58
- FLASK_APP_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
59
- FLASK_APP_PORT = int(os.getenv("FLASK_PORT", "7860"))
60
- FLASK_DEBUG_MODE = os.getenv("FLASK_DEBUG", "True").lower() == "true"
61
- _APP_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
62
- TEXT_EXTRACTIONS_DIR = os.path.join(_APP_BASE_DIR, 'text_extractions')
63
-
64
- RELATED_QUESTIONS_TO_SHOW = 10
65
- QUESTIONS_TO_SEND_TO_GROQ_QA = 3
66
- LLM_FORMATTER_CONFIDENCE_THRESHOLD = int(os.getenv("LLM_FORMATTER_CONFIDENCE_THRESHOLD", "95"))
67
- HIGH_CONFIDENCE_THRESHOLD = 90
68
- CHAT_HISTORY_TO_SEND = int(os.getenv("CHAT_HISTORY_TO_SEND", "5"))
69
-
70
- rag_system: Optional[Any] = None
71
-
72
- # --- Utilidad de Limpieza de Texto (Evita exceso de asteriscos) ---
73
- def sanitize_llm_output(text: str) -> str:
74
- """
75
- Limpia el texto para que el renderizado de Markdown sea profesional.
76
- Evita artefactos como triples asteriscos o espacios excesivos,
77
- pero preserva el formato de tablas de Markdown.
78
- """
79
- if not text or not isinstance(text, str):
80
- return text
81
-
82
- # 1. Corregir errores de formato (Ej: '***' -> '**')
83
- text = text.replace('***', '**')
84
-
85
- # 2. Asegurar que las listas tengan espacio (Ej: '*item' -> '* item')
86
- text = re.sub(r'(^|\n)\s*\*\s*(\w)', r'\1* \2', text)
87
-
88
- # 3. Eliminar saltos de línea excesivos (más de 2)
89
- text = re.sub(r'\n{3,}', '\n\n', text)
90
-
91
- # 4. Eliminar espacios múltiples SOLO si no parecen ser parte de una tabla
92
- # Las tablas de Markdown suelen usar '|' y '-'
93
- lines = text.split('\n')
94
- sanitized_lines = []
95
- for line in lines:
96
- if '|' in line or (line.strip().startswith('|-') or line.strip().startswith('-|')):
97
- # Es una línea de tabla, preservamos espacios
98
- sanitized_lines.append(line)
99
- else:
100
- # No es tabla, podemos normalizar espacios
101
- sanitized_lines.append(re.sub(r' +', ' ', line))
102
-
103
- return "\n".join(sanitized_lines).strip()
104
-
105
- # --- Gestión de Historial con SQLite ---
106
- class ChatHistoryManager:
107
- def __init__(self, db_path):
108
- self.db_path = db_path
109
- self.lock = Lock()
110
- self._create_table()
111
- logger.info(f"SQLite chat history manager initialized at: {self.db_path}")
112
-
113
- def _get_connection(self):
114
- return sqlite3.connect(self.db_path, timeout=10)
115
-
116
- def _create_table(self):
117
- with self.lock:
118
- with self._get_connection() as conn:
119
- cursor = conn.cursor()
120
- cursor.execute("""
121
- CREATE TABLE IF NOT EXISTS chat_histories (
122
- session_id TEXT PRIMARY KEY,
123
- history TEXT NOT NULL
124
- )
125
- """)
126
- conn.commit()
127
-
128
- def get_history(self, session_id: str, limit_turns: int = 5) -> list:
129
- try:
130
- with self._get_connection() as conn:
131
- cursor = conn.cursor()
132
- cursor.execute("SELECT history FROM chat_histories WHERE session_id = ?", (session_id,))
133
- row = cursor.fetchone()
134
- if row:
135
- history_list = json.loads(row[0])
136
- return history_list[-(limit_turns * 2):]
137
- return []
138
- except Exception as e:
139
- logger.error(f"Error fetching history para {session_id}: {e}")
140
- return []
141
-
142
- def update_history(self, session_id: str, query: str, answer: str):
143
- with self.lock:
144
- try:
145
- with self._get_connection() as conn:
146
- cursor = conn.cursor()
147
- cursor.execute("SELECT history FROM chat_histories WHERE session_id = ?", (session_id,))
148
- row = cursor.fetchone()
149
-
150
- history = json.loads(row[0]) if row else []
151
- history.append({'role': 'user', 'content': query})
152
- history.append({'role': 'assistant', 'content': answer})
153
-
154
- updated_history_json = json.dumps(history)
155
- cursor.execute("""
156
- INSERT OR REPLACE INTO chat_histories (session_id, history)
157
- VALUES (?, ?)
158
- """, (session_id, updated_history_json))
159
- conn.commit()
160
- except Exception as e:
161
- logger.error(f"Error updating history para {session_id}: {e}")
162
-
163
- def clear_history(self, session_id: str):
164
- with self.lock:
165
- try:
166
- with self._get_connection() as conn:
167
- cursor = conn.cursor()
168
- cursor.execute("""
169
- INSERT OR REPLACE INTO chat_histories (session_id, history)
170
- VALUES (?, ?)
171
- """, (session_id, json.dumps([])))
172
- conn.commit()
173
- except Exception as e:
174
- logger.error(f"Error clearing history para {session_id}: {e}")
175
-
176
- # --- EmbeddingManager para QA en CSV ---
177
- @dataclass
178
- class QAEmbeddings:
179
- questions: List[str]
180
- question_map: List[int]
181
- embeddings: torch.Tensor
182
- df_qa: pd.DataFrame
183
- original_questions: List[str]
184
-
185
- class EmbeddingManager:
186
- def __init__(self, model_name='all-MiniLM-L6-v2'):
187
- self.model = SentenceTransformer(model_name)
188
- self.embeddings = {'general': None, 'personal': None, 'greetings': None}
189
- logger.info(f"EmbeddingManager initialized with model: {model_name}")
190
-
191
- def _process_questions(self, df: pd.DataFrame) -> Tuple[List[str], List[int], List[str]]:
192
- questions, question_map, original_questions = [], [], []
193
- if 'Question' not in df.columns:
194
- return questions, question_map, original_questions
195
-
196
- for idx, question_text_raw in enumerate(df['Question']):
197
- if pd.isna(question_text_raw): continue
198
- question_text_cleaned = str(question_text_raw).strip()
199
- if not question_text_cleaned or question_text_cleaned.lower() == "nan": continue
200
- questions.append(question_text_cleaned)
201
- question_map.append(idx)
202
- original_questions.append(question_text_cleaned)
203
-
204
- return questions, question_map, original_questions
205
-
206
- def update_embeddings(self, general_qa: pd.DataFrame, personal_qa: pd.DataFrame, greetings_qa: pd.DataFrame):
207
- for name, df in [('general', general_qa), ('personal', personal_qa), ('greetings', greetings_qa)]:
208
- qs, qmap, orig = self._process_questions(df)
209
- embs = self.model.encode(qs, convert_to_tensor=True, show_progress_bar=False) if qs else None
210
- self.embeddings[name] = QAEmbeddings(qs, qmap, embs, df, orig)
211
- logger.info("CSV QA embeddings updated.")
212
-
213
- def find_best_answers(self, user_query: str, qa_type: str, top_n: int = 5):
214
- qa_data = self.embeddings.get(qa_type)
215
- if not qa_data or qa_data.embeddings is None or len(qa_data.embeddings) == 0:
216
- return [], [], [], [], []
217
-
218
- query_embedding_tensor = self.model.encode([user_query], convert_to_tensor=True, show_progress_bar=False)
219
- cos_scores = util.cos_sim(query_embedding_tensor, qa_data.embeddings)[0]
220
- top_k = min(top_n, len(cos_scores))
221
-
222
- if top_k == 0: return [], [], [], [], []
223
-
224
- top_scores_tensor, indices_tensor = torch.topk(cos_scores, k=top_k)
225
- top_confidences = [score.item() * 100 for score in top_scores_tensor]
226
-
227
- top_indices_mapped, top_questions, top_answers, top_images = [], [], [], []
228
- answer_col = 'Respuesta' if 'Respuesta' in qa_data.df_qa.columns else 'Answer'
229
-
230
- for score_idx, idx_tensor in enumerate(indices_tensor):
231
- item_idx = idx_tensor.item()
232
- original_df_idx = qa_data.question_map[item_idx]
233
-
234
- top_indices_mapped.append(original_df_idx)
235
- top_questions.append(qa_data.original_questions[item_idx])
236
- top_answers.append(str(qa_data.df_qa[answer_col].iloc[original_df_idx]))
237
-
238
- img = qa_data.df_qa['Image'].iloc[original_df_idx] if 'Image' in qa_data.df_qa.columns else None
239
- top_images.append(str(img) if pd.notna(img) else None)
240
-
241
- return top_confidences, top_questions, top_answers, top_images, top_indices_mapped
242
-
243
- # --- Inicialización de Flask ---
244
- app = Flask(__name__)
245
- CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
246
-
247
- embedding_manager = EmbeddingManager()
248
- history_manager = ChatHistoryManager(os.path.join(_APP_BASE_DIR, 'chat_history.db'))
249
-
250
- def clean_html_from_text(text: str) -> str:
251
- if not isinstance(text, str): return text
252
- return re.sub(r'<[^>]+>', '', text).strip()
253
-
254
- def replace_placeholders_in_answer(answer, db_data=None):
255
- if pd.isna(answer) or str(answer).strip() == '':
256
- return "Sorry, this information is not available yet"
257
- answer_str = str(answer)
258
- placeholders = re.findall(r'\{(\w+)\}', answer_str)
259
- if not placeholders: return answer_str
260
-
261
- # Lógica simplificada de reemplazo para evitar errores si db_data es None
262
- for placeholder in set(placeholders):
263
- key = placeholder.strip()
264
- val = db_data.get(key) if db_data else "no disponible"
265
- answer_str = answer_str.replace(f'{{{key}}}', str(val))
266
- return answer_str
267
-
268
- # --- Lógica Principal del Chatbot ---
269
- def get_hybrid_response_logic_non_streaming(user_query: str, session_id: str, user_id: Optional[str], chat_history=None) -> Dict[str, Any]:
270
- global rag_system
271
-
272
- if not user_query: return {'error': 'No query provided'}
273
- if not session_id: return {'error': 'session_id is required'}
274
-
275
- # 1. Búsqueda en Saludos
276
- conf_greet, q_greet, a_greet, img_greet, idx_greet = embedding_manager.find_best_answers(user_query, 'greetings', top_n=1)
277
- if conf_greet and conf_greet[0] >= HIGH_CONFIDENCE_THRESHOLD:
278
- ans = sanitize_llm_output(a_greet[0])
279
- history_manager.update_history(session_id, user_query, ans)
280
- return {'query': user_query, 'answer': ans, 'confidence': conf_greet[0], 'source': 'greetings'}
281
-
282
- # 2. Búsqueda en CSV/Excel (Personal y General)
283
- conf_pers, q_pers, a_pers, img_pers, idx_pers = embedding_manager.find_best_answers(user_query, 'personal', top_n=1)
284
- conf_gen, q_gen, a_gen, img_gen, idx_gen = embedding_manager.find_best_answers(user_query, 'general', top_n=1)
285
-
286
- candidates = []
287
- if conf_pers: candidates.append({'ans': a_pers[0], 'conf': conf_pers[0], 'type': 'personal', 'idx': idx_pers[0], 'q': q_pers[0]})
288
- if conf_gen: candidates.append({'ans': a_gen[0], 'conf': conf_gen[0], 'type': 'general', 'idx': idx_gen[0], 'q': q_gen[0]})
289
-
290
- if candidates:
291
- best_candidate = max(candidates, key=lambda x: x['conf'])
292
- if best_candidate['conf'] >= LLM_FORMATTER_CONFIDENCE_THRESHOLD:
293
- # Formatear la respuesta usando el LLM a partir del contexto del Excel
294
- source_type = best_candidate['type']
295
- original_df = embedding_manager.embeddings[source_type].df_qa
296
- matched_row_data = original_df.iloc[best_candidate['idx']]
297
-
298
- context_dict = matched_row_data.drop('Question', errors='ignore').to_dict()
299
- context_str = "\n".join([f"'{k}': '{v}'" for k, v in context_dict.items() if pd.notna(v) and str(v).strip() != ''])
300
-
301
- try:
302
- final_answer = get_answer_from_context(question=user_query, context=context_str, system_prompt=QA_FORMATTER_PROMPT)
303
- # Aplicamos el filtro de limpieza para quitar los asteriscos feos
304
- final_answer = sanitize_llm_output(final_answer)
305
- except Exception as e:
306
- logger.error(f"Error formating with LLM: {e}")
307
- final_answer = sanitize_llm_output(best_candidate['ans'])
308
-
309
- history_manager.update_history(session_id, user_query, final_answer)
310
- return {'query': user_query, 'answer': final_answer, 'confidence': best_candidate['conf'], 'source': f'{source_type}_qa_formatted'}
311
-
312
- # 3. Búsqueda RAG (FAISS)
313
- if rag_system and hasattr(rag_system, 'retriever') and rag_system.retriever:
314
- logger.info(f"Attempting FAISS RAG query for: {user_query[:50]}...")
315
- rag_result = rag_system.invoke(user_query)
316
- rag_answer = rag_result.get("answer")
317
-
318
- if rag_answer and "does not contain specific information" not in rag_answer.lower() and "no contiene" not in rag_answer.lower():
319
- rag_answer = sanitize_llm_output(rag_answer)
320
- history_manager.update_history(session_id, user_query, rag_answer)
321
- return {'query': user_query, 'answer': rag_answer, 'confidence': 85, 'source': 'document_rag_faiss'}
322
-
323
- # 4. Fallback Groq (Memoria general)
324
- logger.info(f"No high-confidence answer. Using Groq fallback.")
325
- chat_hist = chat_history if chat_history is not None else history_manager.get_history(session_id)
326
-
327
- try:
328
- groq_stream = groq_bot_instance.stream_response({'current_query': user_query, 'chat_history': chat_hist, 'qa_related_info': ""})
329
- groq_answer = "".join([chunk for chunk in groq_stream])
330
- groq_answer = sanitize_llm_output(groq_answer)
331
- except Exception as e:
332
- logger.error(f"Groq fallback failed: {e}")
333
- groq_answer = "Lo siento, estoy teniendo problemas de conexión. Por favor intenta de nuevo más tarde."
334
-
335
- history_manager.update_history(session_id, user_query, groq_answer)
336
- return {'query': user_query, 'answer': groq_answer, 'confidence': 75, 'source': 'groq_general_fallback'}
337
-
338
-
339
- # --- Endpoints de la API ---
340
- @app.route('/')
341
- def index_route():
342
- return render_template('chat-bot.html')
343
-
344
- @app.route('/create-session', methods=['POST', 'GET'])
345
- def create_session_route():
346
- try:
347
- session_id = str(generate_uuid())
348
- logger.info(f"New session created: {session_id}")
349
- return jsonify({'status': 'success', 'session_id': session_id}), 200
350
- except Exception as e:
351
- logger.error(f"Session creation error: {e}", exc_info=True)
352
- return jsonify({'status': 'error', 'message': str(e)}), 500
353
-
354
- @app.route('/version', methods=['GET'])
355
- def get_version_route():
356
- return jsonify({'version': '3.9.9-Clean-Output'}), 200
357
-
358
- @app.route('/chat-bot', methods=['POST'])
359
- def get_answer_hybrid():
360
- data = request.json
361
- user_query = clean_html_from_text(data.get('query', ''))
362
- user_id = data.get('user_id')
363
- session_id = data.get('session_id')
364
-
365
- if not user_query or not session_id:
366
- return jsonify({'error': 'query and session_id are required'}), 400
367
-
368
- response_data = get_hybrid_response_logic_non_streaming(user_query, session_id, user_id, None)
369
- return jsonify(response_data)
370
-
371
- @app.route('/clear-history', methods=['POST'])
372
- def clear_session_history_route():
373
- data = request.json
374
- session_id = data.get('session_id')
375
- if not session_id:
376
- return jsonify({'status': 'error', 'message': 'session_id is required'}), 400
377
- history_manager.clear_history(session_id)
378
- return jsonify({'status': 'success', 'message': f'History cleared for session {session_id}'})
379
-
380
-
381
- # --- Startup ---
382
- def load_qa_data_on_startup():
383
- global embedding_manager
384
- logger.info("Cargando archivos CSV de origen...")
385
- try:
386
- # Aseguramos de que RAG_SOURCES_DIR exista o usamos una carpeta temporal
387
- sources_dir = globals().get('RAG_SOURCES_DIR', os.path.join(_APP_BASE_DIR, 'sources'))
388
- general_qa_df = pd.DataFrame(columns=['Question', 'Answer', 'Image'])
389
- personal_qa_df = pd.DataFrame(columns=['Question', 'Answer', 'Image'])
390
- greetings_qa_df = pd.DataFrame(columns=['Question', 'Answer', 'Image'])
391
-
392
- gen_path = os.path.join(sources_dir, 'general_qa.csv')
393
- pers_path = os.path.join(sources_dir, 'personal_qa.csv')
394
- greet_path = os.path.join(sources_dir, 'greetings.csv')
395
-
396
- if os.path.exists(gen_path): general_qa_df = pd.read_csv(gen_path, encoding='cp1252')
397
- if os.path.exists(pers_path): personal_qa_df = pd.read_csv(pers_path, encoding='cp1252')
398
- if os.path.exists(greet_path): greetings_qa_df = pd.read_csv(greet_path, encoding='cp1252')
399
-
400
- # Estandarizamos los nombres de columnas
401
- for df in [general_qa_df, personal_qa_df, greetings_qa_df]:
402
- if 'Pregunta' in df.columns and 'Question' not in df.columns:
403
- df['Question'] = df['Pregunta']
404
-
405
- embedding_manager.update_embeddings(general_qa_df, personal_qa_df, greetings_qa_df)
406
- logger.info("Bases de datos cargadas y vectorizadas correctamente.")
407
- except Exception as e:
408
- logger.error(f"Error cargando bases de datos CSV: {e}", exc_info=True)
409
-
410
-
411
- if __name__ == '__main__':
412
- # Crear carpetas necesarias
413
- for folder_path in [os.path.join(_APP_BASE_DIR, 'templates'), os.path.join(_APP_BASE_DIR, 'static')]:
414
- os.makedirs(folder_path, exist_ok=True)
415
-
416
- load_qa_data_on_startup()
417
-
418
- try:
419
- rag_system = initialize_and_get_rag_system()
420
- logger.info("RAG system initialized.")
421
- except Exception as e:
422
- logger.warning(f"RAG system no pudo inicializarse: {e}")
423
-
424
- app.run(host=FLASK_APP_HOST, port=FLASK_APP_PORT, debug=FLASK_DEBUG_MODE, use_reloader=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>FarmaBot - Endocaser | Bilingual Dental Pharmacology Assistant</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ <style>
10
+ /* Estilos para tablas Markdown */
11
+ .message-content table {
12
+ border-collapse: collapse;
13
+ width: 100%;
14
+ margin: 12px 0;
15
+ font-size: 13px;
16
+ background: #fff;
17
+ border-radius: 8px;
18
+ overflow-x: auto;
19
+ border: 1px solid var(--border);
20
+ display: table;
21
+ }
22
+ .message-content table tbody, .message-content table thead {
23
+ display: table-row-group;
24
+ width: 100%;
25
+ }
26
+ .message-content th, .message-content td {
27
+ padding: 10px 12px;
28
+ text-align: left;
29
+ border: 1px solid var(--border);
30
+ word-break: break-word;
31
+ }
32
+ .message-content th {
33
+ background-color: var(--primary-surface);
34
+ color: var(--primary-dark);
35
+ font-weight: 600;
36
+ white-space: normal;
37
+ }
38
+ .message-content tr:nth-child(even) {
39
+ background-color: #f9fafb;
40
+ }
41
+ /* Modo tarjetas para móvil */
42
+ @media (max-width: 480px) {
43
+ .message-content table {
44
+ display: block;
45
+ border: none;
46
+ margin: 8px 0;
47
+ }
48
+ .message-content table thead {
49
+ display: none;
50
+ }
51
+ .message-content table tbody {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 12px;
55
+ }
56
+ .message-content table tr {
57
+ display: flex;
58
+ flex-direction: column;
59
+ border: 1px solid var(--border);
60
+ border-radius: 8px;
61
+ padding: 12px;
62
+ background: #fff;
63
+ margin-bottom: 0;
64
+ }
65
+ .message-content table tr:nth-child(even) {
66
+ background-color: #fff;
67
+ }
68
+ .message-content table td {
69
+ display: flex;
70
+ justify-content: space-between;
71
+ align-items: flex-start;
72
+ border: none;
73
+ padding: 8px 0;
74
+ border-bottom: 1px solid var(--border);
75
+ }
76
+ .message-content table td:last-child {
77
+ border-bottom: none;
78
+ }
79
+ .message-content table td::before {
80
+ content: attr(data-label);
81
+ font-weight: 600;
82
+ color: var(--primary-dark);
83
+ margin-right: 12px;
84
+ flex-shrink: 0;
85
+ }
86
+ }
87
+ .message-content ul, .message-content ol {
88
+ padding-left: 20px;
89
+ margin: 8px 0;
90
+ }
91
+ .message-content li {
92
+ margin-bottom: 4px;
93
+ }
94
+ .message-content strong {
95
+ color: var(--primary-dark);
96
+ }
97
+ .message-content p {
98
+ margin: 0 0 8px 0;
99
+ word-wrap: break-word;
100
+ overflow-wrap: break-word;
101
+ }
102
+ :root {
103
+ --primary: #4574E8;
104
+ --primary-light: #6B93F2;
105
+ --primary-dark: #2B4FAD;
106
+ --primary-surface: #EEF2FD;
107
+ --bg: #F7F8FC;
108
+ --surface: #FFFFFF;
109
+ --text-primary: #1A1D26;
110
+ --text-secondary: #6B7280;
111
+ --border: #E5E7EB;
112
+ --hint: #9CA3AF;
113
+ --success: #10B981;
114
+ --error: #EF4444;
115
+ --bot-bg: #F3F4F6;
116
+ --user-bg: #EEF2FD;
117
+ --sidebar-bg: #1A2332;
118
+ }
119
+
120
+ * { box-sizing: border-box; margin: 0; padding: 0; }
121
+
122
+ body {
123
+ font-family: 'Inter', sans-serif;
124
+ background: var(--bg);
125
+ color: var(--text-primary);
126
+ height: 100vh;
127
+ overflow: hidden;
128
+ }
129
+
130
+ .chat-container {
131
+ height: 100vh;
132
+ display: flex;
133
+ flex-direction: column;
134
+ }
135
+
136
+ /* Header */
137
+ .chat-header {
138
+ background: linear-gradient(135deg, var(--primary), var(--primary-light));
139
+ color: #fff;
140
+ padding: 16px 24px;
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 14px;
144
+ flex-shrink: 0;
145
+ box-shadow: 0 4px 20px rgba(69, 116, 232, 0.25);
146
+ }
147
+
148
+ .header-logo {
149
+ width: 40px;
150
+ height: 40px;
151
+ background: rgba(255,255,255,0.2);
152
+ border-radius: 12px;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ font-size: 20px;
157
+ }
158
+
159
+ .header-info { flex: 1; }
160
+ .header-info h2 {
161
+ font-size: 18px;
162
+ font-weight: 700;
163
+ letter-spacing: -0.3px;
164
+ }
165
+ .header-info span {
166
+ font-size: 12px;
167
+ opacity: 0.75;
168
+ font-weight: 400;
169
+ }
170
+
171
+ .header-buttons { display: flex; gap: 8px; }
172
+ .icon-btn {
173
+ background: rgba(255,255,255,0.15);
174
+ border: none;
175
+ color: #fff;
176
+ width: 36px;
177
+ height: 36px;
178
+ border-radius: 10px;
179
+ cursor: pointer;
180
+ font-size: 14px;
181
+ transition: all 0.2s;
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ }
186
+ .icon-btn:hover { background: rgba(255,255,255,0.25); transform: translateY(-1px); }
187
+
188
+ /* Messages */
189
+ .chat-messages {
190
+ flex: 1;
191
+ padding: 24px;
192
+ overflow-y: auto;
193
+ background: var(--bg);
194
+ }
195
+
196
+ .message {
197
+ margin-bottom: 16px;
198
+ display: flex;
199
+ max-width: 85%;
200
+ animation: fadeIn 0.3s ease;
201
+ word-wrap: break-word;
202
+ overflow-wrap: break-word;
203
+ }
204
+
205
+ @keyframes fadeIn {
206
+ from { opacity: 0; transform: translateY(8px); }
207
+ to { opacity: 1; transform: translateY(0); }
208
+ }
209
+
210
+ .message.user { margin-left: auto; }
211
+ .message.bot { margin-right: auto; }
212
+
213
+ .bot-avatar {
214
+ width: 32px;
215
+ height: 32px;
216
+ background: var(--primary);
217
+ border-radius: 10px;
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ color: #fff;
222
+ font-size: 14px;
223
+ margin-right: 10px;
224
+ flex-shrink: 0;
225
+ margin-top: 2px;
226
+ }
227
+
228
+ .message-content {
229
+ padding: 12px 16px;
230
+ border-radius: 16px;
231
+ line-height: 1.6;
232
+ font-size: 14px;
233
+ min-width: 0;
234
+ word-wrap: break-word;
235
+ overflow-wrap: break-word;
236
+ }
237
+
238
+ .message.user .message-content {
239
+ background: var(--primary);
240
+ color: #fff;
241
+ border-bottom-right-radius: 4px;
242
+ box-shadow: 0 2px 8px rgba(69, 116, 232, 0.2);
243
+ word-wrap: break-word;
244
+ overflow-wrap: break-word;
245
+ }
246
+
247
+ .message.bot .message-content {
248
+ background: var(--surface);
249
+ border: 1px solid var(--border);
250
+ border-bottom-left-radius: 4px;
251
+ box-shadow: 0 1px 4px rgba(0,0,0,0.04);
252
+ word-wrap: break-word;
253
+ overflow-wrap: break-word;
254
+ }
255
+
256
+ .message-content p { margin: 0; }
257
+ .message-content p + p { margin-top: 8px; }
258
+
259
+ /* Typing indicator */
260
+ .typing-indicator {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 4px;
264
+ padding: 4px 0;
265
+ }
266
+ .typing-indicator span {
267
+ width: 7px;
268
+ height: 7px;
269
+ background: var(--primary);
270
+ border-radius: 50%;
271
+ animation: bounce 1.3s linear infinite;
272
+ }
273
+ @keyframes bounce {
274
+ 0%, 60%, 100% { transform: translateY(0); }
275
+ 30% { transform: translateY(-6px); }
276
+ }
277
+
278
+ /* Welcome message */
279
+ .welcome-card {
280
+ background: var(--surface);
281
+ border: 1px solid var(--border);
282
+ border-radius: 16px;
283
+ padding: 24px;
284
+ text-align: center;
285
+ max-width: 400px;
286
+ margin: 40px auto;
287
+ box-shadow: 0 2px 12px rgba(0,0,0,0.04);
288
+ }
289
+ .welcome-card .icon {
290
+ width: 56px;
291
+ height: 56px;
292
+ background: var(--primary-surface);
293
+ border-radius: 16px;
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: center;
297
+ margin: 0 auto 16px;
298
+ font-size: 24px;
299
+ color: var(--primary);
300
+ }
301
+ .welcome-card h3 {
302
+ font-size: 18px;
303
+ font-weight: 700;
304
+ margin-bottom: 6px;
305
+ color: var(--text-primary);
306
+ }
307
+ .welcome-card p {
308
+ font-size: 13px;
309
+ color: var(--text-secondary);
310
+ line-height: 1.5;
311
+ }
312
+ .language-selector {
313
+ display: flex;
314
+ gap: 8px;
315
+ justify-content: center;
316
+ margin-top: 16px;
317
+ }
318
+ .lang-btn {
319
+ padding: 8px 16px;
320
+ border: 1px solid var(--primary);
321
+ background: transparent;
322
+ color: var(--primary);
323
+ border-radius: 20px;
324
+ cursor: pointer;
325
+ font-weight: 500;
326
+ font-size: 12px;
327
+ transition: all 0.2s;
328
+ }
329
+ .lang-btn.active {
330
+ background: var(--primary);
331
+ color: #fff;
332
+ }
333
+ .lang-btn:hover {
334
+ background: var(--primary);
335
+ color: #fff;
336
+ }
337
+ .quick-actions {
338
+ display: flex;
339
+ flex-wrap: wrap;
340
+ gap: 8px;
341
+ justify-content: center;
342
+ margin-top: 16px;
343
+ }
344
+ .quick-action {
345
+ background: var(--primary-surface);
346
+ color: var(--primary);
347
+ border: 1px solid rgba(69, 116, 232, 0.15);
348
+ padding: 8px 14px;
349
+ border-radius: 20px;
350
+ font-size: 12px;
351
+ font-weight: 500;
352
+ cursor: pointer;
353
+ transition: all 0.2s;
354
+ }
355
+ .quick-action:hover {
356
+ background: var(--primary);
357
+ color: #fff;
358
+ transform: translateY(-1px);
359
+ }
360
+
361
+ /* Input area */
362
+ .chat-input {
363
+ padding: 16px 24px;
364
+ background: var(--surface);
365
+ border-top: 1px solid var(--border);
366
+ display: flex;
367
+ align-items: flex-end;
368
+ gap: 10px;
369
+ flex-shrink: 0;
370
+ }
371
+
372
+ .chat-input textarea {
373
+ flex: 1;
374
+ padding: 12px 16px;
375
+ border: 1px solid var(--border);
376
+ border-radius: 24px;
377
+ resize: none;
378
+ font-size: 14px;
379
+ font-family: 'Inter', sans-serif;
380
+ background: var(--bg);
381
+ color: var(--text-primary);
382
+ transition: border-color 0.2s;
383
+ max-height: 120px;
384
+ min-width: 0;
385
+ }
386
+
387
+ /* Media queries para responsividad */
388
+ @media (max-width: 768px) {
389
+ .chat-header {
390
+ padding: 12px 16px;
391
+ }
392
+ .header-info h2 {
393
+ font-size: 16px;
394
+ }
395
+ .header-info span {
396
+ font-size: 11px;
397
+ }
398
+ .chat-messages {
399
+ padding: 16px;
400
+ }
401
+ .message {
402
+ max-width: 90%;
403
+ margin-bottom: 12px;
404
+ }
405
+ .message-content {
406
+ padding: 10px 14px;
407
+ font-size: 13px;
408
+ }
409
+ .message-content th, .message-content td {
410
+ padding: 8px 10px;
411
+ font-size: 12px;
412
+ }
413
+ .chat-input {
414
+ padding: 12px 16px;
415
+ gap: 8px;
416
+ }
417
+ .chat-input textarea {
418
+ padding: 10px 14px;
419
+ font-size: 13px;
420
+ }
421
+ .send-btn {
422
+ width: 40px;
423
+ height: 40px;
424
+ font-size: 14px;
425
+ }
426
+ }
427
+
428
+ @media (max-width: 480px) {
429
+ .chat-header {
430
+ padding: 10px 12px;
431
+ }
432
+ .header-logo {
433
+ width: 32px;
434
+ height: 32px;
435
+ font-size: 16px;
436
+ }
437
+ .header-info h2 {
438
+ font-size: 14px;
439
+ }
440
+ .header-info span {
441
+ font-size: 10px;
442
+ }
443
+ .chat-messages {
444
+ padding: 12px;
445
+ }
446
+ .message {
447
+ max-width: 95%;
448
+ margin-bottom: 10px;
449
+ }
450
+ .bot-avatar {
451
+ width: 28px;
452
+ height: 28px;
453
+ font-size: 12px;
454
+ margin-right: 8px;
455
+ }
456
+ .message-content {
457
+ padding: 8px 12px;
458
+ font-size: 12px;
459
+ border-radius: 12px;
460
+ }
461
+ .message-content ul, .message-content ol {
462
+ padding-left: 16px;
463
+ margin: 6px 0;
464
+ }
465
+ .chat-input {
466
+ padding: 10px 12px;
467
+ gap: 6px;
468
+ }
469
+ .chat-input textarea {
470
+ padding: 8px 12px;
471
+ font-size: 12px;
472
+ border-radius: 20px;
473
+ }
474
+ .send-btn {
475
+ width: 36px;
476
+ height: 36px;
477
+ font-size: 12px;
478
+ border-radius: 10px;
479
+ }
480
+ .welcome-card {
481
+ max-width: 90%;
482
+ padding: 16px;
483
+ margin: 20px auto;
484
+ }
485
+ .welcome-card .icon {
486
+ width: 48px;
487
+ height: 48px;
488
+ font-size: 20px;
489
+ }
490
+ .welcome-card h3 {
491
+ font-size: 16px;
492
+ }
493
+ .welcome-card p {
494
+ font-size: 12px;
495
+ }
496
+ .quick-action {
497
+ padding: 6px 12px;
498
+ font-size: 11px;
499
+ }
500
+ }
501
+ .chat-input textarea:focus {
502
+ border-color: var(--primary);
503
+ outline: none;
504
+ box-shadow: 0 0 0 3px rgba(69, 116, 232, 0.1);
505
+ }
506
+ .chat-input textarea::placeholder { color: var(--hint); }
507
+
508
+ .send-btn {
509
+ background: var(--primary);
510
+ color: #fff;
511
+ border: none;
512
+ width: 44px;
513
+ height: 44px;
514
+ border-radius: 14px;
515
+ cursor: pointer;
516
+ font-size: 16px;
517
+ transition: all 0.2s;
518
+ display: flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ box-shadow: 0 2px 8px rgba(69, 116, 232, 0.3);
522
+ }
523
+ .send-btn:hover { background: var(--primary-dark); transform: translateY(-1px); }
524
+ .send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
525
+
526
+ /* Modal */
527
+ .modal-overlay {
528
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
529
+ background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
530
+ display: flex; justify-content: center; align-items: center;
531
+ z-index: 1000; opacity: 0; visibility: hidden; transition: all 0.3s;
532
+ }
533
+ .modal-overlay.visible { opacity: 1; visibility: visible; }
534
+
535
+ .modal-content {
536
+ background: var(--surface);
537
+ padding: 28px;
538
+ border-radius: 20px;
539
+ width: 90%; max-width: 600px; max-height: 85vh; overflow-y: auto;
540
+ box-shadow: 0 20px 60px rgba(0,0,0,0.15);
541
+ transform: scale(0.95); transition: transform 0.3s;
542
+ }
543
+ .modal-overlay.visible .modal-content { transform: scale(1); }
544
+
545
+ .modal-content h3 {
546
+ margin-bottom: 20px;
547
+ font-size: 18px;
548
+ font-weight: 700;
549
+ color: var(--text-primary);
550
+ display: flex;
551
+ align-items: center;
552
+ gap: 10px;
553
+ }
554
+ .modal-content h3 i { color: var(--primary); }
555
+
556
+ .modal-close {
557
+ position: absolute; top: 16px; right: 16px;
558
+ background: var(--bg); border: none; width: 32px; height: 32px;
559
+ border-radius: 8px; font-size: 16px; color: var(--text-secondary);
560
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
561
+ }
562
+ .modal-close:hover { background: var(--border); }
563
+
564
+ .admin-grid {
565
+ display: grid;
566
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
567
+ gap: 10px;
568
+ margin-bottom: 20px;
569
+ }
570
+
571
+ .admin-btn {
572
+ background: var(--bg);
573
+ border: 1px solid var(--border);
574
+ color: var(--text-primary);
575
+ padding: 14px;
576
+ border-radius: 12px;
577
+ cursor: pointer;
578
+ font-size: 13px;
579
+ font-weight: 500;
580
+ font-family: 'Inter', sans-serif;
581
+ display: flex;
582
+ align-items: center;
583
+ gap: 10px;
584
+ transition: all 0.2s;
585
+ }
586
+ .admin-btn:hover {
587
+ background: var(--primary);
588
+ color: #fff;
589
+ border-color: var(--primary);
590
+ transform: translateY(-2px);
591
+ box-shadow: 0 4px 12px rgba(69, 116, 232, 0.25);
592
+ }
593
+ .admin-btn i { font-size: 16px; width: 20px; text-align: center; }
594
+
595
+ .status-box {
596
+ background: var(--bg);
597
+ padding: 14px;
598
+ border-radius: 10px;
599
+ font-family: 'JetBrains Mono', monospace;
600
+ font-size: 12px;
601
+ white-space: pre-wrap;
602
+ border: 1px solid var(--border);
603
+ color: var(--text-secondary);
604
+ }
605
+
606
+ .form-group { margin-bottom: 14px; }
607
+ .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 13px; }
608
+ .form-group input {
609
+ width: 100%; padding: 10px 14px;
610
+ border: 1px solid var(--border); border-radius: 10px;
611
+ background: var(--bg); color: var(--text-primary);
612
+ font-family: 'Inter', sans-serif; font-size: 14px;
613
+ }
614
+ .form-group input:focus { border-color: var(--primary); outline: none; }
615
+
616
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
617
+ .btn {
618
+ padding: 10px 20px; border-radius: 10px; border: none;
619
+ cursor: pointer; font-weight: 600; font-size: 13px;
620
+ font-family: 'Inter', sans-serif; transition: all 0.2s;
621
+ }
622
+ .btn-primary { background: var(--primary); color: #fff; }
623
+ .btn-primary:hover { background: var(--primary-dark); }
624
+ .btn-secondary { background: var(--bg); color: var(--text-primary); border: 1px solid var(--border); }
625
+
626
+ /* Scrollbar */
627
+ ::-webkit-scrollbar { width: 6px; }
628
+ ::-webkit-scrollbar-track { background: transparent; }
629
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
630
+ ::-webkit-scrollbar-thumb:hover { background: var(--hint); }
631
+
632
+ /* Responsive */
633
+ @media (max-width: 600px) {
634
+ .chat-header { padding: 12px 16px; }
635
+ .chat-messages { padding: 16px; }
636
+ .chat-input { padding: 12px 16px; }
637
+ .message { max-width: 90%; }
638
+ .welcome-card { margin: 20px 16px; }
639
+ }
640
+ </style>
641
+ </head>
642
+ <body>
643
+
644
+ <div class="chat-container">
645
+ <div class="chat-header">
646
+ <div class="header-logo"><i class="fas fa-pills"></i></div>
647
+ <div class="header-info">
648
+ <h2>FarmaBot</h2>
649
+ <span id="header-subtitle">Asistente de farmacología dental - Endocaser</span>
650
+ </div>
651
+ <div class="header-buttons">
652
+ </div>
653
+ </div>
654
+
655
+ <div class="chat-messages" id="chat-messages">
656
+ <div class="welcome-card">
657
+ <div class="icon"><i class="fas fa-capsules"></i></div>
658
+ <h3>FarmaBot</h3>
659
+ <p id="welcome-text">Tu asistente de farmacología dental. Consulta interacciones, dosis, protocolos de medicación y más.</p>
660
+ <div class="language-selector">
661
+ <button class="lang-btn active" id="lang-es" onclick="setLanguage('es')">Español</button>
662
+ <button class="lang-btn" id="lang-en" onclick="setLanguage('en')">English</button>
663
+ </div>
664
+ <div class="quick-actions" id="quick-actions-container">
665
+ <button class="quick-action" onclick="sendQuickAction(this)">Interacciones medicamentosas</button>
666
+ <button class="quick-action" onclick="sendQuickAction(this)">Dosis de amoxicilina</button>
667
+ <button class="quick-action" onclick="sendQuickAction(this)">Analgesia post-endo</button>
668
+ <button class="quick-action" onclick="sendQuickAction(this)">Paciente alérgico a penicilina</button>
669
+ </div>
670
+ </div>
671
+ </div>
672
+
673
+ <div class="chat-input">
674
+ <textarea id="user-input" placeholder="Escribe tu consulta farmacológica... / Write your pharmacological query..." rows="1" disabled></textarea>
675
+ <button id="send-button" class="send-btn" disabled><i class="fas fa-paper-plane"></i></button>
676
+ </div>
677
+ </div>
678
+
679
+
680
+
681
+ <div class="modal-overlay" id="credentials-modal">
682
+ <div class="modal-content" style="max-width: 380px; position: relative;">
683
+ <h3 id="credentials-title"><i class="fas fa-lock"></i> Autenticación</h3>
684
+ <div class="form-group">
685
+ <label for="username-input">Usuario</label>
686
+ <input type="text" id="username-input" autocomplete="username">
687
+ </div>
688
+ <div class="form-group">
689
+ <label for="password-input">Contraseña</label>
690
+ <input type="password" id="password-input" autocomplete="current-password">
691
+ </div>
692
+ <div class="modal-actions">
693
+ <button id="credentials-cancel-btn" class="btn btn-secondary">Cancelar</button>
694
+ <button id="credentials-submit-btn" class="btn btn-primary">Acceder</button>
695
+ </div>
696
+ </div>
697
+ </div>
698
+
699
+ <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
700
+ <script src="https://unpkg.com/autosize@4.0.2/dist/autosize.min.js"></script>
701
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
702
+ <script>
703
+ // Configurar marked para saltos de línea automáticos
704
+ marked.setOptions({
705
+ breaks: true,
706
+ gfm: true
707
+ });
708
+
709
+ // Traducciónes para soporte bilingüe
710
+ const translations = {
711
+ es: {
712
+ welcomeText: 'Tu asistente de farmacología dental. Consulta interacciones, dosis, protocolos de medicación y más.',
713
+ quickActions: [
714
+ 'Interacciones medicamentosas',
715
+ 'Dosis de amoxicilina',
716
+ 'Analgesia post-endo',
717
+ 'Paciente alérgico a penicilina'
718
+ ],
719
+ headerSubtitle: 'Asistente de farmacología dental - Endocaser',
720
+ placeholder: 'Escribe tu consulta farmacológica...'
721
+ },
722
+ en: {
723
+ welcomeText: 'Your dental pharmacology assistant. Consult drug interactions, dosages, medication protocols and more.',
724
+ quickActions: [
725
+ 'Drug interactions',
726
+ 'Amoxicillin dosage',
727
+ 'Post-endo analgesia',
728
+ 'Penicillin-allergic patient'
729
+ ],
730
+ headerSubtitle: 'Dental Pharmacology Assistant - Endocaser',
731
+ placeholder: 'Write your pharmacological query...'
732
+ }
733
+ };
734
+
735
+ let currentLanguage = 'es';
736
+
737
+ function setLanguage(lang) {
738
+ currentLanguage = lang;
739
+ const t = translations[lang];
740
+
741
+ // Actualizar UI
742
+ document.getElementById('welcome-text').textContent = t.welcomeText;
743
+ document.getElementById('header-subtitle').textContent = t.headerSubtitle;
744
+ document.getElementById('user-input').placeholder = t.placeholder;
745
+
746
+ // Actualizar botones de acción rápida
747
+ const quickActionButtons = document.querySelectorAll('#quick-actions-container .quick-action');
748
+ quickActionButtons.forEach((btn, index) => {
749
+ if (index < t.quickActions.length) {
750
+ btn.textContent = t.quickActions[index];
751
+ }
752
+ });
753
+
754
+ // Actualizar botones de idioma
755
+ document.getElementById('lang-es').classList.toggle('active', lang === 'es');
756
+ document.getElementById('lang-en').classList.toggle('active', lang === 'en');
757
+ }
758
+
759
+ function sendQuickAction(btn) {
760
+ const input = document.getElementById('user-input');
761
+ input.value = btn.textContent;
762
+ document.getElementById('send-button').click();
763
+ }
764
+
765
+ document.addEventListener('DOMContentLoaded', () => {
766
+ let sessionId = null;
767
+ let sessionAuth = { admin: null, report: null };
768
+
769
+ const ui = {
770
+ sendButton: document.getElementById('send-button'),
771
+ userInput: document.getElementById('user-input'),
772
+ chatMessages: document.getElementById('chat-messages'),
773
+ credsModal: {
774
+ overlay: document.getElementById('credentials-modal'),
775
+ title: document.getElementById('credentials-title'),
776
+ usernameInput: document.getElementById('username-input'),
777
+ passwordInput: document.getElementById('password-input'),
778
+ cancelBtn: document.getElementById('credentials-cancel-btn'),
779
+ submitBtn: document.getElementById('credentials-submit-btn')
780
+ }
781
+ };
782
+
783
+ autosize(ui.userInput);
784
+
785
+ const toggleModal = (modal, show) => modal.classList.toggle('visible', show);
786
+
787
+ const initializeChat = async () => {
788
+ try {
789
+ const response = await axios.post('/create-session');
790
+ sessionId = response.data.session_id;
791
+ ui.userInput.disabled = false;
792
+ ui.sendButton.disabled = false;
793
+ } catch (error) {
794
+ console.error('Error creating session:', error);
795
+ appendMessage({sender: 'bot', text: 'Error al iniciar la sesión. Recarga la página.'});
796
+ }
797
+ };
798
+
799
+ const appendMessage = ({ sender, text, isHtml = false }) => {
800
+ // Remove welcome card on first message
801
+ const welcome = ui.chatMessages.querySelector('.welcome-card');
802
+ if (welcome) welcome.remove();
803
+
804
+ const typing = ui.chatMessages.querySelector('.typing-indicator-wrapper');
805
+ if (typing) typing.remove();
806
+
807
+ const el = document.createElement('div');
808
+ el.className = `message ${sender}`;
809
+
810
+ let content = '';
811
+ if (isHtml) {
812
+ content = text;
813
+ } else if (sender === 'bot') {
814
+ // Renderizar Markdown para el bot
815
+ content = marked.parse(text);
816
+ } else {
817
+ // Escapar HTML para el usuario y envolver en párrafo
818
+ content = `<p>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p>`;
819
+ }
820
+
821
+ if (sender === 'bot') {
822
+ el.innerHTML = `<div class="bot-avatar"><i class="fas fa-pills"></i></div><div class="message-content">${content}</div>`;
823
+ } else {
824
+ el.innerHTML = `<div class="message-content">${content}</div>`;
825
+ }
826
+
827
+ ui.chatMessages.appendChild(el);
828
+ ui.chatMessages.scrollTop = ui.chatMessages.scrollHeight;
829
+ };
830
+
831
+ const showTypingIndicator = () => {
832
+ if (ui.chatMessages.querySelector('.typing-indicator-wrapper')) return;
833
+ const el = document.createElement('div');
834
+ el.className = 'message bot typing-indicator-wrapper';
835
+ el.innerHTML = `<div class="bot-avatar"><i class="fas fa-pills"></i></div><div class="message-content"><div class="typing-indicator"><span></span><span style="animation-delay:0.2s"></span><span style="animation-delay:0.4s"></span></div></div>`;
836
+ ui.chatMessages.appendChild(el);
837
+ ui.chatMessages.scrollTop = ui.chatMessages.scrollHeight;
838
+ };
839
+
840
+ const sendMessage = async () => {
841
+ if (!sessionId) return;
842
+ const message = ui.userInput.value.trim();
843
+ if (!message) return;
844
+
845
+ appendMessage({sender: 'user', text: message});
846
+ ui.userInput.value = '';
847
+ autosize.update(ui.userInput);
848
+ showTypingIndicator();
849
+
850
+ try {
851
+ const response = await axios.post('/chat-bot', { query: message, session_id: sessionId });
852
+ const raw = response.data?.answer || 'Lo siento, no pude procesar la consulta.';
853
+ const clean = raw.replace(/<think>[\s\S]*?<\/think>/, '').trim();
854
+ appendMessage({sender: 'bot', text: clean || 'Respuesta vacía.'});
855
+ } catch (error) {
856
+ appendMessage({sender: 'bot', text: 'Error al procesar. Revisa los logs del servidor.'});
857
+ }
858
+ };
859
+
860
+ const getCredentials = (type) => {
861
+ return new Promise((resolve, reject) => {
862
+ if (sessionAuth[type]) return resolve(sessionAuth[type]);
863
+ ui.credsModal.usernameInput.value = '';
864
+ ui.credsModal.passwordInput.value = '';
865
+ toggleModal(ui.credsModal.overlay, true);
866
+ ui.credsModal.usernameInput.focus();
867
+ const submit = () => { cleanup(); const u = ui.credsModal.usernameInput.value, p = ui.credsModal.passwordInput.value; u && p ? resolve({username:u,password:p}) : reject(new Error('No credentials')); };
868
+ const cancel = () => { cleanup(); reject(new Error('Cancelled')); };
869
+ const cleanup = () => { ui.credsModal.submitBtn.removeEventListener('click', submit); ui.credsModal.cancelBtn.removeEventListener('click', cancel); toggleModal(ui.credsModal.overlay, false); };
870
+ ui.credsModal.submitBtn.addEventListener('click', submit);
871
+ ui.credsModal.cancelBtn.addEventListener('click', cancel);
872
+ });
873
+ };
874
+
875
+
876
+
877
+ // Events
878
+ ui.sendButton.addEventListener('click', sendMessage);
879
+ ui.userInput.addEventListener('keypress', e => e.key==='Enter' && !e.shiftKey && (e.preventDefault(), sendMessage()));
880
+
881
+ initializeChat();
882
+ });
883
+ </script>
884
+ </body>
885
+ </html>