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:
oleh 2025-02-12 14:40:28 +01:00
parent 771a6f8432
commit 677ae05159
10 changed files with 791 additions and 254 deletions

View File

@ -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():
# 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
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
###############################################################################
# Функция перевода описания лекарства с сохранением названия (до двоеточия) #
###############################################################################
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)
}

View File

@ -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,7 +46,8 @@ CLIENT_ID = "532143017111-4eqtlp0oejqaovj6rf5l1ergvhrp4vao.apps.googleuserconten
def save_user_to_db(name, email, google_id=None, password=None):
try:
cursor.execute(
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO users (name, email, google_id, password)
VALUES (%s, %s, %s, %s)
@ -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()
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()
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
# Сравнение простым текстом — в 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
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)

View File

@ -1,10 +1,11 @@
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">
@ -17,25 +18,24 @@ const Layout = () => (
</div>
);
function App() {
return (
<Router>
<Routes>
<Route path='/' element={<LandingPage />} />
<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 path="new-chat" element={<NewChatPage />} />
{/* Существующий чат (после создания нового, URL обновится) */}
<Route path="chat/:id" element={<HomePage />} />
<Route path="history" element={<ChatHistory />} />
<Route index element={<HomePage />} />
<Route path="history" element={<>Sorry not implemented yet</>} />
</Route>
</Routes>
</Router>
)
);
}
export default App
export default App;

View 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;

View 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;

View File

@ -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;

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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() {
if (!message.trim()) return;
setChatHistory([...chatHistory, { sender: 'User', text: message }]);
setMessage('');
const isNewChat = state.newChat === true;
const selectedChat = state.selectedChat || null;
const selectedChatId = selectedChat ? selectedChat.id : null;
const question = { query: message };
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatHistory, isLoading, isFetching]);
try {
const res = await sendChatQuestion(question).unwrap();
console.log("Response from server:", res);
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]);
let bestAnswer = res.best_answer.replace(/[*#]/g, "");
const model = res.model;
/**
* Функция форматирования сообщения.
* Если в ответе отсутствуют символы перевода строки, пытаемся разбить текст по нумерованным пунктам.
*/
const formatMessage = (text: string) => {
let lines: string[] = [];
bestAnswer = bestAnswer.replace(/(\d\.\s)/g, "\n\n$1").replace(/:\s-/g, ":\n-");
if (text.includes('\n')) {
lines = text.split('\n');
} else {
lines = text.split(/(?=\d+\.\s+)/);
}
const assistantMessage = {
sender: 'Assistant',
text: `Model: ${model}:\n${bestAnswer}`,
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>;
});
};
setChatHistory((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error("Error:", error);
setChatHistory((prev) => [...prev, { sender: 'Assistant', text: "Что-то пошло не так" }]);
const onSubmit = async () => {
if (!message.trim()) return;
const userMessage = message.trim();
setMessage('');
setChatHistory((prev) => [...prev, { sender: 'User', text: userMessage }]);
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(payload).unwrap();
let bestAnswer = res.response.best_answer;
if (typeof bestAnswer !== 'string') {
bestAnswer = String(bestAnswer);
}
bestAnswer = bestAnswer.trim();
if (bestAnswer) {
setChatHistory((prev) => [...prev, { sender: 'Assistant', text: bestAnswer }]);
}
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 });
}, []);
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: 'Что-то пошло не так' },
]);
}
};
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 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 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>
</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}
<input
type="text"
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
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>