new frontend, evaluating functionality on BE, dockerFiles

This commit is contained in:
oleh 2024-10-31 15:05:21 +01:00
parent 8b2aad77aa
commit 4e0499ff05
41 changed files with 4161 additions and 15932 deletions

14
Backend/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
# Используем базовый образ Python
FROM python:3.12
# Устанавливаем рабочую директорию в контейнере
WORKDIR /app
# Копируем все файлы проекта в контейнер
COPY . .
# Устанавливаем зависимости из requirements.txt
RUN pip install -r requirements.txt
# Запускаем сервер (замените server.py на точку входа вашего бэкенда)
CMD ["python", "server.py"]

Binary file not shown.

View File

@ -1,33 +1,74 @@
import json
from elasticsearch import Elasticsearch
from langchain_huggingface import HuggingFaceEmbeddings
from elasticsearch.helpers import bulk
import json
es = Elasticsearch([{'host': 'localhost', 'port': 9200, 'scheme': 'http'}])
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
def create_index():
# Определяем маппинг для индекса
mapping = {
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "standard"
},
"vector": {
"type": "dense_vector",
"dims": 384 # Размерность векторного представления
},
"full_data": {
"type": "object",
"enabled": False # Отключаем индексацию вложенных данных
}
}
}
}
es.indices.create(index='drug_docs', body=mapping, ignore=400)
def load_drug_data(json_path):
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
def index_documents(data):
for i, item in enumerate(data):
actions = []
total_docs = len(data)
for i, item in enumerate(data, start=1):
doc_text = f"{item['link']} {item.get('pribalovy_letak', '')} {item.get('spc', '')}"
vector = embeddings.embed_query(doc_text)
es.index(index='drug_docs', id=i, body={
'text': doc_text,
'vector': vector,
'full_data': item
})
action = {
"_index": "drug_docs",
"_id": i,
"_source": {
'text': doc_text,
'vector': vector,
'full_data': item
}
}
actions.append(action)
# Отображение прогресса
print(f"Индексируется документ {i}/{total_docs}", end='\r')
data_path = "data/cleaned_general_info_additional.json"
drug_data = load_drug_data(data_path)
index_documents(drug_data)
# Опционально: индексируем пакетами по N документов
if i % 100 == 0 or i == total_docs:
bulk(es, actions)
actions = []
print("Индексирование завершено.")
# Если остались неиндексированные документы
if actions:
bulk(es, actions)
print("\nИндексирование завершено.")
if __name__ == "__main__":
create_index()
data_path = "../../data_adc_databaza/cleaned_general_info_additional.json"
drug_data = load_drug_data(data_path)
index_documents(drug_data)

View File

@ -1,59 +1,80 @@
from elasticsearch import Elasticsearch
import json
import requests
import logging
import time
import re
from requests.exceptions import HTTPError
from elasticsearch import Elasticsearch
from langchain.chains import SequentialChain
from langchain.chains import LLMChain, SequentialChain
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_elasticsearch import ElasticsearchStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
import logging
from langchain.docstore.document import Document
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Загрузка конфигурации
config_file_path = "config.json"
with open(config_file_path, 'r') as config_file:
config = json.load(config_file)
# Загрузка API ключа Mistral
mistral_api_key = "hXDC4RBJk1qy5pOlrgr01GtOlmyCBaNs"
if not mistral_api_key:
raise ValueError("API ключ не найден.")
raise ValueError("API ключ Mistral не найден в конфигурации.")
# Класс для работы с моделями Mistral через OpenAI API
class CustomMistralLLM:
def __init__(self, api_key: str, endpoint_url: str):
def __init__(self, api_key: str, endpoint_url: str, model_name: str):
self.api_key = api_key
self.endpoint_url = endpoint_url
self.model_name = model_name
def generate_text(self, prompt: str, max_tokens=512, temperature=0.7):
def generate_text(self, prompt: str, max_tokens=512, temperature=0.7, retries=3, delay=2):
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "mistral-small-latest",
"model": self.model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": temperature
}
response = requests.post(self.endpoint_url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
logger.info(f"Полный ответ от модели Mistral: {result}")
return result.get("choices", [{}])[0].get("message", {}).get("content", "No response")
attempt = 0
while attempt < retries:
try:
response = requests.post(self.endpoint_url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
logger.info(f"Полный ответ от модели {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
logger.warning(f"Превышен лимит запросов. Ожидание {delay} секунд перед повторной попыткой.")
time.sleep(delay)
attempt += 1
else:
logger.error(f"HTTP Error: {e}")
raise e
except Exception as e:
logger.error(f"Ошибка: {str(e)}")
raise e
raise Exception("Превышено количество попыток запроса к API")
# Инициализация эмбеддингов
logger.info("Загрузка модели HuggingFaceEmbeddings...")
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
# Определяем имя индекса
index_name = 'drug_docs'
config_file_path = "config.json"
with open(config_file_path, 'r') as config_file:
config = json.load(config_file)
# Cloud ID
# Подключение к Elasticsearch
if config.get("useCloud", False):
logger.info("CLOUD ELASTIC")
cloud_id = "tt:dXMtZWFzdC0yLmF3cy5lbGFzdGljLWNsb3VkLmNvbTo0NDMkOGM3ODQ0ZWVhZTEyNGY3NmFjNjQyNDFhNjI4NmVhYzMkZTI3YjlkNTQ0ODdhNGViNmEyMTcxMjMxNmJhMWI0ZGU=" # Замените на ваш Cloud ID
@ -61,48 +82,176 @@ if config.get("useCloud", False):
es_cloud_id=cloud_id,
index_name='drug_docs',
embedding=embeddings,
es_user = "elastic",
es_password = "sSz2BEGv56JRNjGFwoQ191RJ",
es_user="elastic",
es_password="sSz2BEGv56JRNjGFwoQ191RJ",
)
else:
logger.info("LOCAL ELASTIC")
logger.info("LOCALlla ELASTIC")
vectorstore = ElasticsearchStore(
es_url="http://localhost:9200",
index_name='drug_docs',
es_url="http://host.docker.internal:9200",
index_name=index_name,
embedding=embeddings,
)
logger.info(f"Подключение установлено к {'облачному' if config.get('useCloud', False) else 'локальному'} Elasticsearch")
# LLM
llm = CustomMistralLLM(
# Инициализация моделей
llm_small = CustomMistralLLM(
api_key=mistral_api_key,
endpoint_url="https://api.mistral.ai/v1/chat/completions"
endpoint_url="https://api.mistral.ai/v1/chat/completions",
model_name="mistral-small-latest"
)
llm_large = CustomMistralLLM(
api_key=mistral_api_key,
endpoint_url="https://api.mistral.ai/v1/chat/completions",
model_name="mistral-large-latest"
)
# Функция для оценки релевантности результатов
def evaluate_results(query, summaries, model_name):
"""
Оценивает результаты на основе длины текста, наличия ключевых слов из запроса
и других подходящих критериев. Используется для определения качества вывода от модели.
"""
query_keywords = query.split() # Получаем ключевые слова из запроса
total_score = 0
explanation = []
for i, summary in enumerate(summaries):
# Оценка по длине ответа
length_score = min(len(summary) / 100, 10)
total_score += length_score
explanation.append(f"Document {i+1}: Length score - {length_score}")
# Оценка по количеству совпадений ключевых слов
keyword_matches = sum(1 for word in query_keywords if word.lower() in summary.lower())
keyword_score = min(keyword_matches * 2, 10) # Максимальная оценка за ключевые слова - 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"Оценка для модели {model_name}: {final_score}/10")
logger.info(f"Пояснение оценки:\n{explanation_summary}")
return {"rating": round(final_score, 2), "explanation": explanation_summary}
# Функция для сравнения результатов двух моделей
# Функция для сравнения результатов двух моделей
# Функция для сравнения результатов двух моделей
def compare_models(small_model_results, large_model_results, query):
logger.info("Начато сравнение моделей Mistral Small и Mistral Large")
# Логируем результаты
logger.info("Сравнение оценок моделей:")
logger.info(f"Mistral Small: Оценка - {small_model_results['rating']}, Объяснение - {small_model_results['explanation']}")
logger.info(f"Mistral Large: Оценка - {large_model_results['rating']}, Объяснение - {large_model_results['explanation']}")
# Форматируем вывод для текстового и векторного поиска
comparison_summary = {
"query": query,
"text_search": f"Текстовый поиск: Mistral Small - {small_model_results['rating']}/10, Mistral Large - {large_model_results['rating']}/10",
"vector_search": f"Векторный поиск: Mistral Small - {small_model_results['rating']}/10, Mistral Large - {large_model_results['rating']}/10"
}
logger.info(f"Результат сравнения: \n{comparison_summary['text_search']}\n{comparison_summary['vector_search']}")
return comparison_summary
# Функция для обработки запроса
# Функция для обработки запроса
# Функция для обработки запроса
def process_query_with_mistral(query, k=10):
logger.info("Обработка запроса началась.")
try:
# Elasticsearch LangChain
response = vectorstore.similarity_search(query, k=k)
if not response:
return {"summary": "Ничего не найдено", "links": [], "status_log": ["Ничего не найдено."]}
# --- ВЕКТОРНЫЙ ПОИСК ---
vector_results = vectorstore.similarity_search(query, k=k)
vector_documents = [hit.metadata.get('text', '') for hit in vector_results]
documents = [hit.metadata.get('text', '') for hit in response]
links = [hit.metadata.get('link', '-') for hit in response]
structured_prompt = (
f"Na základe otázky: '{query}' a nasledujúcich informácií o liekoch: {documents}. "
"Uveďte tri vhodné lieky alebo riešenia s krátkym vysvetlením pre každý z nich. "
"Odpoveď musí byť v slovenčine."
# Ограничиваем количество документов и их длину
max_docs = 5
max_doc_length = 1000
vector_documents = [doc[:max_doc_length] for doc in vector_documents[:max_docs]]
if vector_documents:
vector_prompt = (
f"Na základe otázky: '{query}' a nasledujúcich informácií o liekoch: {vector_documents}. "
"Uveďte tri vhodné lieky или riešenia с кратким vysvetlením pre každý z nich. "
"Odpoveď musí byť в 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:
small_vector_eval = {"rating": 0, "explanation": "No results"}
large_vector_eval = {"rating": 0, "explanation": "No results"}
# --- ТЕКСТОВЫЙ ПОИСК ---
es_results = vectorstore.client.search(
index=index_name,
body={"size": k, "query": {"match": {"text": query}}}
)
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]]
summary = llm.generate_text(prompt=structured_prompt, max_tokens=512, temperature=0.7)
if text_documents:
text_prompt = (
f"Na základe otázky: '{query}' a nasledujúcich informácií о liekoch: {text_documents}. "
"Uveďte три vhodné lieky alebo riešenia с кратким vysvetленím pre každý з них. "
"Odpoveď musí byť в 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)
#TextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
split_summary = splitter.split_text(summary)
split_summary_small_text = splitter.split_text(summary_small_text)
split_summary_large_text = splitter.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:
small_text_eval = {"rating": 0, "explanation": "No results"}
large_text_eval = {"rating": 0, "explanation": "No results"}
# Выбираем лучший результат среди всех
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['model']} с оценкой {best_result['eval']['rating']}.")
# Возвращаем только лучший ответ
return {
"best_answer": best_result["summary"],
"model": best_result["model"],
"rating": best_result["eval"]["rating"],
"explanation": best_result["eval"]["explanation"]
}
return {"summary": split_summary, "links": links, "status_log": ["Ответ получен от модели Mistral."]}
except Exception as e:
logger.info(f"Ошибка: {str(e)}")
return {"summary": "Произошла ошибка", "links": [], "status_log": [f"Ошибка: {str(e)}"]}
logger.error(f"Ошибка: {str(e)}")
return {
"best_answer": "Произошла ошибка при обработке запроса.",
"error": str(e)
}

191
Backend/requirements.txt Normal file
View File

@ -0,0 +1,191 @@
aiofiles==23.2.1
aiohttp==3.9.5
aiosignal==1.3.1
annotated-types==0.7.0
anyio==4.4.0
argcomplete==3.5.0
attrs==23.2.0
beautifulsoup4==4.12.3
black==22.12.0
blinker==1.8.2
blobfile==3.0.0
certifi==2024.7.4
cffi==1.16.0
cfgv==3.4.0
charset-normalizer==3.3.2
click==8.1.7
colorama==0.4.6
contourpy==1.2.1
coverage==7.6.0
cryptography==3.4.8
cycler==0.12.1
dataclasses-json==0.6.7
decorator==5.1.1
Deprecated==1.2.14
dirtyjson==1.0.8
distlib==0.3.8
distro==1.9.0
dnspython==2.6.1
docstring_parser==0.16
docx2txt==0.8
elastic-transport==8.15.0
elasticsearch==8.15.1
email_validator==2.2.0
eval_type_backport==0.2.0
fastapi==0.111.1
fastapi-cli==0.0.4
ffmpy==0.4.0
filelock==3.15.4
fire==0.6.0
Flask==3.0.3
Flask-Cors==5.0.0
fonttools==4.53.1
frozenlist==1.4.1
fsspec==2024.6.1
gradio==4.39.0
gradio_client==1.1.1
greenlet==3.0.3
grpcio==1.63.0
grpcio-tools==1.62.2
h11==0.14.0
h2==4.1.0
hpack==4.0.0
httpcore==1.0.5
httptools==0.6.1
httpx==0.27.0
huggingface-hub==0.24.3
hyperframe==6.0.1
identify==2.6.0
idna==3.7
importlib_metadata==8.4.0
importlib_resources==6.4.0
iniconfig==2.0.0
injector==0.21.0
itsdangerous==2.2.0
Jinja2==3.1.4
jiter==0.5.0
joblib==1.4.2
jsonpatch==1.33
jsonpath-python==1.0.6
jsonpointer==3.0.0
jsonschema==4.23.0
jsonschema-specifications==2023.12.1
kiwisolver==1.4.5
langchain==0.3.3
langchain-community==0.3.2
langchain-core==0.3.10
langchain-elasticsearch==0.3.0
langchain-huggingface==0.1.0
langchain-text-splitters==0.3.0
langsmith==0.1.132
llama-index-core==0.10.58
llama-index-embeddings-ollama==0.1.2
llama-index-llms-ollama==0.2.2
llama-index-readers-file==0.1.31
llama-index-vector-stores-qdrant==0.2.14
llama_models==0.0.19
llama_toolchain==0.0.17
lxml==5.3.0
markdown-it-py==3.0.0
MarkupSafe==2.1.5
marshmallow==3.21.3
matplotlib==3.9.1.post1
mdurl==0.1.2
minijinja==2.0.1
mistral_common==1.4.2
mistral_inference==1.4.0
mistralai==1.1.0
mpmath==1.3.0
multidict==6.0.5
mypy==1.11.0
mypy-extensions==1.0.0
nest-asyncio==1.6.0
networkx==3.3
nltk==3.8.1
nodeenv==1.9.1
numpy==1.26.4
ollama==0.3.0
openai==1.51.2
opencv-python-headless==4.10.0.84
opentelemetry-api==1.27.0
orjson==3.10.6
packaging==24.1
pandas==2.2.2
pathspec==0.12.1
pillow==10.4.0
pipx==1.7.1
platformdirs==4.2.2
pluggy==1.5.0
portalocker==2.10.1
pre-commit==2.21.0
protobuf==4.25.4
pycparser==2.22
pycryptodomex==3.20.0
pydantic==2.9.2
pydantic-extra-types==2.9.0
pydantic-settings==2.5.2
pydantic_core==2.23.4
pydub==0.25.1
Pygments==2.18.0
pyparsing==3.1.2
pypdf==4.3.1
pytest==7.4.4
pytest-asyncio==0.21.2
pytest-cov==3.0.0
python-dateutil==2.8.2
python-dotenv==1.0.1
python-multipart==0.0.9
pytz==2024.1
PyYAML==6.0.1
qdrant-client==1.10.1
referencing==0.35.1
regex==2024.7.24
requests==2.32.3
requests-toolbelt==1.0.0
retry-async==0.1.4
rich==13.7.1
rpds-py==0.20.0
ruff==0.5.5
safetensors==0.4.3
scikit-learn==1.5.2
scipy==1.14.1
semantic-version==2.10.0
sentence-transformers==3.1.0
sentencepiece==0.2.0
shellingham==1.5.4
simple-parsing==0.1.6
simsimd==5.6.3
six==1.16.0
sniffio==1.3.1
soupsieve==2.5
SQLAlchemy==2.0.31
starlette==0.37.2
striprtf==0.0.26
sympy==1.13.3
tenacity==8.5.0
termcolor==2.4.0
threadpoolctl==3.5.0
tiktoken==0.7.0
tokenizers==0.19.1
tomlkit==0.12.0
torch==2.4.1
tqdm==4.66.4
transformers==4.43.3
typer==0.12.3
types-PyYAML==6.0.12.20240724
typing-inspect==0.9.0
typing_extensions==4.12.2
tzdata==2024.1
ujson==5.10.0
urllib3==2.2.2
userpath==1.9.2
uvicorn==0.30.3
virtualenv==20.26.3
watchdog==4.0.1
watchfiles==0.22.0
websockets==11.0.3
Werkzeug==3.0.4
wrapt==1.16.0
xformers==0.0.28.post1
yarl==1.9.4
zipp==3.20.2

View File

@ -1,21 +1,28 @@
from flask import Flask, request, jsonify
from flask_cors import CORS # Импортируем CORS
from flask_cors import CORS
import logging
# Импортируем функцию обработки из model.py
from model import process_query_with_mistral
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Создаем Flask приложение
app = Flask(__name__)
CORS(app)
CORS(app) # Разрешаем CORS для всех доменов
CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}})
# Маршрут для обработки запросов от фронтенда
@app.route('/api/chat', methods=['POST'])
def chat():
data = request.get_json()
query = data.get('query', '')
if not query:
return jsonify({'error': 'No query provided'}), 400
response = process_query_with_mistral(query)
return jsonify(response)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
services:
backend:
build:
context: ./Backend
dockerfile: Dockerfile
ports:
- "5000:5000"
environment:
- ELASTICSEARCH_HOST=http://host.docker.internal:9200
networks:
- app-network
networks:
app-network:
driver: bridge

41
frontend/.gitignore vendored
View File

@ -1,23 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,8 @@
{
"hash": "c4d2d06d",
"configHash": "9db50785",
"lockfileHash": "e3b0c442",
"browserHash": "efbdb5f3",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

17
frontend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# Используем базовый образ Node.js
FROM node:18
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем файлы проекта
COPY . .
# Устанавливаем зависимости
RUN npm install
# Собираем проект
RUN npm run dev
# Запускаем сервер
CMD ["npm", "run", "dev"]

Binary file not shown.

28
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Health AI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17928
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,45 @@
{
"name": "frontend",
"version": "0.1.0",
"name": "coconuts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.1.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.112",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"axios": "^1.7.7",
"framer-motion": "^11.11.1",
"@gsap/react": "^2.1.1",
"@mui/icons-material": "^6.1.5",
"@mui/material": "^6.1.5",
"@reduxjs/toolkit": "^2.3.0",
"appwrite": "^16.0.2",
"final-form": "^4.20.10",
"gsap": "^3.12.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"three": "^0.169.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"react-final-form": "^6.5.9",
"react-icons": "^5.3.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"react-router-dom": "^6.26.2"
"@eslint/js": "^9.13.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.13",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.2",
"typescript-eslint": "^8.10.0",
"vite": "^5.4.9"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

37
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
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';
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>
</div>
);
function App() {
return (
<Router>
<Routes>
<Route path='/' element={<LandingPage />} />
<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>
)
}
export default App

View File

@ -0,0 +1,100 @@
import React from 'react';
import { Form, Field } from 'react-final-form';
interface FormValues {
healthGoal: string;
dietType?: string;
exerciseLevel?: string;
hydrationGoal?: string;
userInput: string;
}
const EatingForm: React.FC = () => {
const onSubmit = (values: FormValues) => {
console.log('Form values:', values);
};
return (
<Form<FormValues>
onSubmit={onSubmit}
render={({ handleSubmit, form }) => {
const healthGoal = form.getFieldState("healthGoal")?.value;
return (
<form onSubmit={handleSubmit} className="flex flex-col items-center p-8 bg-gray-100 rounded-lg shadow-lg max-w-md mx-auto">
<h2 className="text-2xl font-semibold text-gray-800 mb-6">Select Your Health Goal</h2>
{/* Health Goal Selection */}
<div className="w-full mb-4">
<label className="text-gray-700 mb-2 block">Health Goal</label>
<Field<string> name="healthGoal" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select your goal</option>
<option value="weight_loss">Weight Loss</option>
<option value="muscle_gain">Muscle Gain</option>
<option value="improve_energy">Improve Energy</option>
<option value="enhance_focus">Enhance Focus</option>
<option value="general_health">General Health</option>
</Field>
</div>
{/* Dynamic Fields Based on Health Goal */}
{healthGoal === 'weight_loss' && (
<div className="w-full mb-4">
<label className="text-gray-700 mb-2 block">Diet Type</label>
<Field<string> name="dietType" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select diet type</option>
<option value="keto">Keto</option>
<option value="low_carb">Low Carb</option>
<option value="intermittent_fasting">Intermittent Fasting</option>
<option value="mediterranean">Mediterranean</option>
</Field>
</div>
)}
{healthGoal === 'muscle_gain' && (
<div className="w-full mb-4">
<label className="text-gray-700 mb-2 block">Exercise Level</label>
<Field<string> name="exerciseLevel" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select exercise level</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</Field>
</div>
)}
{healthGoal === 'improve_energy' && (
<div className="w-full mb-4">
<label className="text-gray-700 mb-2 block">Hydration Goal</label>
<Field<string> name="hydrationGoal" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select hydration goal</option>
<option value="2_liters">2 Liters</option>
<option value="3_liters">3 Liters</option>
<option value="4_liters">4 Liters</option>
</Field>
</div>
)}
{/* User Input */}
<div className="w-full mb-4">
<label className="text-gray-700 mb-2 block">Your Preferences</label>
<Field<string>
name="userInput"
component="input"
type="text"
placeholder="Enter your preferences or comments"
className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500"
/>
</div>
<button type="submit" className="px-6 py-3 text-white bg-blue-500 rounded-lg hover:bg-blue-600 transition-colors">
Submit
</button>
</form>
);
}}
/>
);
};
export default EatingForm;

View File

@ -0,0 +1,235 @@
import React, { useState } from 'react';
export type Muscle = 'neck' | 'chest' | 'biceps' | 'forearms' | 'quadriceps' | 'calves' | 'abs' | 'shoulders' | 'trapezius';
const MuscleDiagram: React.FC = () => {
const [highlightedMuscle, setHighlightedMuscle] = useState<Muscle | null>(null);
const handleMouseEnter = (muscle: Muscle) => {
setHighlightedMuscle(muscle);
};
const handleMouseLeave = () => {
setHighlightedMuscle(null);
};
const handleMuscleClick = (muscle: Muscle) => {
}
const getDarkerColor = (baseColor: string) => {
const colorValue = parseInt(baseColor.slice(1), 16);
const r = Math.max((colorValue >> 16) - 20, 0);
const g = Math.max(((colorValue >> 8) & 0x00ff) - 20, 0);
const b = Math.max((colorValue & 0x0000ff) - 20, 0);
return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`;
};
const baseColor = "#f1c27d";
return (
<div style={{ textAlign: 'center', width: '200px', margin: '0 auto' }}>
<svg viewBox="0 0 200 400" width="200" height="400">
{/* Голова */}
<circle
cx="100"
cy="50"
r="30"
fill={baseColor}
stroke="black"
id="head"
/>
{/* Шея */}
<rect
x="90"
y="70"
width="20"
height="30"
fill={highlightedMuscle === 'neck' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="neck"
onMouseEnter={() => handleMouseEnter('neck')}
onMouseLeave={handleMouseLeave}
/>
{/* Трапеции */}
<path
d="M70,100 Q100,60 130,100 L120,110 Q100,90 80,110 Z"
fill={highlightedMuscle === 'trapezius' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="trapezius"
onMouseEnter={() => handleMouseEnter('trapezius')}
onMouseLeave={handleMouseLeave}
/>
{/* Грудные мышцы */}
<path
d="M70,100 L130,100 C135,125 135,125 130,150 Q100,170 70,150 C65,125 65,125 70,100 Z"
fill={highlightedMuscle === 'chest' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="chest"
onMouseEnter={() => handleMouseEnter('chest')}
onMouseLeave={handleMouseLeave}
/>
{/* Плечи */}
<path
d="M70,100 L60,120 L70,130 L80,110 Z"
fill={highlightedMuscle === 'shoulders' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="leftShoulder"
onMouseEnter={() => handleMouseEnter('shoulders')}
onMouseLeave={handleMouseLeave}
/>
<path
d="M130,100 L140,120 L130,130 L120,110 Z"
fill={highlightedMuscle === 'shoulders' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="rightShoulder"
onMouseEnter={() => handleMouseEnter('shoulders')}
onMouseLeave={handleMouseLeave}
/>
{/* Бицепсы */}
<g transform="rotate(25,60,130)">
<rect
x="55"
y="125"
width="15"
height="35"
rx="5"
ry="5"
fill={highlightedMuscle === 'biceps' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="leftBicep"
onMouseEnter={() => handleMouseEnter('biceps')}
onMouseLeave={handleMouseLeave}
/>
</g>
<g transform="rotate(-25,140,130)">
<rect
x="130"
y="125"
width="15"
height="35"
rx="5"
ry="5"
fill={highlightedMuscle === 'biceps' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="rightBicep"
onMouseEnter={() => handleMouseEnter('biceps')}
onMouseLeave={handleMouseLeave}
/>
</g>
{/* Предплечья */}
<g transform="rotate(25,60,130)">
<rect
x="55"
y="160"
width="15"
height="35"
rx="5"
ry="5"
fill={highlightedMuscle === 'forearms' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="leftForearm"
onMouseEnter={() => handleMouseEnter('forearms')}
onMouseLeave={handleMouseLeave}
/>
</g>
<g transform="rotate(-25,140,130)">
<rect
x="130"
y="160"
width="15"
height="35"
rx="5"
ry="5"
fill={highlightedMuscle === 'forearms' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="rightForearm"
onMouseEnter={() => handleMouseEnter('forearms')}
onMouseLeave={handleMouseLeave}
/>
</g>
{/* Пресс */}
<path
d="M70,150 L130,150 L130,210 Q100,250 70,210 Z"
fill={highlightedMuscle === 'abs' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="abs"
onMouseEnter={() => handleMouseEnter('abs')}
onMouseLeave={handleMouseLeave}
/>
{/* Квадрицепсы */}
<g transform="rotate(5,75,260)">
<ellipse
cx="75"
cy="260"
rx="15"
ry="35"
fill={highlightedMuscle === 'quadriceps' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="leftQuadricep"
onMouseEnter={() => handleMouseEnter('quadriceps')}
onMouseLeave={handleMouseLeave}
/>
</g>
<g transform="rotate(-5,125,260)">
<ellipse
cx="125"
cy="260"
rx="15"
ry="35"
fill={highlightedMuscle === 'quadriceps' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="rightQuadricep"
onMouseEnter={() => handleMouseEnter('quadriceps')}
onMouseLeave={handleMouseLeave}
/>
</g>
{/* Икроножные мышцы */}
<g transform="rotate(5,75,260)">
<ellipse
cx="75"
cy="325"
rx="12"
ry="30"
fill={highlightedMuscle === 'calves' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="leftCalf"
onMouseEnter={() => handleMouseEnter('calves')}
onMouseLeave={handleMouseLeave}
/>
</g>
<g transform="rotate(-5,125,260)">
<ellipse
cx="125"
cy="325"
rx="12"
ry="30"
fill={highlightedMuscle === 'calves' ? getDarkerColor(baseColor) : baseColor}
stroke="black"
id="rightCalf"
onMouseEnter={() => handleMouseEnter('calves')}
onMouseLeave={handleMouseLeave}
/>
</g>
</svg>
{/* Отображение названия мышечной группы */}
<div style={{ marginTop: '20px', fontSize: '18px' }}>
{highlightedMuscle
? `Выделено: ${highlightedMuscle}`
: 'Наведите на мышцу, чтобы увидеть название'}
</div>
</div>
);
};
export default MuscleDiagram;

View File

View File

@ -0,0 +1,275 @@
import React, { useState } from "react";
import { Form, Field } from "react-final-form";
import Slider from "@mui/material/Slider";
import { useLazySendTestVersionQuery } from "../store/api/chatApi";
import { LuLoader2 } from "react-icons/lu";
import { Link } from "react-router-dom";
interface FormValues {
age?: number;
height?: number;
weight?: number;
healthGoal?: string;
dietType?: string;
exerciseLevel?: string;
hydrationGoal?: string;
userInput?: string;
}
const MultiStepForm: React.FC = () => {
const [formValues, setFormValues] = useState<FormValues>({});
const [stage, setStage] = useState<number>(1);
const [data, setData] = useState<string | null>(null)
const [sendTestMessage, { isLoading, isFetching }] = useLazySendTestVersionQuery()
const nextStage = () => setStage((prev) => prev + 1);
const previousStage = () => setStage((prev) => prev - 1);
const saveFormData = (values: FormValues) => {
setFormValues((prev) => ({
...prev,
...values,
}));
};
const onSubmit = (values: FormValues) => {
saveFormData(values);
nextStage();
};
console.log(isLoading)
const finalSubmit = async () => {
const res = await sendTestMessage(formValues).unwrap()
setData(res)
};
const selectEmoji = (
value: number | undefined,
thresholds: number[],
emojis: string[]
) => {
if (value === undefined) return null;
if (value <= thresholds[0]) return emojis[0];
if (value <= thresholds[1]) return emojis[1];
return emojis[2];
};
return !data ? (
<div className="w-full max-w-md bg-white rounded-lg shadow-lg p-8 text-center">
<h1 className="text-2xl font-bold text-gray-700 mb-6">
Fill in your profile and get some advices
</h1>
<Form<FormValues>
onSubmit={onSubmit}
initialValues={formValues}
render={({ handleSubmit, values }) => (
<form onSubmit={handleSubmit}>
{stage === 1 && (<> <div>
<h2 className="text-xl font-semibold text-gray-600 mb-4">
Stage 1: Base information
</h2>
<div className="text-3xl mb-4">
{selectEmoji(values.age, [17, 50], ["👶", "🧑", "👴"])}
</div>
<Field
name="age"
parse={(value) => (value === "" ? 0 : Number(value))}
>
{({ input }) => (
<input
{...input}
type="number"
placeholder="Enter age"
min={0}
max={100}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-indigo-500"
/>
)}
</Field>
</div>
<div>
<div className="text-3xl mb-4">
{selectEmoji(values.height, [150, 175], ["🌱", "🌳", "🌲"])}
</div>
<Field
name="height"
parse={(value) => (value === "" ? 0 : Number(value))}
>
{({ input }) => (
<input
{...input}
type="number"
placeholder="Enter height"
min={0}
max={250}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-indigo-500"
/>
)}
</Field>
</div>
<div >
<h2 className="text-xl font-semibold text-gray-600 mb-4">
</h2>
<Field<string> name="dietType" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select your goal</option>
<option value="weight_loss">Weight Loss</option>
<option value="muscle_gain">Muscle Gain</option>
<option value="improve_energy">Improve Energy</option>
<option value="enhance_focus">Enhance Focus</option>
<option value="general_health">General Health</option>
</Field>
</div>
<div>
<div className="text-3xl mb-4">
{selectEmoji(values.weight, [70, 99], ["🐭", "🐱", "🐘"])}
</div>
<Field
name="weight"
parse={(value) => (value === "" ? 0 : Number(value))}
>
{({ input }) => (
<div>
<Slider
value={input.value || 0}
onChange={(_, value) =>
input.onChange(
Array.isArray(value) ? value[0] : value
)
}
min={0}
max={200}
className="text-indigo-500"
/>
<div className="text-gray-600 mt-2">
Current Weight: {input.value || 0} kg
</div>
</div>
)}
</Field>
<div className="flex justify-end">
<button
type="button"
onClick={nextStage}
className="px-4 py-2 bg-bright-blue text-white rounded-md hover:bg-indigo-500"
>
Next
</button>
</div>
</div>
</>)}
{stage === 2 && (
<>
<div>
<h2 className="text-xl font-semibold text-gray-600 mb-4">
Stage 2: Details
</h2>
</div>
<div className="text-start">
<div className="mb-4">
<label className="text-gray-700 mb-2 block">Diet Type</label>
<Field<string> name="dietType" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select diet type</option>
<option value="keto">Keto</option>
<option value="low_carb">Low Carb</option>
<option value="intermittent_fasting">Intermittent Fasting</option>
<option value="mediterranean">Mediterranean</option>
</Field>
</div>
<div className="mb-4">
<label className="text-gray-700 mb-2 block">Exercise Level</label>
<Field<string> name="exerciseLevel" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select exercise level</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</Field>
</div>
<div className="mb-4">
<label className="text-gray-700 mb-2 block">Hydration Goal</label>
<Field<string> name="hydrationGoal" component="select" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500">
<option value="">Select hydration goal</option>
<option value="2_liters">2 Liters</option>
<option value="3_liters">3 Liters</option>
<option value="4_liters">4 Liters</option>
</Field>
</div>
<div className="mb-4">
<label className="text-gray-700 mb-2 block">Your Preferences</label>
<Field<string> name="userInput" component="input" type="text" placeholder="Enter your preferences or comments" className="w-full p-3 border border-gray-300 rounded-lg text-gray-800 focus:outline-none focus:border-blue-500" />
</div>
<div className="flex justify-between">
<button type="button" onClick={previousStage} className="px-4 py-2 bg-gray-400 text-white rounded-md hover:bg-gray-500">
Previous
</button>
<button type="submit" className="px-4 py-2 bg-bright-blue text-white rounded-md hover:bg-indigo-500">
Next
</button>
</div>
</div>
</>
)}
{stage === 3 && (
<div className="text-start">
<h2 className="text-xl font-semibold text-gray-600 mb-4 text-center">Summary</h2>
<p><strong>Age:</strong> {formValues.age}</p>
<p><strong>Height:</strong> {formValues.height} cm</p>
<p><strong>Weight:</strong> {formValues.weight} kg</p>
<p><strong>Health Goal:</strong> {formValues.healthGoal}</p>
<p><strong>Diet Type:</strong> {formValues.dietType || "Not specified"}</p>
<p><strong>Exercise Level:</strong> {formValues.exerciseLevel || "Not specified"}</p>
<p><strong>Hydration Goal:</strong> {formValues.hydrationGoal || "Not specified"}</p>
<p><strong>User Input:</strong> {formValues.userInput || "Not specified"}</p>
<div className="flex justify-between mt-4">
<button type="button" disabled={isLoading} onClick={previousStage} className="px-4 py-2 bg-gray-400 text-white rounded-md hover:bg-gray-500">
Previous
</button>
<button type="button" disabled={isLoading} onClick={finalSubmit} className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600">
{isLoading || isFetching ? <LuLoader2 className="animate-spin" /> : 'Confirm'}
</button>
</div>
</div>
)}
</form>
)}
/>
</div>
) : (<div className="w-full flex flex-col items-center gap-6">
<h1 className="text-4xl flex items-center sm:text-5xl md:text-6xl font-semibold mb-4 text-center text-dark-blue">
Advices for your health
</h1>
<p className="w-1/2">{data}</p>
<div className="flex gap-2 items-center">
<Link to='/dashboard'>
<button className="bg-bright-blue text-white font-medium py-2 px-5 rounded hover:bg-deep-blue transition duration-300 shadow-md">
Get started with full version
</button>
</Link>
<button onClick={() => { setData(null), setStage(1) }} className="px-4 py-2 bg-gray-400 text-white rounded-md hover:bg-gray-500">
Try again
</button>
</div>
</div>
)
};
export default MultiStepForm;

View File

@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react'
import { IoMdHome } from "react-icons/io";
import { GoHistory } from "react-icons/go";
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 BackImage from '../assets/smallheadicon.png'
export interface NavigationItem {
icon: React.ReactNode,
title: string
link: string
}
const NavigationItems: NavigationItem[] = [
{
title: 'Dashboard',
link: '/dashboard',
icon: <IoMdHome size={30} />
},
{
title: 'History',
link: '/dashboard/history',
icon: <GoHistory size={25} />
}
]
interface NavigationProps {
isExpanded: boolean,
}
const Navigation = ({ isExpanded = false }: NavigationProps) => {
const [theme, setTheme] = useState<'dark' | 'light'>('light')
useEffect(() => {
if (window.matchMedia('(prefers-color-scheme:dark)').matches) {
setTheme('dark');
} else {
setTheme('light')
}
}, [])
useEffect(() => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [theme])
const handleThemeSwitch = () => {
setTheme(theme === "dark" ? "light" : "dark")
}
return (
<div className='h-full p-3 w-fit'>
<div className='h-full rounded-xl border flex flex-col px-1 justify-between py-2 items-center dark:bg-slate-300 shadow-xl'>
<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="" />
</IconButton>
{isExpanded && <p className='text-2xl font-semibold text-dark-blue flex items-center' >Health AI</p>}
</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'>
<IconButton sx={{
width: 40,
height: 40,
borderRadius: 2,
backgroundColor: '#FFFFFF',
...(theme === 'dark' && {
'&:hover': {
backgroundColor: '#eef3f4',
borderColor: '#0062cc',
boxShadow: 'none',
},
}),
}} >
{item.icon}
</IconButton>
{isExpanded && item.title}
</Link>
))}
</div>
</div>
<button onClick={handleThemeSwitch} className='flex items-center gap-2'>
<IconButton
sx={{
width: 40,
height: 40,
borderRadius: 2,
background: theme === 'dark' ? 'white' : 'initial',
'&:focus-visible': {
outline: '2px solid blue', // Кастомний стиль фокуса
outlineOffset: '0px', // Щоб межі виділення були близько до кнопки
borderRadius: '4px', // Залишає квадратні кути навколо фокуса
},
}}>
{theme === 'light' ? <CiLight size={30} /> : <MdOutlineDarkMode size={30} />}
</IconButton>
{isExpanded && (theme === 'light' ? 'Light mode' : 'Dark mode')}
</button>
</div>
</div>
)
}
export default Navigation

View File

@ -0,0 +1,201 @@
import React, { useState } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
import Slider from "@mui/material/Slider";
interface FormValues {
age?: number;
height?: number;
weight?: number;
}
const UserMetricsForm: React.FC = () => {
const [stage, setStage] = useState<number>(1);
const next = () => setStage((prev) => prev + 1);
const previous = () => setStage((prev) => prev - 1);
const onSubmit = (values: FormValues) => {
console.log("Form submitted:", values);
};
// Helper function to select emoji based on value
const selectEmoji = (value: number | undefined, thresholds: number[], emojis: (string | JSX.Element)[]) => {
if (value === undefined) return null;
if (value <= thresholds[0]) return emojis[0];
if (value <= thresholds[1]) return emojis[1];
return emojis[2];
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-200">
<div className="w-full max-w-md bg-white rounded-lg shadow-lg p-8 text-center">
<h1 className="text-2xl font-bold text-gray-700 mb-6">
User Metrics Form
</h1>
<Form<FormValues>
onSubmit={onSubmit}
render={({ handleSubmit, values }) => (
<form onSubmit={handleSubmit}>
{/* Stage 1: Age */}
{stage === 1 && (
<div>
<h2 className="text-xl font-semibold text-gray-600 mb-4">
Stage 1: Age
</h2>
<div className="text-3xl mb-4">
{selectEmoji(values.age, [17, 50], ["👶", "🧑", "👴"])}
</div>
<div className="mb-4">
<label htmlFor="age" className="block text-gray-500 mb-2">
Age
</label>
<Field<number>
name="age"
parse={(value) => (value === undefined ? 0 : Number(value))} // Changed to return 0 instead of undefined
>
{({ input, meta }) => (
<div>
<input
{...input}
id="age"
type="number"
placeholder="Enter age"
min={0}
max={100}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-indigo-500"
/>
{meta.touched && meta.error && (
<span className="text-red-500 text-sm">{meta.error}</span>
)}
</div>
)}
</Field>
</div>
<div className="flex justify-center">
<button
type="button"
onClick={next}
className="px-4 py-2 bg-indigo-500 text-white rounded-md hover:bg-indigo-600"
>
Next
</button>
</div>
</div>
)}
{/* Stage 2: Height */}
{stage === 2 && (
<div>
<h2 className="text-xl font-semibold text-gray-600 mb-4">
Stage 2: Height
</h2>
<div className="text-3xl mb-4">
{selectEmoji(values.height, [150, 175], ["🌼", "🧍🏻", "🦒"])}
</div>
<div className="mb-4">
<label htmlFor="height" className="block text-gray-500 mb-2">
Height (cm)
</label>
<Field<number>
name="height"
parse={(value) => (value === undefined ? 0 : Number(value))} // Changed to return 0 instead of undefined
>
{({ input, meta }) => (
<div>
<input
{...input}
id="height"
type="number"
placeholder="Enter height"
min={0}
max={250}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-indigo-500"
/>
{meta.touched && meta.error && (
<span className="text-red-500 text-sm">{meta.error}</span>
)}
</div>
)}
</Field>
</div>
<div className="flex justify-between">
<button
type="button"
onClick={previous}
className="px-4 py-2 bg-gray-400 text-white rounded-md hover:bg-gray-500"
>
Previous
</button>
<button
type="button"
onClick={next}
className="px-4 py-2 bg-indigo-500 text-white rounded-md hover:bg-indigo-600"
>
Next
</button>
</div>
</div>
)}
{/* Stage 3: Weight */}
{stage === 3 && (
<div>
<h2 className="text-xl font-semibold text-gray-600 mb-4">
Stage 3: Weight
</h2>
<div className="text-3xl mb-4">
{selectEmoji(values.weight, [70, 99], ["🐭", "🐱", "🐘"])}
</div>
<div className="mb-6">
<label htmlFor="weight" className="block text-gray-500 mb-2">
Weight (kg)
</label>
<Field<number> name="weight">
{({ input, meta }: FieldRenderProps<number, HTMLElement>) => (
<div>
<Slider
value={input.value || 0}
onChange={(_, value) => {
const newValue = Array.isArray(value) ? value[0] : value;
input.onChange(newValue);
}}
min={0}
max={200}
className="text-indigo-500"
/>
<div className="text-gray-600 mt-2">
Weight: {input.value || 0} kg
</div>
{meta.touched && meta.error && (
<span className="text-red-500 text-sm">{meta.error}</span>
)}
</div>
)}
</Field>
</div>
<div className="flex justify-between">
<button
type="button"
onClick={previous}
className="px-4 py-2 bg-gray-400 text-white rounded-md hover:bg-gray-500"
>
Previous
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-500 text-white rounded-md hover:bg-indigo-600"
>
Submit
</button>
</div>
</div>
)}
</form>
)}
/>
</div>
</div>
);
};
export default UserMetricsForm;

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,13 +1,37 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Приховує скроллбар у браузерах на основі WebKit (Chrome, Safari) */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
/* Приховує скроллбар у Firefox */
.no-scrollbar {
scrollbar-width: none;
}
/* Приховує скроллбар в Internet Explorer та Edge */
.no-scrollbar {
-ms-overflow-style: none;
}
/* HTML: <div class="loader"></div> */
.loader {
width: 20px;
aspect-ratio: 2;
--_g: no-repeat radial-gradient(circle closest-side,#000 90%,#0000);
background:
var(--_g) 0% 50%,
var(--_g) 50% 50%,
var(--_g) 100% 50%;
background-size: calc(100%/3) 50%;
animation: l3 1s infinite linear;
}
@keyframes l3 {
20%{background-position:0% 0%, 50% 50%,100% 50%}
40%{background-position:0% 100%, 50% 0%,100% 50%}
60%{background-position:0% 50%, 50% 100%,100% 0%}
80%{background-position:0% 50%, 50% 50%,100% 100%}
}

15
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { Provider } from 'react-redux'
import store from './store/index.ts'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)

View File

@ -0,0 +1,117 @@
import { CgGym } from "react-icons/cg";
import { FaBed } from "react-icons/fa6";
import { MdFastfood } from "react-icons/md";
import { useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { useLazySendChatQuestionQuery } from "../store/api/chatApi";
const HomePage = () => {
const [sendChatQuestion, { isLoading, isFetching }] = useLazySendChatQuestionQuery();
type Category = 'sport' | 'feed' | 'sleep';
const [category, setCategory] = useState<Category | null>(null);
const [message, setMessage] = useState<string>('');
const [chatHistory, setChatHistory] = useState<{ sender: string; text: string, rating?: number, explanation?: string }[]>([]);
async function onSubmit() {
if (!message.trim()) return;
setChatHistory([...chatHistory, { sender: 'User', text: message }]);
setMessage('');
const question = { query: message };
try {
const res = await sendChatQuestion(question).unwrap();
console.log("Response from server:", res);
// Извлекаем лучший ответ и очищаем его от ненужных символов
let bestAnswer = res.best_answer.replace(/[*#]/g, "");
const model = res.model;
// Форматируем ответ для удобства чтения
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]);
} catch (error) {
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
key={index}
className={`flex ${msg.sender === 'User' ? 'justify-end' : 'justify-start'} mb-2`}
>
<div
className={`p-2 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>}
</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>
</div>
)}
</div>
<div id="buttons">
<div className="flex gap-6">
<button onClick={() => setCategory('sport')} className={`flex items-center shadow-lg justify-center gap-2 ${category === 'sport' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>
Training <CgGym size={30} />
</button>
<button onClick={() => setCategory('sleep')} className={`flex items-center shadow-lg justify-center gap-2 ${category === 'sleep' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>
Sleep <FaBed size={25} />
</button>
<button onClick={() => setCategory('feed')} className={`flex items-center shadow-lg justify-center gap-2 ${category === 'feed' ? 'bg-bright-blue' : 'bg-gray-300'} text-white rounded-md font-medium hover:opacity-90 duration-200 h-12 w-40`}>
Feed <MdFastfood size={20} />
</button>
</div>
</div>
<div id="input" className="w-2/3 rounded-xl drop-shadow-2xl 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</button>
</div>
</div>
</div>
);
};
export default HomePage;

View File

@ -0,0 +1,121 @@
import React from 'react';
import { CgLogIn } from "react-icons/cg";
import { Link } from 'react-router-dom';
import BackImage from '../assets/smallheadicon.png'
import MultiStepForm from '../Components/MultistepForm';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
const BouncingArrow = () => {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
mt: 2,
animation: 'bounce 1s infinite', // Додаємо анімацію
'@keyframes bounce': { // Описуємо ключові кадри для анімації
'0%, 100%': {
transform: 'translateY(0)',
},
'50%': {
transform: 'translateY(-10px)',
},
}
}}
>
<ArrowDownwardIcon fontSize="large" />
</Box>
);
};
const Navbar: React.FC = () => {
return (
<nav className="w-full bg-white shadow-md py-4 px-2 sm:px-8 flex justify-between items-center fixed top-0 left-0 right-0 z-50">
<div className="text-2xl font-semibold text-dark-blue flex items-center">
Health AI
<img src={BackImage} width={25} alt="" />
</div>
<ul className="flex space-x-6 text-gray-600">
<li><Link to="/dashboard" className="hover:text-bright-blue transition duration-300">Home</Link></li>
<li><Link to="/solutions" className="hover:text-bright-blue transition duration-300">Solutions</Link></li>
<li><Link to="/about" className="hover:text-bright-blue transition duration-300">About</Link></li>
<li><Link to="/contact" className="hover:text-bright-blue transition duration-300">Contact</Link></li>
</ul>
<div className='flex gap-2 items-center'>
Sign in <CgLogIn size={25} />
</div>
</nav>
);
};
const Home: React.FC = () => {
useGSAP(() => {
gsap.from('#mainheading', { 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('#button', { opacity: 0, y: 5, ease: 'power2.inOut', delay: 0.5, duration: 0.5 })
gsap.from('#features', { opacity: 0, y: 5, ease: 'power2.inOut', delay: 0.7, duration: 0.5 })
gsap.from('#arrow', { opacity: 0, ease: 'power2.inOut', delay: 2, duration: 0.2 })
}, [])
return (<>
<div className="h-screen flex flex-col items-center justify-center bg-gradient-to-b text-gray-800 p-4">
{/* Навігація */}
<Navbar />
<div className="pt-20 flex flex-col items-center">
<h1 id='mainheading' className="text-4xl flex items-center sm:text-5xl md:text-6xl font-semibold mb-4 text-center text-dark-blue">
AI Assistant for Your Health
</h1>
<p id='secondheading' className="text-base sm:text-lg md:text-xl text-center max-w-2xl mb-8 text-gray-700">
The product for health improvement, trainings, and other. Care for yourself with modern technologies be a modern human.
</p>
<Link id='button' to='/dashboard'>
<button className="bg-bright-blue text-white font-medium py-2 px-5 rounded hover:bg-deep-blue transition duration-300 mb-10 shadow-md">
Get started
</button>
</Link>
<div className="flex flex-col sm:flex-row gap-6 mb-10" id="features">
<div className="bg-white p-6 rounded-lg max-w-xs text-center shadow-md">
<h3 className="text-xl font-medium mb-3 text-dark-blue">Personalized Training</h3>
<p className="text-gray-600">Get customized training plans designed just for you and track your progress effectively.</p>
</div>
<div className="bg-white p-6 rounded-lg max-w-xs text-center shadow-md">
<h3 className="text-xl font-medium mb-3 text-dark-blue">Health Monitoring</h3>
<p className="text-gray-600">Stay informed about your health with real-time monitoring and AI-driven insights.</p>
</div>
<div className="bg-white p-6 rounded-lg max-w-xs text-center shadow-md">
<h3 className="text-xl font-medium mb-3 text-dark-blue">Advanced AI Support</h3>
<p className="text-gray-600">Utilize AI support to ensure you're following the best routines for a healthier lifestyle.</p>
</div>
</div>
<div id='arrow' className='flex flex-col items-center mt-10 z-0'>
<p className='text-gray-600'>Try it out</p>
<BouncingArrow />
</div>
</div>
</div>
<div className='w-full h-screen flex flex-col justify-center items-center' >
<MultiStepForm />
</div>
<footer className=" mt-auto text-center text-gray-500 p-4">
<p>&copy; {new Date().getFullYear()} Health AI. All rights reserved.</p>
</footer>
</>
);
};
export default Home;

View File

@ -0,0 +1,33 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
// type chatQuestion = {
// }
const chatApi = createApi({
reducerPath: 'chat',
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000' }),
endpoints: (builder) => ({
sendTestVersion: builder.query<string, any>({
query: (body) => ({
url: '/create-answer',
method: 'POST',
body: body
}),
transformResponse: ({ response }) => response
}),
sendChatQuestion: builder.query<any, any>({
query: (body) => ({
url: '/api/chat',
method: 'POST',
body: body
})
})
})
})
export default chatApi
export const { useLazySendTestVersionQuery,useLazySendChatQuestionQuery } = chatApi

View File

@ -0,0 +1,19 @@
import { configureStore } from "@reduxjs/toolkit";
import chatApi from "./api/chatApi";
import { setupListeners } from '@reduxjs/toolkit/query';
const store = configureStore({
reducer: {
[chatApi.reducerPath]: chatApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(chatApi.middleware),
})
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {
colors: {
'light-blue': '#dbeafe',
'soft-blue': '#bfdbfe',
'light-cyan': '#e0f7fa',
'dark-blue': '#1e3a8a',
'bright-blue': '#2563eb',
'deep-blue': '#1d4ed8',
},
},
},
plugins: [],
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -1,27 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
},
"include": [
"src",
"declarations.d.ts"
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})