chat history, modified database on aws, navigation modified, clearify promts, temporary closing translator, add verification of answer depending on user question
This commit is contained in:
parent
771a6f8432
commit
677ae05159
Binary file not shown.
164
Backend/model.py
164
Backend/model.py
@ -3,6 +3,7 @@ import requests
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import difflib
|
||||
from requests.exceptions import HTTPError
|
||||
from elasticsearch import Elasticsearch
|
||||
from langchain.chains import SequentialChain
|
||||
@ -11,48 +12,80 @@ from langchain_huggingface import HuggingFaceEmbeddings
|
||||
from langchain_elasticsearch import ElasticsearchStore
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
from langchain.docstore.document import Document
|
||||
from googletrans import Translator # Translator for final polishing
|
||||
# from googletrans import Translator
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load configuration
|
||||
config_file_path = "config.json"
|
||||
with open(config_file_path, 'r') as config_file:
|
||||
config = json.load(config_file)
|
||||
|
||||
# Load Mistral API key
|
||||
mistral_api_key = "hXDC4RBJk1qy5pOlrgr01GtOlmyCBaNs"
|
||||
if not mistral_api_key:
|
||||
raise ValueError("Mistral API key not found in configuration.")
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Function to translate entire text to Slovak #
|
||||
# translate all answer to slovak(temporary closed :) ) #
|
||||
###############################################################################
|
||||
translator = Translator()
|
||||
|
||||
# translator = Translator()
|
||||
def translate_to_slovak(text: str) -> str:
|
||||
"""
|
||||
Translates the entire text into Slovak.
|
||||
Logs the text before and after translation.
|
||||
Переводит весь текст на словацкий с логированием изменений.
|
||||
Сейчас функция является заглушкой и возвращает исходный текст без изменений.
|
||||
"""
|
||||
if not text.strip():
|
||||
return text
|
||||
|
||||
try:
|
||||
# 1) Slovak (or any language) -> English
|
||||
mid_result = translator.translate(text, src='auto', dest='en').text
|
||||
|
||||
# 2) English -> Slovak
|
||||
final_result = translator.translate(mid_result, src='en', dest='sk').text
|
||||
|
||||
return final_result
|
||||
except Exception as e:
|
||||
logger.error(f"Translation error: {e}")
|
||||
return text # fallback to the original text
|
||||
|
||||
# if not text.strip():
|
||||
# return text
|
||||
#
|
||||
# logger.info("Translation - Before: " + text)
|
||||
# try:
|
||||
# mid_result = translator.translate(text, src='auto', dest='en').text
|
||||
# final_result = translator.translate(mid_result, src='en', dest='sk').text
|
||||
# logger.info("Translation - After: " + final_result)
|
||||
# before_words = text.split()
|
||||
# after_words = final_result.split()
|
||||
# diff = list(difflib.ndiff(before_words, after_words))
|
||||
# changed_words = [word[2:] for word in diff if word.startswith('+ ')]
|
||||
# if changed_words:
|
||||
# logger.info("Changed words: " + ", ".join(changed_words))
|
||||
# else:
|
||||
# logger.info("No changed words detected.")
|
||||
# return final_result
|
||||
# except Exception as e:
|
||||
# logger.error(f"Translation error: {e}")
|
||||
# return text
|
||||
return text
|
||||
|
||||
###############################################################################
|
||||
# Функция перевода описания лекарства с сохранением названия (до двоеточия) #
|
||||
###############################################################################
|
||||
def translate_preserving_medicine_names(text: str) -> str:
|
||||
"""
|
||||
Ищет строки вида "номер. Название лекарства: описание..." и переводит только описание,
|
||||
оставляя название без изменений.
|
||||
Сейчас функция является заглушкой и возвращает исходный текст без изменений.
|
||||
"""
|
||||
# pattern = re.compile(r'^(\d+\.\s*[^:]+:\s*)(.*)$', re.MULTILINE)
|
||||
#
|
||||
# def replacer(match):
|
||||
# prefix = match.group(1)
|
||||
# description = match.group(2)
|
||||
# logger.info("Translating description: " + description)
|
||||
# translated_description = translate_to_slovak(description)
|
||||
# logger.info("Translated description: " + translated_description)
|
||||
# diff = list(difflib.ndiff(description.split(), translated_description.split()))
|
||||
# changed_words = [word[2:] for word in diff if word.startswith('+ ')]
|
||||
# if changed_words:
|
||||
# logger.info("Changed words in description: " + ", ".join(changed_words))
|
||||
# else:
|
||||
# logger.info("No changed words in description detected.")
|
||||
# return prefix + translated_description
|
||||
#
|
||||
# if pattern.search(text):
|
||||
# return pattern.sub(replacer, text)
|
||||
# else:
|
||||
# return translate_to_slovak(text)
|
||||
return text
|
||||
|
||||
###############################################################################
|
||||
# Custom Mistral LLM #
|
||||
@ -83,7 +116,7 @@ class CustomMistralLLM:
|
||||
logger.info(f"Full response from model {self.model_name}: {result}")
|
||||
return result.get("choices", [{}])[0].get("message", {}).get("content", "No response")
|
||||
except HTTPError as e:
|
||||
if response.status_code == 429: # Too Many Requests
|
||||
if response.status_code == 429:
|
||||
logger.warning(f"Rate limit exceeded. Waiting {delay} seconds before retry.")
|
||||
time.sleep(delay)
|
||||
attempt += 1
|
||||
@ -95,7 +128,6 @@ class CustomMistralLLM:
|
||||
raise e
|
||||
raise Exception("Reached maximum number of retries for API request")
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Initialize embeddings and Elasticsearch store #
|
||||
###############################################################################
|
||||
@ -104,7 +136,6 @@ embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-
|
||||
|
||||
index_name = 'drug_docs'
|
||||
|
||||
# Connect to Elasticsearch
|
||||
if config.get("useCloud", False):
|
||||
logger.info("Using cloud Elasticsearch.")
|
||||
cloud_id = "tt:dXMtZWFzdC0yLmF3cy5lbGFzdGljLWNsb3VkLmNvbTo0NDMkOGM3ODQ0ZWVhZTEyNGY3NmFjNjQyNDFhNjI4NmVhYzMkZTI3YjlkNTQ0ODdhNGViNmEyMTcxMjMxNmJhMWI0ZGU="
|
||||
@ -125,7 +156,6 @@ else:
|
||||
|
||||
logger.info(f"Connected to {'cloud' if config.get('useCloud', False) else 'local'} Elasticsearch.")
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Initialize Mistral models (small & large) #
|
||||
###############################################################################
|
||||
@ -141,41 +171,52 @@ llm_large = CustomMistralLLM(
|
||||
model_name="mistral-large-latest"
|
||||
)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Helper function to evaluate model output #
|
||||
###############################################################################
|
||||
def evaluate_results(query, summaries, model_name):
|
||||
"""
|
||||
Evaluates results by:
|
||||
- text length,
|
||||
- presence of query keywords, etc.
|
||||
Returns a rating and explanation.
|
||||
"""
|
||||
query_keywords = query.split()
|
||||
total_score = 0
|
||||
explanation = []
|
||||
|
||||
for i, summary in enumerate(summaries):
|
||||
# Length-based scoring
|
||||
length_score = min(len(summary) / 100, 10)
|
||||
total_score += length_score
|
||||
explanation.append(f"Document {i+1}: Length score - {length_score}")
|
||||
|
||||
# Keyword-based scoring
|
||||
keyword_matches = sum(1 for word in query_keywords if word.lower() in summary.lower())
|
||||
keyword_score = min(keyword_matches * 2, 10)
|
||||
total_score += keyword_score
|
||||
explanation.append(f"Document {i+1}: Keyword match score - {keyword_score}")
|
||||
|
||||
final_score = total_score / len(summaries) if summaries else 0
|
||||
explanation_summary = "\n".join(explanation)
|
||||
|
||||
logger.info(f"Evaluation for model {model_name}: {final_score}/10")
|
||||
logger.info(f"Explanation:\n{explanation_summary}")
|
||||
|
||||
return {"rating": round(final_score, 2), "explanation": explanation_summary}
|
||||
|
||||
###############################################################################
|
||||
# validation of recieved answer is it correct for user question #
|
||||
###############################################################################
|
||||
def validate_answer_logic(query: str, answer: str) -> str:
|
||||
"""
|
||||
Проверяет, соответствует ли ответ логике вопроса.
|
||||
Если, например, вопрос относится к ľudským liekom a obsahuje otázku na dávkovanie,
|
||||
odpoveď musí obsahovať iba lieky vhodné pre ľudí s uvedením správneho dávkovania.
|
||||
"""
|
||||
validation_prompt = (
|
||||
f"Otázka: '{query}'\n"
|
||||
f"Odpoveď: '{answer}'\n\n"
|
||||
"Analyzuj prosím túto odpoveď. Ak odpoveď obsahuje odporúčania liekov, ktoré nie sú vhodné pre ľudí, "
|
||||
"alebo ak neobsahuje správne informácie o dávkovaní, oprav ju tak, aby bola logicky konzistentná s otázkou. "
|
||||
"Odpoveď musí obsahovať iba lieky určené pre ľudí a pri potrebe aj presné informácie o dávkovaní (napr. v gramoch). "
|
||||
"Ak je odpoveď logická a korektná, vráť pôvodnú odpoveď bez zmien. "
|
||||
"Odpovedz v slovenčine a iba čistou, konečnou odpoveďou bez ďalších komentárov."
|
||||
)
|
||||
try:
|
||||
validated_answer = llm_small.generate_text(prompt=validation_prompt, max_tokens=500, temperature=0.5)
|
||||
logger.info(f"Validated answer: {validated_answer}")
|
||||
return validated_answer
|
||||
except Exception as e:
|
||||
logger.error(f"Error during answer validation: {e}")
|
||||
return answer
|
||||
|
||||
###############################################################################
|
||||
# Main function: process_query_with_mistral (Slovak prompt) #
|
||||
@ -186,29 +227,25 @@ def process_query_with_mistral(query, k=10):
|
||||
# --- Vector search ---
|
||||
vector_results = vectorstore.similarity_search(query, k=k)
|
||||
vector_documents = [hit.metadata.get('text', '') for hit in vector_results]
|
||||
|
||||
max_docs = 5
|
||||
max_doc_length = 1000
|
||||
vector_documents = [doc[:max_doc_length] for doc in vector_documents[:max_docs]]
|
||||
|
||||
if vector_documents:
|
||||
# Slovak prompt
|
||||
vector_prompt = (
|
||||
f"Otázka: '{query}'.\n"
|
||||
"Na základe nasledujúcich informácií o liekoch:\n"
|
||||
f"{vector_documents}\n\n"
|
||||
"Prosím, uveďte tri najvhodnejšie lieky alebo riešenia. Pre každý liek uveďte jeho názov a stručné, jasné vysvetlenie, prečo je vhodný. "
|
||||
"Odpovedajte priamo a ľudským, priateľským tónom v číslovanom zozname, bez nepotrebných úvodných fráz alebo opisu procesu. "
|
||||
"Prosím, uveďte tri najvhodnejšie lieky alebo riešenia pre daný problém. "
|
||||
"Pre každý liek uveďte jeho názov, stručné a jasné vysvetlenie, prečo je vhodný, a ak je to relevantné, "
|
||||
"aj odporúčané dávkovanie (napr. v gramoch alebo v iných vhodných jednotkách). "
|
||||
"Odpovedajte priamo a ľudským, priateľským tónom v číslovanom zozname, bez nepotrebných úvodných fráz. "
|
||||
"Odpoveď musí byť v slovenčine."
|
||||
)
|
||||
|
||||
summary_small_vector = llm_small.generate_text(prompt=vector_prompt, max_tokens=700, temperature=0.7)
|
||||
summary_large_vector = llm_large.generate_text(prompt=vector_prompt, max_tokens=700, temperature=0.7)
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
|
||||
split_summary_small_vector = splitter.split_text(summary_small_vector)
|
||||
split_summary_large_vector = splitter.split_text(summary_large_vector)
|
||||
|
||||
small_vector_eval = evaluate_results(query, split_summary_small_vector, 'Mistral Small')
|
||||
large_vector_eval = evaluate_results(query, split_summary_large_vector, 'Mistral Large')
|
||||
else:
|
||||
@ -224,24 +261,22 @@ def process_query_with_mistral(query, k=10):
|
||||
)
|
||||
text_documents = [hit['_source'].get('text', '') for hit in es_results['hits']['hits']]
|
||||
text_documents = [doc[:max_doc_length] for doc in text_documents[:max_docs]]
|
||||
|
||||
if text_documents:
|
||||
# Slovak prompt
|
||||
text_prompt = (
|
||||
f"Otázka: '{query}'.\n"
|
||||
"Na základe nasledujúcich informácií o liekoch:\n"
|
||||
f"{text_documents}\n\n"
|
||||
"Prosím, uveďte tri najvhodnejšie lieky alebo riešenia. Pre každý liek uveďte jeho názov a stručné, jasné vysvetlenie, prečo je vhodný. "
|
||||
"Odpovedajte priamo a ľudským, priateľským tónom v číslovanom zozname, bez nepotrebných úvodných fráz alebo opisu procesu. "
|
||||
"Prosím, uveďte tri najvhodnejšie lieky alebo riešenia pre daný problém. "
|
||||
"Pre každý liek uveďte jeho názov, stručné a jasné vysvetlenie, prečo je vhodný, a ak je to relevantné, "
|
||||
"aj odporúčané dávkovanie (napr. v gramoch alebo v iných vhodných jednotkách). "
|
||||
"Odpovedajte priamo a ľudským, priateľským tónom v číslovanom zozname, bez nepotrebných úvodných fráz. "
|
||||
"Odpoveď musí byť v slovenčine."
|
||||
)
|
||||
|
||||
summary_small_text = llm_small.generate_text(prompt=text_prompt, max_tokens=700, temperature=0.7)
|
||||
summary_large_text = llm_large.generate_text(prompt=text_prompt, max_tokens=700, temperature=0.7)
|
||||
|
||||
split_summary_small_text = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20).split_text(summary_small_text)
|
||||
split_summary_large_text = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20).split_text(summary_large_text)
|
||||
|
||||
splitter_text = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
|
||||
split_summary_small_text = splitter_text.split_text(summary_small_text)
|
||||
split_summary_large_text = splitter_text.split_text(summary_large_text)
|
||||
small_text_eval = evaluate_results(query, split_summary_small_text, 'Mistral Small')
|
||||
large_text_eval = evaluate_results(query, split_summary_large_text, 'Mistral Large')
|
||||
else:
|
||||
@ -250,30 +285,31 @@ def process_query_with_mistral(query, k=10):
|
||||
summary_small_text = ""
|
||||
summary_large_text = ""
|
||||
|
||||
# Combine all results and pick the best
|
||||
# Porovnanie výsledkov a výber najlepšieho
|
||||
all_results = [
|
||||
{"eval": small_vector_eval, "summary": summary_small_vector, "model": "Mistral Small Vector"},
|
||||
{"eval": large_vector_eval, "summary": summary_large_vector, "model": "Mistral Large Vector"},
|
||||
{"eval": small_text_eval, "summary": summary_small_text, "model": "Mistral Small Text"},
|
||||
{"eval": large_text_eval, "summary": summary_large_text, "model": "Mistral Large Text"},
|
||||
]
|
||||
|
||||
best_result = max(all_results, key=lambda x: x["eval"]["rating"])
|
||||
logger.info(f"Best result from model {best_result['model']} with score {best_result['eval']['rating']}.")
|
||||
|
||||
# Final translation to Slovak (with logs before/after)
|
||||
polished_answer = translate_to_slovak(best_result["summary"])
|
||||
# Dodatočná kontrola logiky odpovede
|
||||
validated_answer = validate_answer_logic(query, best_result["summary"])
|
||||
|
||||
|
||||
polished_answer = translate_preserving_medicine_names(validated_answer)
|
||||
return {
|
||||
"best_answer": polished_answer,
|
||||
"model": best_result["model"],
|
||||
"rating": best_result["eval"]["rating"],
|
||||
"explanation": best_result["eval"]["explanation"]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {str(e)}")
|
||||
return {
|
||||
"best_answer": "An error occurred during query processing.",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import time
|
||||
import re
|
||||
|
||||
# Сохраняем оригинальную функцию time.time
|
||||
_real_time = time.time
|
||||
# Переопределяем time.time для смещения времени на 1 секунду назад
|
||||
@ -15,7 +17,7 @@ from model import process_query_with_mistral
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# Параметры подключения
|
||||
# Параметры подключения к базе данных
|
||||
DATABASE_CONFIG = {
|
||||
"dbname": "postgres",
|
||||
"user": "postgres",
|
||||
@ -27,7 +29,6 @@ DATABASE_CONFIG = {
|
||||
# Подключение к базе данных
|
||||
try:
|
||||
conn = psycopg2.connect(**DATABASE_CONFIG)
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
print("Подключение к базе данных успешно установлено")
|
||||
except Exception as e:
|
||||
print(f"Ошибка подключения к базе данных: {e}")
|
||||
@ -45,15 +46,16 @@ CLIENT_ID = "532143017111-4eqtlp0oejqaovj6rf5l1ergvhrp4vao.apps.googleuserconten
|
||||
|
||||
def save_user_to_db(name, email, google_id=None, password=None):
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (name, email, google_id, password)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (email) DO NOTHING
|
||||
""",
|
||||
(name, email, google_id, password)
|
||||
)
|
||||
conn.commit()
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (name, email, google_id, password)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (email) DO NOTHING
|
||||
""",
|
||||
(name, email, google_id, password)
|
||||
)
|
||||
conn.commit()
|
||||
print(f"User {name} ({email}) saved successfully!")
|
||||
except Exception as e:
|
||||
print(f"Error saving user to database: {e}")
|
||||
@ -63,91 +65,154 @@ def save_user_to_db(name, email, google_id=None, password=None):
|
||||
def verify_token():
|
||||
data = request.get_json()
|
||||
token = data.get('token')
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': 'No token provided'}), 400
|
||||
|
||||
try:
|
||||
id_info = id_token.verify_oauth2_token(token, requests.Request(), CLIENT_ID)
|
||||
user_email = id_info.get('email')
|
||||
user_name = id_info.get('name')
|
||||
google_id = id_info.get('sub') # Уникальный идентификатор пользователя Google
|
||||
|
||||
save_user_to_db(name=user_name, email=user_email, google_id=google_id)
|
||||
|
||||
logger.info(f"User authenticated and saved: {user_name} ({user_email})")
|
||||
return jsonify({'message': 'Authentication successful', 'user': {'email': user_email, 'name': user_name}}), 200
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
# Эндпоинт для регистрации пользователя
|
||||
# Эндпоинт для регистрации пользователя с проверкой на дублирование
|
||||
@app.route('/api/register', methods=['POST'])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
email = data.get('email')
|
||||
password = data.get('password') # Рекомендуется хэшировать пароль
|
||||
|
||||
if not all([name, email, password]):
|
||||
return jsonify({'error': 'All fields are required'}), 400
|
||||
|
||||
try:
|
||||
# Проверка, существует ли пользователь с таким email
|
||||
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
|
||||
existing_user = cursor.fetchone()
|
||||
if existing_user:
|
||||
return jsonify({'error': 'User already exists'}), 409
|
||||
|
||||
# Сохранение пользователя в базу данных
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
|
||||
existing_user = cur.fetchone()
|
||||
if existing_user:
|
||||
return jsonify({'error': 'User already exists'}), 409
|
||||
save_user_to_db(name=name, email=email, password=password)
|
||||
|
||||
return jsonify({'message': 'User registered successfully'}), 201
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Эндпоинт для логина пользователя (см. предыдущий пример)
|
||||
# Эндпоинт для логина пользователя
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
email = data.get('email')
|
||||
password = data.get('password')
|
||||
|
||||
if not all([email, password]):
|
||||
return jsonify({'error': 'Email and password are required'}), 400
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Сравнение простым текстом — в production используйте хэширование!
|
||||
if user.get('password') != password:
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
return jsonify({
|
||||
'message': 'Login successful',
|
||||
'user': {
|
||||
'name': user.get('name'),
|
||||
'email': user.get('email')
|
||||
}
|
||||
}), 200
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
|
||||
user = cur.fetchone()
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
if user.get('password') != password:
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
return jsonify({'message': 'Login successful', 'user': {'name': user.get('name'), 'email': user.get('email')}}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Эндпоинт для обработки запросов от фронтенда
|
||||
# Объединённый эндпоинт для обработки запроса чата
|
||||
@app.route('/api/chat', methods=['POST'])
|
||||
def chat():
|
||||
data = request.get_json()
|
||||
query = data.get('query', '')
|
||||
user_email = data.get('email') # email пользователя (если передается)
|
||||
chat_id = data.get('chatId') # параметр для обновления существующего чата
|
||||
|
||||
if not query:
|
||||
return jsonify({'error': 'No query provided'}), 400
|
||||
|
||||
response = process_query_with_mistral(query)
|
||||
return jsonify(response)
|
||||
# Вызов функции для обработки запроса (например, чат-бота)
|
||||
response_obj = process_query_with_mistral(query)
|
||||
best_answer = ""
|
||||
if isinstance(response_obj, dict):
|
||||
best_answer = response_obj.get("best_answer", "")
|
||||
else:
|
||||
best_answer = str(response_obj)
|
||||
|
||||
# Форматирование ответа с использованием re.sub
|
||||
best_answer = re.sub(r'[*#]', '', best_answer)
|
||||
best_answer = re.sub(r'(\d\.\s)', r'\n\n\1', best_answer)
|
||||
best_answer = re.sub(r':\s-', r':\n-', best_answer)
|
||||
|
||||
# Если chatId передан, обновляем существующий чат, иначе создаем новый чат
|
||||
if chat_id:
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT chat FROM chat_history WHERE id = %s", (chat_id,))
|
||||
existing_chat = cur.fetchone()
|
||||
if existing_chat:
|
||||
updated_chat = existing_chat['chat'] + f"\nUser: {query}\nBot: {best_answer}"
|
||||
cur.execute("UPDATE chat_history SET chat = %s WHERE id = %s", (updated_chat, chat_id))
|
||||
conn.commit()
|
||||
else:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur2:
|
||||
cur2.execute(
|
||||
"INSERT INTO chat_history (user_email, chat) VALUES (%s, %s) RETURNING id",
|
||||
(user_email, f"User: {query}\nBot: {best_answer}")
|
||||
)
|
||||
new_chat_id = cur2.fetchone()['id']
|
||||
conn.commit()
|
||||
chat_id = new_chat_id
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
else:
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO chat_history (user_email, chat) VALUES (%s, %s) RETURNING id",
|
||||
(user_email, f"User: {query}\nBot: {best_answer}")
|
||||
)
|
||||
new_chat_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
chat_id = new_chat_id
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Возвращаем текстовый ответ и новый chatId, если чат был создан
|
||||
return jsonify({'response': {'best_answer': best_answer, 'model': 'Mistral Small Vector', 'chatId': chat_id}}), 200
|
||||
|
||||
# Эндпоинт для получения истории чатов конкретного пользователя
|
||||
@app.route('/api/chat_history', methods=['GET'])
|
||||
def get_chat_history():
|
||||
user_email = request.args.get('email')
|
||||
if not user_email:
|
||||
return jsonify({'error': 'User email is required'}), 400
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, chat, created_at FROM chat_history WHERE user_email = %s ORDER BY created_at DESC",
|
||||
(user_email,)
|
||||
)
|
||||
history = cur.fetchall()
|
||||
return jsonify({'history': history}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Эндпоинт для получения деталей чата по ID
|
||||
@app.route('/api/chat_history_detail', methods=['GET'])
|
||||
def chat_history_detail():
|
||||
chat_id = request.args.get('id')
|
||||
if not chat_id:
|
||||
return jsonify({'error': 'Chat id is required'}), 400
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT id, chat, created_at FROM chat_history WHERE id = %s", (chat_id,))
|
||||
chat = cur.fetchone()
|
||||
if not chat:
|
||||
return jsonify({'error': 'Chat not found'}), 404
|
||||
return jsonify({'chat': chat}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
|
@ -1,41 +1,41 @@
|
||||
import { BrowserRouter as Router, Route, Routes, Outlet } from 'react-router-dom';
|
||||
import Navigation from './Components/Navigation';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import RegistrationForm from "./Components/RegistrationForm.tsx";
|
||||
import LoginForm from "./Components/LoginForm.tsx";
|
||||
|
||||
import RegistrationForm from "./Components/RegistrationForm";
|
||||
import LoginForm from "./Components/LoginForm";
|
||||
import ChatHistory from "./Components/ChatHistory";
|
||||
import HomePage from './pages/HomePage';
|
||||
import NewChatPage from "./Components/NewChatPage";
|
||||
|
||||
const Layout = () => (
|
||||
<div className="flex w-full h-screen dark:bg-slate-200">
|
||||
<Navigation isExpanded={false} />
|
||||
<div className="flex-grow p-3 h-full">
|
||||
<main className="h-full w-full border rounded-xl dark:bg-slate-100 shadow-xl" >
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className="flex w-full h-screen dark:bg-slate-200">
|
||||
<Navigation isExpanded={false} />
|
||||
<div className="flex-grow p-3 h-full">
|
||||
<main className="h-full w-full border rounded-xl dark:bg-slate-100 shadow-xl">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path='/' element={<LandingPage />} />
|
||||
<Route path="/register" element={<RegistrationForm />} />
|
||||
<Route path="/login" element={<LoginForm />} />
|
||||
<Route path="solutions" element={<>Sorry not implemented yet</>} />
|
||||
<Route path="contact" element={<>Sorry not implemented yet</>} />
|
||||
<Route path="about" element={<>Sorry not implemented yet</>} />
|
||||
<Route path="/dashboard" element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="history" element={<>Sorry not implemented yet</>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
)
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/register" element={<RegistrationForm />} />
|
||||
<Route path="/login" element={<LoginForm />} />
|
||||
<Route path="/dashboard" element={<Layout />}>
|
||||
{/* Новый чат */}
|
||||
<Route path="new-chat" element={<NewChatPage />} />
|
||||
{/* Существующий чат (после создания нового, URL обновится) */}
|
||||
<Route path="chat/:id" element={<HomePage />} />
|
||||
<Route path="history" element={<ChatHistory />} />
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
export default App;
|
||||
|
58
frontend/src/Components/ChatDetails.tsx
Normal file
58
frontend/src/Components/ChatDetails.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
interface ChatHistoryItem {
|
||||
id: number;
|
||||
chat: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ChatDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const [chat, setChat] = useState<ChatHistoryItem | null>(location.state?.chat || null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat && id) {
|
||||
// Если данные не переданы через state, можно попробовать получить их с сервера
|
||||
fetch(`http://localhost:5000/api/chat_history_detail?id=${encodeURIComponent(id)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
setChat(data.chat);
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}
|
||||
}, [id, chat]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!chat) {
|
||||
return <div>Loading chat details...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h1>Chat Details</h1>
|
||||
<div style={{ border: '1px solid #ccc', padding: '10px' }}>
|
||||
{chat.chat.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
<small>{new Date(chat.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatDetails;
|
80
frontend/src/Components/ChatHistory.tsx
Normal file
80
frontend/src/Components/ChatHistory.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ChatHistoryItem {
|
||||
id: number;
|
||||
chat: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ChatHistory: React.FC = () => {
|
||||
const [history, setHistory] = useState<ChatHistoryItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const user = JSON.parse(storedUser);
|
||||
const email = user.email;
|
||||
fetch(`http://localhost:5000/api/chat_history?email=${encodeURIComponent(email)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
setHistory(data.history);
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Error fetching chat history'));
|
||||
} else {
|
||||
setError('User not logged in');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// При клике перенаправляем пользователя на /dashboard/chat/{chatId}
|
||||
const handleClick = (item: ChatHistoryItem) => {
|
||||
navigate(`/dashboard/chat/${item.id}`, { state: { selectedChat: item } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h1>Chat History</h1>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
{history.length === 0 && !error ? (
|
||||
<p>No chat history found.</p>
|
||||
) : (
|
||||
<ul style={{ listStyleType: 'none', padding: 0 }}>
|
||||
{history.map((item) => {
|
||||
// Извлекаем первую строку из сохранённого чата.
|
||||
// Предполагаем, что чат хранится в формате: "User: <вопрос>\nBot: <ответ>\n..."
|
||||
const lines = item.chat.split("\n");
|
||||
let firstUserMessage = lines[0];
|
||||
if (firstUserMessage.startsWith("User:")) {
|
||||
firstUserMessage = firstUserMessage.replace("User:", "").trim();
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
style={{
|
||||
marginBottom: '15px',
|
||||
borderBottom: '1px solid #ccc',
|
||||
paddingBottom: '10px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleClick(item)}
|
||||
>
|
||||
<div>
|
||||
<strong>{firstUserMessage}</strong>
|
||||
</div>
|
||||
<small>{new Date(item.created_at).toLocaleString()}</small>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHistory;
|
@ -1,47 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { IoMdHome } from "react-icons/io";
|
||||
import { GoHistory } from "react-icons/go";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MdOutlineDarkMode } from "react-icons/md";
|
||||
import { CiLight } from "react-icons/ci";
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import { MdAddCircleOutline, MdOutlineDarkMode } from "react-icons/md";
|
||||
import { GoHistory } from "react-icons/go";
|
||||
import { CiLight } from "react-icons/ci";
|
||||
import { CgLogIn } from "react-icons/cg";
|
||||
import BackImage from '../assets/smallheadicon.png'
|
||||
import BackImage from '../assets/smallheadicon.png';
|
||||
|
||||
export interface NavigationItem {
|
||||
icon: React.ReactNode,
|
||||
title: string,
|
||||
link: string
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const NavigationItems: NavigationItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
link: '/dashboard',
|
||||
icon: <IoMdHome size={30} />
|
||||
title: 'New Chat',
|
||||
link: '/dashboard/new-chat', // Перенаправляем сразу на новый чат
|
||||
icon: <MdAddCircleOutline size={30} />
|
||||
},
|
||||
{
|
||||
title: 'History',
|
||||
link: '/dashboard/history',
|
||||
icon: <GoHistory size={25} />
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
interface NavigationProps {
|
||||
isExpanded: boolean,
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('light')
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-color-scheme:dark)').matches) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light')
|
||||
setTheme('light');
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "dark") {
|
||||
@ -49,11 +48,11 @@ const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}, [theme])
|
||||
}, [theme]);
|
||||
|
||||
const handleThemeSwitch = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark")
|
||||
}
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
// Загружаем данные пользователя из localStorage (если имеются)
|
||||
const [user, setUser] = useState<any>(null);
|
||||
@ -70,7 +69,7 @@ const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
<div className='flex flex-col items-start gap-12'>
|
||||
<Link to='/' className='w-full flex items-center justify-center'>
|
||||
<IconButton sx={{ width: 40, height: 40 }}>
|
||||
<img src={BackImage} width={25} alt="" />
|
||||
<img src={BackImage} width={25} alt="Back" />
|
||||
</IconButton>
|
||||
{isExpanded && (
|
||||
<p className='text-2xl font-semibold text-dark-blue flex items-center'>
|
||||
@ -80,7 +79,11 @@ const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
</Link>
|
||||
<div className='flex flex-col p-1 gap-5 items-center'>
|
||||
{NavigationItems.map((item) => (
|
||||
<Link key={item.link} to={item.link} className='flex gap-2 items-center w-full'>
|
||||
<Link
|
||||
key={item.link}
|
||||
to={item.link}
|
||||
className='flex gap-2 items-center w-full'
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
width: 40,
|
||||
@ -129,7 +132,7 @@ const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
borderRadius: 2,
|
||||
background: theme === 'dark' ? 'white' : 'initial',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid blue', // Кастомный стиль фокуса
|
||||
outline: '2px solid blue',
|
||||
outlineOffset: '0px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
@ -142,7 +145,7 @@ const Navigation = ({ isExpanded = false }: NavigationProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
|
186
frontend/src/Components/NewChatPage.tsx
Normal file
186
frontend/src/Components/NewChatPage.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import { useLazySendChatQuestionQuery } from '../store/api/chatApi';
|
||||
import callCenterIcon from '../assets/call-center.png';
|
||||
|
||||
interface ChatMessage {
|
||||
sender: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const NewChatPage: React.FC = () => {
|
||||
const [sendChatQuestion, { isLoading, isFetching }] = useLazySendChatQuestionQuery();
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chatHistory, isLoading, isFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
setChatHistory([]);
|
||||
}, []);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!message.trim()) return;
|
||||
|
||||
const newUserMessage: ChatMessage = { sender: 'User', text: message };
|
||||
const updatedHistory = [...chatHistory, newUserMessage];
|
||||
setChatHistory(updatedHistory);
|
||||
const userMessage = message;
|
||||
setMessage('');
|
||||
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const email = storedUser ? JSON.parse(storedUser).email : '';
|
||||
|
||||
const question = { query: userMessage, email };
|
||||
|
||||
try {
|
||||
const res = await sendChatQuestion(question).unwrap();
|
||||
console.log('Response from server:', res);
|
||||
|
||||
let bestAnswer = res.response.best_answer;
|
||||
if (typeof bestAnswer !== 'string') {
|
||||
bestAnswer = String(bestAnswer);
|
||||
}
|
||||
bestAnswer = bestAnswer.trim();
|
||||
|
||||
const newAssistantMessage: ChatMessage = { sender: 'Assistant', text: bestAnswer };
|
||||
const newUpdatedHistory = [...updatedHistory, newAssistantMessage];
|
||||
setChatHistory(newUpdatedHistory);
|
||||
|
||||
if (res.response.chatId) {
|
||||
const chatString = newUpdatedHistory
|
||||
.map(msg => (msg.sender === 'User' ? 'User: ' : 'Bot: ') + msg.text)
|
||||
.join('\n');
|
||||
navigate(`/dashboard/chat/${res.response.chatId}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
selectedChat: {
|
||||
id: res.response.chatId,
|
||||
chat: chatString,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setChatHistory(prev => [
|
||||
...prev,
|
||||
{ sender: 'Assistant', text: 'Что-то пошло не так' }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
useGSAP(() => {
|
||||
gsap.from('#input', { opacity: 0, y: 5, ease: 'power2.inOut', duration: 0.5 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-end items-center p-4 gap-8 h-full w-full">
|
||||
<div className="w-full p-2 rounded overflow-y-auto h-full mb-4">
|
||||
{chatHistory.length > 0 ? (
|
||||
<>
|
||||
{chatHistory.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex mb-2 ${msg.sender === 'User' ? 'justify-end' : 'justify-start items-start'}`}
|
||||
>
|
||||
{msg.sender === 'Assistant' && (
|
||||
<img
|
||||
src={callCenterIcon}
|
||||
alt="Call Center Icon"
|
||||
className="w-6 h-6 mr-2"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`p-3 rounded-lg max-w-md flex ${
|
||||
msg.sender === 'User'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-800'
|
||||
}`}
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
>
|
||||
{msg.text.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(isLoading || isFetching) && (
|
||||
<div className="flex mb-2 justify-start items-start">
|
||||
<img
|
||||
src={callCenterIcon}
|
||||
alt="Call Center Icon"
|
||||
className="w-6 h-6 mr-2"
|
||||
/>
|
||||
<div
|
||||
className="p-3 rounded-lg max-w-md flex bg-gray-200 text-gray-800"
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-3 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Assistant is typing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col gap-2 items-center justify-center">
|
||||
<h1 className="text-xl" id="firstheading">
|
||||
Start a New Chat
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div id="input" className="w-2/3 mb-20">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full px-5 py-2 rounded-l-xl outline-none border border-gray-300"
|
||||
/>
|
||||
<button
|
||||
disabled={isLoading || isFetching}
|
||||
onClick={onSubmit}
|
||||
className="bg-black text-white font-semibold px-4 py-2 rounded-r-xl hover:bg-slate-700"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewChatPage;
|
BIN
frontend/src/assets/call-center.png
Normal file
BIN
frontend/src/assets/call-center.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
@ -1,116 +1,225 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLazySendChatQuestionQuery } from '../store/api/chatApi';
|
||||
import callCenterIcon from '../assets/call-center.png';
|
||||
|
||||
import { MdLocalPharmacy, MdSelfImprovement } from "react-icons/md";
|
||||
import { GiPill } from "react-icons/gi";
|
||||
import { useState } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
interface ChatMessage {
|
||||
sender: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
import { useLazySendChatQuestionQuery } from "../store/api/chatApi";
|
||||
|
||||
const HomePage = () => {
|
||||
const HomePage: React.FC = () => {
|
||||
const [sendChatQuestion, { isLoading, isFetching }] = useLazySendChatQuestionQuery();
|
||||
const [message, setMessage] = useState('');
|
||||
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
type Category = 'medication' | 'supplements' | 'lifestyle';
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [chatHistory, setChatHistory] = useState<{ sender: string; text: string, rating?: number, explanation?: string }[]>([]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const state = (location.state as any) || {};
|
||||
|
||||
async function onSubmit() {
|
||||
const isNewChat = state.newChat === true;
|
||||
const selectedChat = state.selectedChat || null;
|
||||
const selectedChatId = selectedChat ? selectedChat.id : null;
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chatHistory, isLoading, isFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNewChat && selectedChat && selectedChat.chat) {
|
||||
const messages: ChatMessage[] = selectedChat.chat
|
||||
.split(/(?=^(User:|Bot:))/m)
|
||||
.map((msg) => {
|
||||
const trimmed = msg.trim();
|
||||
const sender = trimmed.startsWith('User:') ? 'User' : 'Assistant';
|
||||
return {
|
||||
sender,
|
||||
text: trimmed.replace(/^User:|^Bot:/, '').trim(),
|
||||
};
|
||||
});
|
||||
setChatHistory(messages);
|
||||
} else {
|
||||
setChatHistory([]);
|
||||
}
|
||||
}, [isNewChat, selectedChat]);
|
||||
|
||||
/**
|
||||
* Функция форматирования сообщения.
|
||||
* Если в ответе отсутствуют символы перевода строки, пытаемся разбить текст по нумерованным пунктам.
|
||||
*/
|
||||
const formatMessage = (text: string) => {
|
||||
let lines: string[] = [];
|
||||
|
||||
if (text.includes('\n')) {
|
||||
lines = text.split('\n');
|
||||
} else {
|
||||
lines = text.split(/(?=\d+\.\s+)/);
|
||||
}
|
||||
|
||||
lines = lines.map((line) => line.trim()).filter((line) => line !== '');
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
return lines.map((line, index) => {
|
||||
if (/^\d+\.\s*/.test(line)) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const firstPart = line.substring(0, colonIndex);
|
||||
const rest = line.substring(colonIndex + 1);
|
||||
return (
|
||||
<div key={index} className="mb-1">
|
||||
<strong>{firstPart.trim()}</strong>: {rest.trim()}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={index} className="mb-1">
|
||||
<strong>{line}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <div key={index}>{line}</div>;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!message.trim()) return;
|
||||
setChatHistory([...chatHistory, { sender: 'User', text: message }]);
|
||||
const userMessage = message.trim();
|
||||
setMessage('');
|
||||
setChatHistory((prev) => [...prev, { sender: 'User', text: userMessage }]);
|
||||
|
||||
const question = { query: message };
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const email = storedUser ? JSON.parse(storedUser).email : '';
|
||||
const payload = selectedChatId
|
||||
? { query: userMessage, chatId: selectedChatId, email }
|
||||
: { query: userMessage, email };
|
||||
|
||||
try {
|
||||
const res = await sendChatQuestion(question).unwrap();
|
||||
console.log("Response from server:", res);
|
||||
const res = await sendChatQuestion(payload).unwrap();
|
||||
let bestAnswer = res.response.best_answer;
|
||||
if (typeof bestAnswer !== 'string') {
|
||||
bestAnswer = String(bestAnswer);
|
||||
}
|
||||
bestAnswer = bestAnswer.trim();
|
||||
|
||||
let bestAnswer = res.best_answer.replace(/[*#]/g, "");
|
||||
const model = res.model;
|
||||
if (bestAnswer) {
|
||||
setChatHistory((prev) => [...prev, { sender: 'Assistant', text: bestAnswer }]);
|
||||
}
|
||||
|
||||
bestAnswer = bestAnswer.replace(/(\d\.\s)/g, "\n\n$1").replace(/:\s-/g, ":\n-");
|
||||
|
||||
const assistantMessage = {
|
||||
sender: 'Assistant',
|
||||
text: `Model: ${model}:\n${bestAnswer}`,
|
||||
};
|
||||
|
||||
setChatHistory((prev) => [...prev, assistantMessage]);
|
||||
if (!selectedChatId && res.response.chatId) {
|
||||
const updatedChatHistory = [...chatHistory, { sender: 'User', text: userMessage }];
|
||||
if (bestAnswer) {
|
||||
updatedChatHistory.push({ sender: 'Assistant', text: bestAnswer });
|
||||
}
|
||||
const chatString = updatedChatHistory
|
||||
.map((msg) => (msg.sender === 'User' ? 'User: ' : 'Bot: ') + msg.text)
|
||||
.join('\n');
|
||||
navigate(`/dashboard/chat/${res.response.chatId}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
selectedChat: {
|
||||
id: res.response.chatId,
|
||||
chat: chatString,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
setChatHistory((prev) => [...prev, { sender: 'Assistant', text: "Что-то пошло не так" }]);
|
||||
console.error('Error:', error);
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ sender: 'Assistant', text: 'Что-то пошло не так' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
useGSAP(() => {
|
||||
gsap.from('#firstheading', { opacity: 0.3, ease: 'power2.inOut', duration: 0.5 });
|
||||
gsap.from('#secondheading', { opacity: 0, y: 5, ease: 'power2.inOut', delay: 0.3, duration: 0.5 });
|
||||
gsap.from('#buttons', { opacity: 0, y: 5, ease: 'power2.inOut', delay: 0.3, duration: 0.5 });
|
||||
gsap.from('#input', { opacity: 0, y: 5, ease: 'power2.inOut', duration: 0.5 });
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col justify-end items-center p-4 gap-8'>
|
||||
<div className="w-full overflow-y-auto no-scrollbar h-full p-2 border-gray-200 mb-4">
|
||||
{chatHistory.length > 0 ? (
|
||||
<>
|
||||
{chatHistory.map((msg, index) => (
|
||||
<div className="flex flex-col justify-end items-center p-4 gap-8 h-full w-full">
|
||||
<div className="w-full p-2 rounded overflow-y-auto h-full mb-4">
|
||||
{chatHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-xl">Start a New Chat</h1>
|
||||
</div>
|
||||
) : (
|
||||
chatHistory.map((msg, index) => {
|
||||
const formattedMessage = formatMessage(msg.text);
|
||||
if (!formattedMessage) return null;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${msg.sender === 'User' ? 'justify-end' : 'justify-start'} mb-2`}
|
||||
className={`flex mb-2 ${msg.sender === 'User' ? 'justify-end' : 'justify-start items-start'}`}
|
||||
>
|
||||
{msg.sender === 'Assistant' && (
|
||||
<img
|
||||
src={callCenterIcon}
|
||||
alt="Call Center Icon"
|
||||
className="w-6 h-6 mr-2"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`p-2 rounded-lg max-w-md ${msg.sender === 'User' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}`}
|
||||
className={`p-3 rounded-lg max-w-md ${
|
||||
msg.sender === 'User'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{msg.text.split("\n").map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
{msg.rating && <p>Rating: {msg.rating}</p>}
|
||||
{msg.explanation && <p>Explanation: {msg.explanation}</p>}
|
||||
{formattedMessage}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(isLoading || isFetching) && (
|
||||
<div className="flex justify-start mb-2">
|
||||
<div className="p-2 rounded-lg max-w-md bg-gray-200 text-gray-800">
|
||||
<p className="flex items-center">I'm thinking <div className="loader"></div></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full items-center flex flex-col gap-2 justify-center">
|
||||
<h1 className="text-xl" id="firstheading">Ask any question or advice about your health or trainings and let's see what happens</h1>
|
||||
<h2 className="text-gray-600" id="secondheading">Choose a category for a better experience and make your life better with Health AI</h2>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{(isLoading || isFetching) && (
|
||||
<div className="flex mb-2 justify-start items-start">
|
||||
<img src={callCenterIcon} alt="Call Center Icon" className="w-6 h-6 mr-2" />
|
||||
<div className="p-3 rounded-lg max-w-md bg-gray-200 text-gray-800 flex items-center">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-3 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Assistant is typing...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/*<div id="buttons">*/}
|
||||
{/* <div className="flex gap-6">*/}
|
||||
{/* <button onClick={() => setCategory('medication')}*/}
|
||||
{/* className={`flex items-center shadow-lg justify-center gap-2 ${category === 'medication' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>*/}
|
||||
{/* Medications <MdLocalPharmacy size={30}/>*/}
|
||||
{/* </button>*/}
|
||||
{/* <button onClick={() => setCategory('supplements')}*/}
|
||||
{/* className={`flex items-center shadow-lg justify-center gap-2 ${category === 'supplements' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>*/}
|
||||
{/* Supplements <GiPill size={25}/>*/}
|
||||
{/* </button>*/}
|
||||
{/* <button onClick={() => setCategory('lifestyle')}*/}
|
||||
{/* className={`flex items-center shadow-lg justify-center gap-2 ${category === 'lifestyle' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>*/}
|
||||
{/* Lifestyle <MdSelfImprovement size={25}/>*/}
|
||||
{/* </button>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
|
||||
<div id="input" className="w-2/3 rounded-xl drop-shadow-2xl mb-20">
|
||||
<div className="w-2/3 mb-20">
|
||||
<div className="flex">
|
||||
<input placeholder="Waiting for your question..." value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full px-5 py-2 rounded-l-xl outline-none" type="text"/>
|
||||
<button disabled={isLoading || isFetching} onClick={onSubmit}
|
||||
className="bg-black rounded-r-xl px-4 py-2 text-white font-semibold hover:bg-slate-700">Send
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Waiting for your question..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
disabled={isLoading || isFetching}
|
||||
className="w-full px-5 py-2 rounded-l-xl outline-none border border-gray-300"
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isLoading || isFetching}
|
||||
className="bg-black text-white font-semibold px-4 py-2 rounded-r-xl hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user