diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..6a630f643d787a4619d99811de624d7c27a193f7 Binary files /dev/null and b/.coverage differ diff --git a/.gitignore b/.gitignore index 8dc50e76c10a13404d872f7549eb4179668ea646..32f2227e5e02c08663028253f3692d06d2d53548 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ __pycache__/ venv/ .env .venv*/ -conversations.json \ No newline at end of file +conversations.json +/.coverage diff --git a/README.md b/README.md index 8ecb890ce94cccccbbd95fc3c3aaafa09e11e7fa..732e3ae2367c044d824f37fc66a1bc036a460310 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,42 @@ To update the code in the space, click on `+ Contribute` button in the upper-rig You could add the Git repo as a remote to your local Git repository, but it would add unnecessary complexity. HuggingFace is stricter than Gitlab concerning best Git practices. You would have to configure `git-xet` and delete the `.env` file and the binary file in `rag_data` from the Git history to be able to push your changes. The `.env` file has not been added to the space. The environment variables are stored in the settings page. +## Unit testing +To run the tests, simply execute `pytest` at the project root. Make sure your virtual environment is activated and that dev dependencies are installed: +```bash +pip install -r requirements-dev.txt +``` + +Some tests are marked as `resource_intensive`. They take longer to run and might consume significant memory or CPU. To run them: +```bash +pytest -m resource_intensive +``` + +To run every test: +```bash +pytest -m "" +``` + +### Code coverage +`coverage` is a Python library that measures code coverage. To use it, run: +```bash +coverage run -m pytest +``` + +To see a short summary of the results, run: +```bash +coverage report +``` + +For a more detailed presentation, run: +```bash +coverage html +``` + +To run `pytest` with additionnal arguments, you can run, for example: +```bash +coverage run -m pytest -m resource_intensive +``` ## Load testing [k6](https://k6.io/open-source/) is an open-source tool for performing load testing. Test cases are defined in JavaScript files and can be run using the command `k6 run .js`. diff --git a/champ/rag.py b/champ/rag.py index 513f35302293ab910c40a2c5733fac20c570edde..f6f93d42bc734ea2565ef378a64caef2e58cfd54 100644 --- a/champ/rag.py +++ b/champ/rag.py @@ -46,7 +46,7 @@ def load_vector_store( def create_session_vector_store( base_vector_store: LCFAISS, embedding_model: HuggingFaceEmbeddings, - documents: List[Document], + document_contents: List[str], ): # Only deep copy the FAISS index, not the embedding model index_copy = faiss.clone_index(base_vector_store.index) @@ -58,6 +58,7 @@ def create_session_vector_store( index_to_docstore_id=copy.deepcopy(base_vector_store.index_to_docstore_id), ) + documents = [Document(document_text) for document_text in document_contents] text_splitter = RecursiveCharacterTextSplitter() document_chunks = text_splitter.split_documents(documents) diff --git a/champ/service.py b/champ/service.py index 97b564d8475d55b82905b07392582341eafd8fa9..47330468a99383eb46639233eec6807ffcd8456c 100644 --- a/champ/service.py +++ b/champ/service.py @@ -1,5 +1,6 @@ # app/champ/service.py +import logging from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple from langchain_community.vectorstores import FAISS as LCFAISS @@ -8,6 +9,8 @@ from langchain_core.messages import HumanMessage from .agent import build_champ_agent from .triage import safety_triage +logger = logging.getLogger("uvicorn") + class ChampService: vector_store: Optional[LCFAISS] = None @@ -33,6 +36,7 @@ class ChampService: Tuple[str, Dict[str, Any], List[str]]: The replay, the triage_triggered object and the retrieved passages """ if self.agent is None: + logger.error("CHAMP invoked before initialization") raise RuntimeError("CHAMP is not initialized yet.") # --- Safety triage micro-layer (before LLM) --- last_user_text = None @@ -43,11 +47,15 @@ class ChampService: if last_user_text: triggered, override_reply, reason = safety_triage(last_user_text) - if triggered: - return override_reply, { - "triage_triggered": True, - "triage_reason": reason, - } + if triggered and override_reply is not None: + return ( + override_reply, + { + "triage_triggered": True, + "triage_reason": reason, + }, + [], # No retrieved documents + ) result = self.agent.invoke({"messages": list(lc_messages)}) diff --git a/classes/base_models.py b/classes/base_models.py index a3dfefbb36ef53bd9e1f67e84096e934d77a051e..59a70cdb0912864f24bcd6e4bae49b4dfd62a7ff 100644 --- a/classes/base_models.py +++ b/classes/base_models.py @@ -5,9 +5,10 @@ from constants import ( MAX_FILE_NAME_LENGTH, MAX_ID_LENGTH, MAX_MESSAGE_LENGTH, + MAX_RESPONSE_LENGTH, ) from pydantic import BaseModel, Field, field_validator -from typing import List, Literal, Set +from typing import Literal, Set class IdentifierBase(BaseModel): @@ -46,6 +47,23 @@ class ChatRequest(IdentifierBase, ProfileBase): return nh3.clean(human_message) +class FeedbackRequest(IdentifierBase, ProfileBase): + message_index: int = Field(ge=0, le=10_000) + rating: Literal["like", "dislike", "mixed"] + comment: str = Field(min_length=0, max_length=MAX_COMMENT_LENGTH) + reply_content: str = Field(min_length=1, max_length=MAX_RESPONSE_LENGTH) + + @field_validator("comment") + def sanitize_comment(cls, comment: str): + """Remove HTML tags to prevent XSS""" + return nh3.clean(comment) + + @field_validator("reply_content") + def sanitize_reply_content(cls, reply_content: str): + """Remove HTML tags to prevent XSS""" + return nh3.clean(reply_content) + + class CommentRequest(IdentifierBase, ProfileBase): comment: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH) @@ -58,7 +76,7 @@ class CommentRequest(IdentifierBase, ProfileBase): class DeleteFileRequest(IdentifierBase, ProfileBase): file_name: str = Field( # Pattern: Allows letters, numbers, -, _, spaces, and dots (but no double dots or starting dots or spaces) - pattern="^[a-zA-Z0-9_()-][a-zA-Z0-9\s_()-]*(\.[a-zA-Z0-9\s_-]+)*$", + pattern=r"^[a-zA-Z0-9_()-][a-zA-Z0-9\s_()-]*(\.[a-zA-Z0-9\s_-]+)*$", min_length=1, max_length=MAX_FILE_NAME_LENGTH, ) diff --git a/classes/ocr_reader.py b/classes/ocr_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..2a4b58359dd7f3b3c9263e416b39047a7e281d36 --- /dev/null +++ b/classes/ocr_reader.py @@ -0,0 +1,27 @@ +import logging +from typing import Optional +import cv2 +import easyocr +import torch + +logger = logging.getLogger("uvicorn") + + +class OCRReader: + _instance: Optional["OCRReader"] = None + ocr_reader: easyocr.Reader + + def __new__(cls): + if cls._instance is None: + logger.info("Loading the OCR model into memory...") + cls._instance = super(OCRReader, cls).__new__(cls) + cls._instance.ocr_reader = easyocr.Reader( + ["en", "fr"], gpu=torch.cuda.is_available() + ) + return cls._instance + + def read_text(self, img: cv2.typing.MatLike | None): + res = self.ocr_reader.readtext(img, detail=0) + if not isinstance(res, list): + return None + return " ".join([str(item) for item in res]) diff --git a/classes/pii_filter.py b/classes/pii_filter.py index 2cdc8dcf48f54f7ecb4d07a55fdbd673327a0663..f85c7fe30ff2f18f874d4ce93a63367146f3caa7 100644 --- a/classes/pii_filter.py +++ b/classes/pii_filter.py @@ -1,3 +1,4 @@ +import logging from typing import List, Optional from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer from presidio_analyzer.nlp_engine import NlpEngineProvider @@ -5,23 +6,38 @@ from presidio_anonymizer import AnonymizerEngine from presidio_anonymizer.entities import OperatorConfig # from lingua import Language, LanguageDetector +logger = logging.getLogger("uvicorn") def create_ssn_pattern_recognizer(): # matches 111-111-111, 111 111 111, and 111111111 ssn_pattern = Pattern( - name="ssn_pattern", regex=r"\b\d{3}[- ]?\d{3}[- ]?\d{3}\b", score=0.8 + name="ssn_pattern", regex=r"\b\d{3}[- ]?\d{3}[- ]?\d{3}\b", score=0.9 + ) + fuzzy_sin_pattern = Pattern( + name="fuzzy_sin_pattern", + regex=r"\b[\dlIOS]{3}[- ]?[\dlIOS]{3}[- ]?[\dlIOS]{3}\b", + score=0.8, + ) + return PatternRecognizer( + supported_entity="SSN", patterns=[ssn_pattern, fuzzy_sin_pattern] ) - return PatternRecognizer(supported_entity="SSN", patterns=[ssn_pattern]) def create_zip_code_pattern_recognizer(): zip_code_pattern = Pattern( name="zip_code_pattern", regex=r"\b[A-Z]\d[A-Z]\s?\d[A-Z]\d\b", # Matches A1A 1A1 and A1A1A1 + score=0.9, + ) + fuzzy_zip_code_pattern = Pattern( + name="fuzzy_zip_code_pattern", + regex=r"\b[A-Z][\dlIOS][A-Z]\s?[\dlIOS][A-Z][\dlIOS]\b", score=0.8, ) - return PatternRecognizer(supported_entity="ZIP_CODE", patterns=[zip_code_pattern]) + return PatternRecognizer( + supported_entity="ZIP_CODE", patterns=[zip_code_pattern, fuzzy_zip_code_pattern] + ) def create_street_pattern_recognizer(): @@ -41,6 +57,34 @@ def create_street_pattern_recognizer(): ) +# The default phone pattern recognizer does not catch some edge cases. +def create_phone_pattern_recognizer(): + """ + Create a custom phone pattern recognizer to catch additional phone formats. + Matches various North American phone formats: + - 123-456-7890 (with dashes) + - 123 456 7890 (with spaces) + - (123) 456-7890 (with parentheses) + - (123) 456 7890 (with parentheses and spaces) + - +1-123-456-7890 (with country code and dashes) + - +1 (123) 456-7890 (with country code, parentheses, and dashes) + - +1 123 456 7890 (with country code and spaces) + """ + phone_pattern = Pattern( + name="phone_pattern", + regex=r"(?:\+\d{1,3}[-\s]?)?\(?(?:\d{3})\)?[-\s]?\d{3}[-\s]?\d{4}", + score=0.9, + ) + fuzzy_phone_pattern = Pattern( + name="fuzzy_phone_pattern", + regex=r"(?:\+[\dlIOS]{1,3}[-\s]?)?\(?(?:[\dlIOS]{3})\)?[-\s]?[\dlIOS]{3}[-\s]?[\dlIOS]{4}", + score=0.8, + ) + return PatternRecognizer( + supported_entity="PHONE_NUMBER", patterns=[phone_pattern, fuzzy_phone_pattern] + ) + + class PIIFilter: _instance: Optional["PIIFilter"] = None analyzer: AnalyzerEngine @@ -50,7 +94,7 @@ class PIIFilter: def __new__(cls): if cls._instance is None: - print("Initializing Presidio Engines (this should happen only once)...") + logger.info("Loading the prompt sanitizer into memory...") cls._instance = super(PIIFilter, cls).__new__(cls) # Define which models to use for which language @@ -69,10 +113,12 @@ class PIIFilter: ssn_pattern_recognizer = create_ssn_pattern_recognizer() zip_code_pattern_recognizer = create_zip_code_pattern_recognizer() street_pattern_recognizer = create_street_pattern_recognizer() + phone_pattern_recognizer = create_phone_pattern_recognizer() cls._instance.analyzer.registry.add_recognizer(ssn_pattern_recognizer) cls._instance.analyzer.registry.add_recognizer(zip_code_pattern_recognizer) cls._instance.analyzer.registry.add_recognizer(street_pattern_recognizer) + cls._instance.analyzer.registry.add_recognizer(phone_pattern_recognizer) cls._instance.anonymizer = AnonymizerEngine() diff --git a/classes/session_conversation_store.py b/classes/session_conversation_store.py index 69bfce0ccf469bfd6067fd5d719da6775cc42f67..ac5cf5a45f1cc63765a586515bd28c77d82b4a24 100644 --- a/classes/session_conversation_store.py +++ b/classes/session_conversation_store.py @@ -15,11 +15,6 @@ class SessionConversationStore: # session_id -> conversation_id -> [ChatMessage] self.session_conversation_map: Dict[str, Dict[str, List[ChatMessage]]] = dict() - def get_conversation( - self, session_id: str, conversation_id: str - ) -> List[ChatMessage]: - return self.session_conversation_map[session_id][conversation_id] - def add_human_message( self, session_id: str, @@ -27,6 +22,7 @@ class SessionConversationStore: human_message: str, ): self.__add_message(session_id, conversation_id, human_message, role="user") + return self.session_conversation_map[session_id][conversation_id] def add_assistant_reply( self, @@ -35,6 +31,7 @@ class SessionConversationStore: reply: str, ): self.__add_message(session_id, conversation_id, reply, role="assistant") + return self.session_conversation_map[session_id][conversation_id] def delete_session_conversations(self, session_id: str): if session_id in self.session_conversation_map: diff --git a/classes/session_document_store.py b/classes/session_document_store.py index 18d6689a4b21f798e516d3201b635cda222d467f..a99a4716535091bdaf8b5d4ab9155f051b257a44 100644 --- a/classes/session_document_store.py +++ b/classes/session_document_store.py @@ -1,6 +1,6 @@ -from typing import Dict, List, Tuple -from langchain_core.documents import Document +import sys +from typing import Dict, List, Tuple from constants import MAX_FILE_SIZES_PER_SESSION @@ -10,21 +10,24 @@ class SessionDocumentStore: # session_id -> {file_name -> (file_text, size_in_bytes)} self.session_document_map: Dict[str, Dict[str, Tuple[str, int]]] = dict() - def create_document( - self, session_id: str, file_text: str, file_name: str, file_size: int - ): + def create_document(self, session_id: str, file_text: str, file_name: str): + text_size = sys.getsizeof(file_text) if session_id not in self.session_document_map: + if text_size > MAX_FILE_SIZES_PER_SESSION: + return False self.session_document_map[session_id] = dict() + self.session_document_map[session_id][file_name] = (file_text, text_size) + return True current_total_file_size = sum( file_text_size[1] for file_text_size in self.session_document_map[session_id].values() ) - if current_total_file_size + file_size > MAX_FILE_SIZES_PER_SESSION: + if current_total_file_size + text_size > MAX_FILE_SIZES_PER_SESSION: return False - self.session_document_map[session_id][file_name] = (file_text, file_size) + self.session_document_map[session_id][file_name] = (file_text, text_size) return True def get_document_contents(self, session_id: str) -> List[str] | None: @@ -40,13 +43,6 @@ class SessionDocumentStore: return document_contents - def get_documents(self, session_id: str) -> List[Document] | None: - document_contents = self.get_document_contents(session_id) - if document_contents is None: - return None - - return [Document(document_text) for document_text in document_contents] - def delete_document(self, session_id: str, file_name: str) -> bool: """Deletes a document with the given name. If the session no longer has documents after the deletion, the session is also deleted and the function returns True.""" @@ -63,7 +59,4 @@ class SessionDocumentStore: return False def delete_session_documents(self, session_id: str) -> bool: - if session_id in self.session_document_map: - del self.session_document_map[session_id] - return True - return False + return self.session_document_map.pop(session_id, None) is not None diff --git a/classes/session_tracker.py b/classes/session_tracker.py index 054e0fa269005b96565a5adbe8476c94073fd6d6..a276f02f84b4be06bfd2e2812a9c0546cca501d8 100644 --- a/classes/session_tracker.py +++ b/classes/session_tracker.py @@ -29,7 +29,6 @@ class SessionTracker: return sessions_to_delete def delete_oldest_session(self) -> str | None: - print(f"active sessions: {self.session_timestamp_map.keys()}") if len(self.session_timestamp_map) == 0: return None oldest_session_id = min(self.session_timestamp_map.items(), key=lambda x: x[1])[ diff --git a/conftest.py b/conftest.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7bdf0e11c8cc53878b58c551b2784a91954d86f6 100644 --- a/conftest.py +++ b/conftest.py @@ -0,0 +1,10 @@ +# conftest.py +import os +import pytest + + +@pytest.fixture(autouse=True) +def aws_credentials(): + os.environ["AWS_ACCESS_KEY"] = "testing" + os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" + os.environ["AWS_REGION"] = "ca-central-1" diff --git a/constants.py b/constants.py index decff3dd7190b92473d5aae0397658642afe01da..51919b184e855a045b95a95c8b7fa886f0e12b07 100644 --- a/constants.py +++ b/constants.py @@ -20,21 +20,24 @@ MAX_RAM_USAGE_PERCENT = 90 # Max history messages to keep for context MAX_HISTORY = 20 -MAX_MESSAGE_LENGTH = 1000 -MAX_COMMENT_LENGTH = 500 +MAX_MESSAGE_LENGTH = 2500 +MAX_COMMENT_LENGTH = 2500 +MAX_RESPONSE_LENGTH = 5000 MAX_ID_LENGTH = 50 MAX_FILE_NAME_LENGTH = 50 MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB FILE_CHUNK_SIZE = 1024 * 1024 # 1 MB MAX_FILE_SIZES_PER_SESSION = 30 * 1024 * 1024 # 30 MB +TEXT_EXTRACTION_TIMEOUT = 10 # 10 seconds SUPPORTED_FILE_EXTENSIONS = {".txt", ".pdf", ".docx", ".jpg", ".jpeg", ".png"} SUPPORTED_FILE_TYPES = { "text/plain", # .txt "application/pdf", # .pdf "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx - "application/zip", # docx files are actually zip files under the hood and are detected as such by magic + # TODO: magic can detect docx files as zip files, but not always. Under which conditions? + # "application/zip", "image/jpeg", # .jpeg and .jpg "image/png", # .png } diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..e645b53631ca1fe0f0c814fe5d9d693ad051fa5d --- /dev/null +++ b/exceptions.py @@ -0,0 +1,61 @@ +from enum import Enum + +from constants import ( + STATUS_CODE_BAD_REQUEST, + STATUS_CODE_CONTENT_TOO_LARGE, + STATUS_CODE_INTERNAL_SERVER_ERROR, + STATUS_CODE_LENGTH_REQUIRED, + STATUS_CODE_UNSUPPORTED_MEDIA_TYPE, +) + + +class FileValidationError(Enum): + MISSING_SIZE = "MISSING_SIZE" + FILE_TOO_LARGE = "FILE_TOO_LARGE" + MISSING_FILE_NAME = "MISSING_FILE_NAME" + FILE_NAME_TOO_LARGE = "FILE_NAME_TOO_LARGE" + INVALID_FILE_NAME = "INVALID_FILE_NAME" + INVALID_MIME_TYPE = "INVALID_MIME_TYPE" + UNSUPPORTED_EXTENSION = "UNSUPPORTED_EXTENSION" + EMPTY_FILE = "EMPTY_FILE" + + +class FileValidationException(Exception): + def __init__(self, error: FileValidationError): + self.error = error + + +FILE_VALIDATION_ERROR_STATUS_CODES = { + FileValidationError.MISSING_SIZE: STATUS_CODE_LENGTH_REQUIRED, + FileValidationError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE, + FileValidationError.MISSING_FILE_NAME: STATUS_CODE_BAD_REQUEST, + FileValidationError.FILE_NAME_TOO_LARGE: STATUS_CODE_BAD_REQUEST, + FileValidationError.INVALID_FILE_NAME: STATUS_CODE_BAD_REQUEST, + FileValidationError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE, + FileValidationError.UNSUPPORTED_EXTENSION: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE, + FileValidationError.EMPTY_FILE: STATUS_CODE_BAD_REQUEST, +} + + +class FileExtractionError(Enum): + INVALID_MIME_TYPE = "INVALID_MIME_TYPE" + NO_TEXT = "NO_TEXT" + TEXT_EXTRACTION_TIMEOUT = "TEXT_EXTRACTION_TIMEOUT" + UNSAFE_ZIP = "UNSAFE_ZIP" + FILE_TOO_LARGE = "FILE_TOO_LARGE" + MALFORMED_FILE = "MALFORMED_FILE" + + +class FileExtractionException(Exception): + def __init__(self, error: FileExtractionError): + self.error = error + + +FILE_EXTRACTION_ERROR_STATUS_CODES = { + FileExtractionError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE, + FileExtractionError.NO_TEXT: STATUS_CODE_BAD_REQUEST, + FileExtractionError.TEXT_EXTRACTION_TIMEOUT: STATUS_CODE_INTERNAL_SERVER_ERROR, + FileExtractionError.UNSAFE_ZIP: STATUS_CODE_INTERNAL_SERVER_ERROR, + FileExtractionError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE, + FileExtractionError.MALFORMED_FILE: STATUS_CODE_BAD_REQUEST, +} diff --git a/helpers/file_helper.py b/helpers/file_helper.py index 99b2dfc49b507e7d86f43fa69da4881c504763a1..cedd7028b1d418541514b3c4ff0df173445a6de5 100644 --- a/helpers/file_helper.py +++ b/helpers/file_helper.py @@ -1,34 +1,54 @@ +import asyncio +from dataclasses import dataclass +import os import zipfile import cv2 -import easyocr +from fastapi import UploadFile import fitz # PyMuPDF import io +import magic import numpy as np import re from docx import Document +from lxml.etree import XMLSyntaxError +import PIL from PIL import Image -from constants import FILE_CHUNK_SIZE, MAX_FILE_SIZE +from classes.ocr_reader import OCRReader +from constants import ( + FILE_CHUNK_SIZE, + MAX_FILE_NAME_LENGTH, + MAX_FILE_SIZE, + SUPPORTED_FILE_EXTENSIONS, + SUPPORTED_FILE_TYPES, + TEXT_EXTRACTION_TIMEOUT, +) +from exceptions import FileExtractionError, FileExtractionException, FileValidationError +from exceptions import FileValidationException def clean_text(raw_text: str): - # TODO: Try to keep paragraphs (\n\n) - # 1. Strip whitespace from the beginning and end of every single line - # This handles the "spaces followed by newlines" issue + # 1. Strip whitespace from the beginning and end of every line + # We keep the resulting empty strings to preserve the "gap" locations lines = [line.strip() for line in raw_text.splitlines()] - # 2. Remove completely empty lines from the list - non_empty_lines = [line for line in lines if line] + # 2. Join them back together with a single newline + # This turns empty lines into sequences of \n + text = "\n".join(lines) - # 3. Join them back together with a single newline - text = "\n".join(non_empty_lines) + # 3. Merge 3+ newlines into 2, and 2 newlines into 2 + # This specifically looks for 2 or more newlines and replaces them with \n\n + # Hello\n\n\nWorld (3) -> Hello\n\nWorld + # Hello\n\nWorld (2) -> Hello\n\nWorld + # Hello\nWorld (1) -> Not matched, stays Hello\nWorld + text = re.sub(r"\n{2,}", "\n\n", text) # 4. Final pass: replace any remaining double-spaces with single ones text = re.sub(r" {2,}", " ", text) - return text + return text.strip() async def extract_text_from_pdf(binary_content: bytes): @@ -43,8 +63,7 @@ async def extract_text_from_pdf(binary_content: bytes): full_text += page.get_text() if len(full_text.strip()) == 0: - # TODO: OCR if reading binary files doesn't work - raise ValueError() + raise FileExtractionException(FileExtractionError.NO_TEXT) doc.close() return clean_text(full_text) @@ -67,18 +86,26 @@ def safe_unzip_check(file_bytes: bytes) -> bool: break total += len(chunk) if total > MAX_FILE_SIZE: - return False # bail out immediately + raise FileExtractionException( + FileExtractionError.FILE_TOO_LARGE + ) return True except zipfile.BadZipFile: - return False + raise FileExtractionException(FileExtractionError.UNSAFE_ZIP) -async def extract_text_from_docx(binary_content: bytes): +def extract_text_from_docx(binary_content: bytes): + if not safe_unzip_check(binary_content): + return None + # Load the binary data into a stream stream = io.BytesIO(binary_content) # Load the docx document - doc = Document(stream) + try: + doc = Document(stream) + except XMLSyntaxError: + raise FileExtractionException(FileExtractionError.UNSAFE_ZIP) # Extract text from all paragraphs paragraphs = [] @@ -91,18 +118,14 @@ async def extract_text_from_docx(binary_content: bytes): def sanitize_image(binary_content: bytes): - img = Image.open(io.BytesIO(binary_content)).convert("RGB") - arr = np.array(img, dtype=np.int16) - noise = np.random.randint(-1, 2, arr.shape) # -1, 0, or 1 - arr = np.clip(arr + noise, 0, 255).astype(np.uint8) - output = io.BytesIO() - Image.fromarray(arr).save(output, format="PNG") - return output.getvalue() - - -def extract_text_from_img( - binary_content: bytes, ocr_reader: easyocr.Reader -) -> str | None: + with Image.open(io.BytesIO(binary_content)) as img: + img = img.convert("RGB") + output = io.BytesIO() + img.save(output, format="PNG") + return output.getvalue() + + +def extract_text_from_img(binary_content: bytes) -> str | None: # 1. Convert bytes to a numpy array nparr = np.frombuffer(binary_content, np.uint8) @@ -110,12 +133,7 @@ def extract_text_from_img( img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # 3. Pass the image variable directly - res = ocr_reader.readtext(img, detail=0) - - if isinstance(res, list): - return " ".join([str(item) for item in res]) - - return None + return OCRReader().read_text(img) def replace_spaces_in_filename(filename: str) -> str: @@ -142,7 +160,7 @@ def is_valid_filename(filename: str) -> bool: if not filename or len(filename) > 255: return False - pattern = r"^[a-zA-Z0-9_()\-]+(\.[a-zA-Z0-9_()\-]+)*$" + pattern = r"^[a-zA-Z0-9_()\-]+(\.[a-zA-Z0-9_()\-]+)?$" if not re.match(pattern, filename): return False @@ -150,3 +168,126 @@ def is_valid_filename(filename: str) -> bool: return False return True + + +@dataclass +class ValidatedFile: + content: bytes + filename: str + mime_type: str + + +async def validate_file(file: UploadFile) -> ValidatedFile: + # Preliminary checks + file_size = file.size + if file_size is None: + raise FileValidationException(FileValidationError.MISSING_SIZE) + + if file_size > MAX_FILE_SIZE: + raise FileValidationException(FileValidationError.FILE_TOO_LARGE) + + # Check filename and extension + file_name = file.filename + if file_name is None: + raise FileValidationException(FileValidationError.MISSING_FILE_NAME) + + if len(file_name) > MAX_FILE_NAME_LENGTH: + raise FileValidationException(FileValidationError.FILE_NAME_TOO_LARGE) + + file_name = replace_spaces_in_filename(file_name) + + if not is_valid_filename(file_name): + raise FileValidationException(FileValidationError.INVALID_FILE_NAME) + + _, extension = os.path.splitext(file_name) + if extension not in SUPPORTED_FILE_EXTENSIONS: + raise FileValidationException(FileValidationError.UNSUPPORTED_EXTENSION) + + # Check mime type from headers + file_mime = file.headers.get("content-type") + if file_mime is None or file_mime not in SUPPORTED_FILE_TYPES: + raise FileValidationException(FileValidationError.INVALID_MIME_TYPE) + + # Read in chunks to avoid RAM spikes + file_content = b"" + actual_size = 0 + while True: + chunk = await file.read(FILE_CHUNK_SIZE) + if not chunk: + break + actual_size += len(chunk) + if actual_size > MAX_FILE_SIZE: + raise FileValidationException(FileValidationError.FILE_TOO_LARGE) + file_content += chunk + + if actual_size == 0: + raise FileValidationException(FileValidationError.EMPTY_FILE) + + # Verify mime type from actual file content + file_mime = magic.from_buffer(file_content[:2048], mime=True) + if file_mime not in SUPPORTED_FILE_TYPES: + raise FileValidationException(FileValidationError.INVALID_MIME_TYPE) + + return ValidatedFile( + content=file_content, + filename=file_name, + mime_type=file_mime, + ) + + +async def extract_text_from_file(file_content: bytes, file_mime: str) -> str: + file_text = None + try: + if file_mime == "application/pdf": + file_text = await asyncio.wait_for( + extract_text_from_pdf(file_content), timeout=TEXT_EXTRACTION_TIMEOUT + ) + elif file_mime == "text/plain": + file_text = await asyncio.wait_for( + extract_text_from_txt(file_content), timeout=TEXT_EXTRACTION_TIMEOUT + ) + elif ( + file_mime + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ): + loop = asyncio.get_event_loop() + file_text = await asyncio.wait_for( + loop.run_in_executor( + None, + extract_text_from_docx, + file_content, + ), + timeout=TEXT_EXTRACTION_TIMEOUT, + ) + elif file_mime in ["image/jpeg", "image/png"]: + loop = asyncio.get_event_loop() + sanitized_file_content = await asyncio.wait_for( + loop.run_in_executor( + None, + sanitize_image, + file_content, + ), + timeout=TEXT_EXTRACTION_TIMEOUT, + ) + file_text = await asyncio.wait_for( + loop.run_in_executor( + None, + extract_text_from_img, + sanitized_file_content, + ), + timeout=TEXT_EXTRACTION_TIMEOUT, + ) + else: + raise FileExtractionException(FileExtractionError.INVALID_MIME_TYPE) + except asyncio.TimeoutError: + raise FileExtractionException(FileExtractionError.TEXT_EXTRACTION_TIMEOUT) + except Image.DecompressionBombError: + # TODO: Log the decompression bomb DOS attack + raise FileExtractionException(FileExtractionError.FILE_TOO_LARGE) + except (PIL.UnidentifiedImageError, OSError): + raise FileExtractionException(FileExtractionError.MALFORMED_FILE) + + if file_text is None: + raise FileExtractionException(FileExtractionError.NO_TEXT) + + return file_text diff --git a/helpers/lifespan_helper.py b/helpers/lifespan_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..36635f506b897b52f6909a1e9a43f5e290f364f8 --- /dev/null +++ b/helpers/lifespan_helper.py @@ -0,0 +1,52 @@ +import asyncio +import logging + +import psutil + +from classes.ocr_reader import OCRReader +from classes.pii_filter import PIIFilter +from classes.session_conversation_store import SessionConversationStore +from classes.session_document_store import SessionDocumentStore +from classes.session_tracker import SessionTracker +from constants import MAX_RAM_USAGE_PERCENT + + +logger = logging.getLogger("uvicorn") + + +def run_cleanup( + session_tracker: SessionTracker, + session_document_store: SessionDocumentStore, + session_conversation_store: SessionConversationStore, +): + logger.info("Running cleanup") + deleted_session_ids = session_tracker.delete_inactive_sessions() + if len(deleted_session_ids) > 0: + logger.info(f"{len(deleted_session_ids)} inactive sessions will be deleted.") + for session_id in deleted_session_ids: + session_document_store.delete_session_documents(session_id) + session_conversation_store.delete_session_conversations(session_id) + + while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT: + oldest_session_id = session_tracker.delete_oldest_session() + logger.info(f"Deleting {oldest_session_id} session because of high RAM usage") + if oldest_session_id is None: + break + session_document_store.delete_session_documents(oldest_session_id) + session_conversation_store.delete_session_conversations(oldest_session_id) + + +async def cleanup_loop( + session_tracker: SessionTracker, + session_document_store: SessionDocumentStore, + session_conversation_store: SessionConversationStore, +): + """Run the 4-hour cleanup check every 10 minutes.""" + while True: + await asyncio.sleep(600) # Wait 10 minutes + run_cleanup(session_tracker, session_document_store, session_conversation_store) + + +def load_heavy_models(): + OCRReader() + PIIFilter() diff --git a/helpers/llm_helper.py b/helpers/llm_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..debc05120176468d3183d5a77f16f8918432dbe3 --- /dev/null +++ b/helpers/llm_helper.py @@ -0,0 +1,129 @@ +import os + +from champ.rag import ( + create_embedding_model, + create_session_vector_store, + load_vector_store, +) +from champ.service import ChampService +from classes.base_models import ChatMessage +from helpers.message_helper import convert_messages, convert_messages_langchain +from opentelemetry import trace +from google import genai +from openai import AsyncOpenAI + + +from typing import Any, AsyncGenerator, Dict, List, Literal, Tuple + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +if OPENAI_API_KEY is None: + raise RuntimeError( + "OPENAI_API_KEY is not set. " + "Go to Space → Settings → Variables & secrets and add one." + ) +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +if GEMINI_API_KEY is None: + raise RuntimeError( + "GEMINI_API_KEY is not set. " + "Go to Space → Settings → Variables & secrets and add one." + ) + +openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None +gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None + + +embedding_model = create_embedding_model() +base_vector_store = load_vector_store(embedding_model) + + +# The "Google" models are differentiated by their temperature. +MODEL_MAP = { + "champ": "champ-model/placeholder", + "openai": "gpt-5-mini-2025-08-07", + "google-conservative": "gemini-2.5-flash-lite", + "google-creative": "gemini-2.5-flash-lite", +} + + +async def _call_openai( + model_id: str, msgs: list[dict], document_texts: List[str] | None = None +) -> AsyncGenerator[str, None]: + + stream = await openai_client.responses.create( + model=model_id, input=msgs, stream=True + ) + + async for chunk in stream: + if chunk.type == "response.output_text.delta": + yield chunk.delta + + +def _call_gemini(model_id: str, msgs: list[dict], temperature: float) -> str: + transcript = [] + for m in msgs: + role = m["role"] + content = m["content"] + transcript.append(f"{role.upper()}: {content}") + contents = "\n".join(transcript) + + resp = gemini_client.models.generate_content( + model=model_id, + contents=contents, + config={"temperature": temperature}, + ) + return (resp.text or "").strip() + + +def _call_champ( + lang: Literal["en", "fr"], + conversation: List[ChatMessage], + document_contents: List[str] | None, +): + tracer = trace.get_tracer(__name__) + + if document_contents is None: + vector_store = base_vector_store + else: + vector_store = create_session_vector_store( + base_vector_store, embedding_model, document_contents + ) + + with tracer.start_as_current_span("ChampService"): + champ = ChampService(vector_store=vector_store, lang=lang) + + with tracer.start_as_current_span("convert_messages_langchain"): + msgs = convert_messages_langchain(conversation) + + with tracer.start_as_current_span("invoke"): + reply, triage_meta, context = champ.invoke(msgs) + + return reply, triage_meta, context + + +def call_llm( + model_type: str, + lang: Literal["en", "fr"], + conversation: List[ChatMessage], + document_contents: List[str] | None, +) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]: + + if model_type not in MODEL_MAP: + raise ValueError(f"Unknown model_type: {model_type}") + + if model_type == "champ": + return _call_champ(lang, conversation, document_contents) + + model_id = MODEL_MAP[model_type] + msgs = convert_messages(conversation, lang=lang, docs_content=document_contents) + + if model_type == "openai": + return _call_openai(model_id, msgs) + + if model_type == "google-conservative": + return _call_gemini(model_id, msgs, temperature=0.2), {}, [] + + if model_type == "google-creative": + return _call_gemini(model_id, msgs, temperature=1.0), {}, [] + + # If you later add HF models via hf_client, handle here. + raise ValueError(f"Unhandled model_type: {model_type}") diff --git a/helpers/message_helper.py b/helpers/message_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..442283c320b61011d042ace6212ded7440759ea5 --- /dev/null +++ b/helpers/message_helper.py @@ -0,0 +1,54 @@ +from champ.prompts import ( + DEFAULT_SYSTEM_PROMPT_V3, + DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V3, +) +from classes.base_models import ChatMessage +from constants import MAX_HISTORY + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from typing import List, Literal + + +def convert_messages( + messages: List[ChatMessage], + lang: Literal["en", "fr"], + docs_content: List[str] | None = None, +): + """ + Convert our internal message format into OpenAI-style messages. + """ + # Ideally, the document contents should be aggregated in a vector store + # and sent to the API instead of being added manually to the system + # prompt. However, this would require managing uploaded files which + # is out of scope for the demo. + # + # Read more here: https://developers.openai.com/api/docs/guides/tools-file-search + language = "English" if lang == "en" else "French" + + system_prompt = ( + DEFAULT_SYSTEM_PROMPT_V3.format(language=language) + if docs_content is None + else DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V3.format( + context=docs_content, language=language + ) + ) + + out = [{"role": "system", "content": system_prompt}] + for m in messages: + if m.role == "system": + continue + out.append({"role": m.role, "content": m.content}) + return out + + +def convert_messages_langchain(messages: List[ChatMessage]): + list_chatmessages = [] + + for m in messages[-MAX_HISTORY:]: + if m.role == "user": + list_chatmessages.append(HumanMessage(content=m.content)) + elif m.role == "assistant": + list_chatmessages.append(AIMessage(content=m.content)) + elif m.role == "system": + list_chatmessages.append(SystemMessage(content=m.content)) + return list_chatmessages diff --git a/main.py b/main.py index b13ced3538c0d40ab6afbaa5b23c460efefb96f9..ec487c34c6cc6edc764f5c2f92cbdc28134df77f 100644 --- a/main.py +++ b/main.py @@ -1,306 +1,99 @@ -import os import asyncio -import easyocr -import magic -import psutil -import torch - +import logging +import os from contextlib import asynccontextmanager +from typing import AsyncGenerator -from typing import AsyncGenerator, List, Literal, Tuple, Dict, Any - +import torch from dotenv import load_dotenv - -from fastapi import FastAPI, File, Form, Request, BackgroundTasks, Response, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi import BackgroundTasks, FastAPI, File, Form, Request, Response, UploadFile +from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates - +from opentelemetry import trace from slowapi import Limiter from slowapi.util import get_remote_address +from uvicorn.logging import DefaultFormatter -from opentelemetry import trace - -from champ.rag import ( - create_embedding_model, - create_session_vector_store, - load_vector_store, -) from classes.base_models import ( - ChatMessage, ChatRequest, CommentRequest, DeleteFileRequest, + FeedbackRequest, ) -# from classes.guardrail_manager import GuardrailManager from classes.pii_filter import PIIFilter -from classes.prompt_injection_filter import PromptInjectionFilter from classes.session_conversation_store import SessionConversationStore +from classes.session_document_store import SessionDocumentStore from classes.session_tracker import SessionTracker from constants import ( - FILE_CHUNK_SIZE, - MAX_FILE_NAME_LENGTH, - MAX_FILE_SIZE, - MAX_HISTORY, MAX_ID_LENGTH, - MAX_RAM_USAGE_PERCENT, - STATUS_CODE_BAD_REQUEST, - STATUS_CODE_CONTENT_TOO_LARGE, STATUS_CODE_EXCEED_SIZE_LIMIT, STATUS_CODE_INTERNAL_SERVER_ERROR, - STATUS_CODE_LENGTH_REQUIRED, - STATUS_CODE_UNPROCESSABLE_CONTENT, - STATUS_CODE_UNSUPPORTED_MEDIA_TYPE, - SUPPORTED_FILE_EXTENSIONS, - SUPPORTED_FILE_TYPES, ) -from helpers.dynamodb_helper import log_event - -from openai import AsyncOpenAI -from google import genai - - -from langchain_core.messages import HumanMessage, AIMessage, SystemMessage - -# from lingua import Language, LanguageDetectorBuilder - -from champ.prompts import ( - DEFAULT_SYSTEM_PROMPT_V2, - DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V2, +from exceptions import ( + FILE_EXTRACTION_ERROR_STATUS_CODES, + FILE_VALIDATION_ERROR_STATUS_CODES, + FileExtractionException, + FileValidationException, ) -from champ.service import ChampService - +from helpers.dynamodb_helper import log_event from helpers.file_helper import ( - extract_text_from_docx, - extract_text_from_img, - extract_text_from_pdf, - extract_text_from_txt, - is_valid_filename, + extract_text_from_file, replace_spaces_in_filename, - safe_unzip_check, - sanitize_image, + validate_file, ) -from classes.session_document_store import SessionDocumentStore +from helpers.lifespan_helper import cleanup_loop, load_heavy_models, run_cleanup +from helpers.llm_helper import call_llm from telemetry import setup_telemetry load_dotenv() +logger = logging.getLogger("uvicorn") + # -------------------- Config -------------------- DEV = os.getenv("ENV", None) == "dev" -# The "Google" models are differentiated by their temperature. -MODEL_MAP = { - "champ": "champ-model/placeholder", - "openai": "gpt-5-mini-2025-08-07", - "google-conservative": "gemini-2.5-flash-lite", - "google-creative": "gemini-2.5-flash-lite", -} - -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -if OPENAI_API_KEY is None: - raise RuntimeError( - "OPENAI_API_KEY is not set. " - "Go to Space → Settings → Variables & secrets and add one." - ) -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") -if GEMINI_API_KEY is None: - raise RuntimeError( - "GEMINI_API_KEY is not set. " - "Go to Space → Settings → Variables & secrets and add one." - ) - -openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None -gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None - # -------------------- Helpers -------------------- -embedding_model = create_embedding_model() -base_vector_store = load_vector_store(embedding_model) # For now, conversations and uploaded documents are stored in RAM. # This is tolerable for a demo, but we will have to switch to # Redis (or another real-time database) at some point. We are # currently storing sessions in what should be a stateless server. -session_document_store = SessionDocumentStore() session_tracker = SessionTracker() +session_document_store = SessionDocumentStore() session_conversation_store = SessionConversationStore() -def run_cleanup(): - print("running cleanup") - deleted_session_ids = session_tracker.delete_inactive_sessions() - if len(deleted_session_ids) > 0: - print(f"{len(deleted_session_ids)} inactive sessions will be deleted.") - for session_id in deleted_session_ids: - session_document_store.delete_session_documents(session_id) - session_conversation_store.delete_session_conversations(session_id) - - while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT: - oldest_session_id = session_tracker.delete_oldest_session() - print(f"Deleting {oldest_session_id} session because of high RAM usage") - if oldest_session_id is None: - break - session_document_store.delete_session_documents(oldest_session_id) - session_conversation_store.delete_session_conversations(oldest_session_id) - - -async def cleanup_loop(): - """Run the 4-hour cleanup check every 10 minutes.""" - while True: - await asyncio.sleep(600) # Wait 10 minutes - run_cleanup() - - -def convert_and_sanitize_messages( - messages: List[ChatMessage], - lang: Literal["en", "fr"], - docs_content: List[str] | None = None, -): - """ - Convert our internal message format into OpenAI-style messages. - """ - # Ideally, the document contents should be aggregated in a vector store - # and sent to the API instead of being added manually to the system - # prompt. However, this would require managing uploaded files which - # is out of scope for the demo. - # - # Read more here: https://developers.openai.com/api/docs/guides/tools-file-search - language = "English" if lang == "en" else "French" - - system_prompt = ( - DEFAULT_SYSTEM_PROMPT_V2.format(language=language) - if docs_content is None - else DEFAULT_SYSTEM_PROMPT_WITH_CONTEXT_V2.format( - context=docs_content, language=language - ) - ) - - out = [{"role": "system", "content": system_prompt}] - for m in messages: - if m.role == "system": - continue - out.append({"role": m.role, "content": m.content}) - return out - - -def convert_and_sanitize_messages_langchain(messages: List[ChatMessage]): - list_chatmessages = [] - - for m in messages[-MAX_HISTORY:]: - if m.role == "user": - list_chatmessages.append(HumanMessage(content=m.content)) - elif m.role == "assistant": - list_chatmessages.append(AIMessage(content=m.content)) - elif m.role == "system": - list_chatmessages.append(SystemMessage(content=m.content)) - return list_chatmessages - - -async def _call_openai( - model_id: str, msgs: list[dict], document_texts: List[str] | None = None -) -> AsyncGenerator[str, None]: - - stream = await openai_client.responses.create( - model=model_id, input=msgs, stream=True - ) - - async for chunk in stream: - if chunk.type == "response.output_text.delta": - yield chunk.delta - - -def _call_gemini(model_id: str, msgs: list[dict], temperature: float) -> str: - transcript = [] - for m in msgs: - role = m["role"] - content = m["content"] - transcript.append(f"{role.upper()}: {content}") - contents = "\n".join(transcript) - - resp = gemini_client.models.generate_content( - model=model_id, - contents=contents, - config={"temperature": temperature}, - ) - return (resp.text or "").strip() - - -def call_llm( - session_id: str, - model_type: str, - lang: Literal["en", "fr"], - conversation: List[ChatMessage], -) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]: - tracer = trace.get_tracer(__name__) - - if model_type == "champ": - session_documents = session_document_store.get_documents(session_id) - with tracer.start_as_current_span("vector_store"): - vector_store = ( - base_vector_store - if session_documents is None - else create_session_vector_store( - base_vector_store, embedding_model, session_documents - ) - ) - - with tracer.start_as_current_span("ChampService"): - champ = ChampService(vector_store=vector_store, lang=lang) - - with tracer.start_as_current_span("convert_messages_langchain"): - msgs = convert_and_sanitize_messages_langchain(conversation) - - with tracer.start_as_current_span("invoke"): - reply, triage_meta, context = champ.invoke(msgs) - - return reply, triage_meta, context - - if model_type not in MODEL_MAP: - raise ValueError(f"Unknown model_type: {model_type}") - - model_id = MODEL_MAP[model_type] - document_contents = session_document_store.get_document_contents(session_id) - msgs = convert_and_sanitize_messages( - conversation, lang=lang, docs_content=document_contents - ) - - if model_type == "openai": - return _call_openai(model_id, msgs) - - if model_type == "google-conservative": - return _call_gemini(model_id, msgs, temperature=0.2), {}, [] - - if model_type == "google-creative": - return _call_gemini(model_id, msgs, temperature=1.0), {}, [] - - # If you later add HF models via hf_client, handle here. - raise ValueError(f"Unhandled model_type: {model_type}") - - # -------------------- FastAPI setup -------------------- @asynccontextmanager async def lifespan(app: FastAPI): - print(f"Is CUDA available: {torch.cuda.is_available()}") + logger = logging.getLogger("uvicorn") - print("Loading the OCR model into memory...") - # We are loading the OCR Reader in advance, because loading the model takes time. - app.state.ocr_reader = easyocr.Reader(["en", "fr"], gpu=torch.cuda.is_available()) + if logger.handlers: + colored_formatter = DefaultFormatter( + fmt="%(levelprefix)s %(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + logger.handlers[0].setFormatter(colored_formatter) - # languages = [Language.ENGLISH, Language.FRENCH] - # app.state.language_detector = LanguageDetectorBuilder.from_languages( - # *languages - # ).build() + logger.info("Logging configured!") - # Idem for the prompt sanitizer. No need to store it in the state since this - # class follows the Singleton design pattern. - PIIFilter() + if torch.cuda.is_available(): + logger.info("CUDA is available") + else: + logger.warning("CUDA is NOT available") + + load_heavy_models() - bg_task = asyncio.create_task(cleanup_loop()) + bg_task = asyncio.create_task( + cleanup_loop( + session_tracker, session_document_store, session_conversation_store + ) + ) yield bg_task.cancel() - del app.state.ocr_reader app = FastAPI(lifespan=lifespan) @@ -312,7 +105,7 @@ templates = Jinja2Templates(directory="templates") @app.middleware("http") async def cleanup_middleware(request: Request, call_next): - run_cleanup() + run_cleanup(session_tracker, session_document_store, session_conversation_store) response = await call_next(request) return response @@ -335,34 +128,23 @@ limiter = Limiter(key_func=get_remote_address) async def chat_endpoint( payload: ChatRequest, background_tasks: BackgroundTasks, request: Request ): - if not payload.human_message: - return JSONResponse({"error": "No message provided"}, status_code=400) - session_id = payload.session_id model_type = payload.model_type lang = payload.lang conversation_id = payload.conversation_id + human_message = payload.human_message session_tracker.update_session(session_id) - prompt_injection_filter = PromptInjectionFilter() - injection_filtered_msg = prompt_injection_filter.sanitize_input( - payload.human_message - ) - pii_filter = PIIFilter() with tracer.start_as_current_span("sanitize_document"): - # pii_filtered_msg = pii_filter.sanitize( - # injection_filtered_msg, app.state.language_detector - # ) - pii_filtered_msg = pii_filter.sanitize(injection_filtered_msg) + pii_filtered_msg = pii_filter.sanitize(human_message) - session_conversation_store.add_human_message( + conversation = session_conversation_store.add_human_message( session_id, payload.conversation_id, pii_filtered_msg ) - conversation = session_conversation_store.get_conversation( - session_id, conversation_id - ) + + document_contents = session_document_store.get_document_contents(session_id) reply = "" triage_meta = {} @@ -372,7 +154,7 @@ async def chat_endpoint( loop = asyncio.get_running_loop() with tracer.start_as_current_span("call_llm"): result = await loop.run_in_executor( - None, call_llm, session_id, model_type, lang, conversation + None, call_llm, model_type, lang, conversation, document_contents ) if isinstance(result, AsyncGenerator): @@ -434,7 +216,6 @@ async def chat_endpoint( }, ) - # Ajouter les passages récupérés background_tasks.add_task( log_event, user_id=payload.user_id, @@ -460,13 +241,37 @@ async def chat_endpoint( return {"reply": reply} +# Endpoint for specific replies/responses +@app.post("/feedback") +@limiter.limit("20/minute") +def feedback_endpoint( + payload: FeedbackRequest, background_tasks: BackgroundTasks, request: Request +): + background_tasks.add_task( + log_event, + user_id=payload.user_id, + session_id=payload.session_id, + data={ + "consent": payload.consent, + "comment": payload.comment, + "age_group": payload.age_group, + "gender": payload.gender, + "roles": payload.roles, + "participant_id": payload.participant_id, + "message_index": payload.message_index, + "rating": payload.rating, + "reply_content": payload.reply_content, + }, + ) + + +# Endpoint for specific generic comments @app.post("/comment") @limiter.limit("20/minute") def comment_endpoint( payload: CommentRequest, background_tasks: BackgroundTasks, request: Request ): - if not payload.comment: - return JSONResponse({"error": "No comment provided"}, status_code=400) + logger.info("Received comment") background_tasks.add_task( log_event, @@ -486,116 +291,42 @@ def comment_endpoint( @app.put("/file") @limiter.limit("12/minute") async def upload_file( - # background_tasks: BackgroundTasks, request: Request, file: UploadFile = File(...), session_id: str = Form( pattern="^[a-zA-Z0-9_-]+$", min_length=1, max_length=MAX_ID_LENGTH ), ): - # Preliminary checks - file_size = file.size - if file_size is None: - return Response(status_code=STATUS_CODE_LENGTH_REQUIRED) - - if file_size > MAX_FILE_SIZE: - return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE) - - # Check filename and extensions - file_name = file.filename - if file_name is None: - return Response(status_code=STATUS_CODE_BAD_REQUEST) - - if len(file_name) > MAX_FILE_NAME_LENGTH: - return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT) + try: + validated_file = await validate_file(file) + except FileValidationException as e: + status_code = FILE_VALIDATION_ERROR_STATUS_CODES[e.error] + return Response(status_code=status_code) - file_name = replace_spaces_in_filename(file_name) + file_content = validated_file.content + file_name = validated_file.filename + file_mime = validated_file.mime_type - if not is_valid_filename(file_name): - return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT) - - _, extension = os.path.splitext(file_name) - if extension not in SUPPORTED_FILE_EXTENSIONS: - print("Unsupported extension") - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - - file_mime = file.headers.get("content-type") - if file_mime is None: - print("None content-type") - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - - if file_mime not in SUPPORTED_FILE_TYPES: - print(f"Unsupported file_mime: {file_mime}") - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - - # Read in chunks to avoid RAM spikes - file_content = b"" - file_size = 0 - while True: - chunk = await file.read(FILE_CHUNK_SIZE) - if not chunk: - break - file_size += len(chunk) - if file_size > MAX_FILE_SIZE: - return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE) - file_content += chunk - - file_mime = magic.from_buffer(file_content[:2048], mime=True) - if file_mime not in SUPPORTED_FILE_TYPES: - print("magic file_mime unsupported") - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - - if file_mime == "application/pdf": - file_text = await extract_text_from_pdf(file_content) - elif file_mime == "text/plain": - file_text = await extract_text_from_txt(file_content) - elif file_mime == "application/zip": - if not safe_unzip_check(file_content): - return Response(status_code=STATUS_CODE_CONTENT_TOO_LARGE) - file_text = await extract_text_from_docx(file_content) - elif file_mime in ["image/jpeg", "image/png"]: - ocr_reader = app.state.ocr_reader - sanitized_file_content = sanitize_image(file_content) - file_text = extract_text_from_img(sanitized_file_content, ocr_reader) - else: - # Theoretically impossible scenario - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - - if file_text is None: + try: + file_text = await extract_text_from_file(file_content, file_mime) + except FileExtractionException as e: + status_code = FILE_EXTRACTION_ERROR_STATUS_CODES[e.error] + return Response(status_code=status_code) + except Exception: + # TODO: Log the unexpected failure return Response(status_code=STATUS_CODE_INTERNAL_SERVER_ERROR) - prompt_injection_filter = PromptInjectionFilter() - injection_filtered_file_text = prompt_injection_filter.sanitize_input(file_text) - pii_filter = PIIFilter() with tracer.start_as_current_span("sanitize_document"): - # pii_filtered_file_text = pii_filter.sanitize( - # injection_filtered_file_text, app.state.language_detector - # ) - pii_filtered_file_text = pii_filter.sanitize(injection_filtered_file_text) + pii_filtered_file_text = pii_filter.sanitize(file_text) if session_document_store.create_document( - session_id, pii_filtered_file_text, file_name, file_size + session_id, pii_filtered_file_text, file_name ): session_tracker.update_session(session_id) else: return Response(status_code=STATUS_CODE_EXCEED_SIZE_LIMIT) - # Should the logging event be coupled to the LLM call instead of the API call? - # background_tasks.add_task( - # log_event, - # user_id=user_id, - # session_id=session_id, - # data={ - # "consent": consent, - # "age_group": age_group, - # "gender": gender, - # "roles": roles, - # "participant_id": participant_id, - # "uploaded_file_name": file_name, - # }, - # ) - @app.delete("/file") @limiter.limit("20/minute") @@ -608,11 +339,4 @@ def delete_file( file_name = replace_spaces_in_filename(file_name) - if not is_valid_filename(file_name): - return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT) - - _, extension = os.path.splitext(file_name) - if extension not in SUPPORTED_FILE_EXTENSIONS: - return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE) - session_document_store.delete_document(session_id, file_name) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..c0cf4911399a3a5b8c7d89aba5eb06bacb570929 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +# pytest.ini +[pytest] +; We skip resource_intensive tests by default +addopts = "-m not resource_intensive" +asyncio_mode = auto +filterwarnings = + ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning + ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning + ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning + ignore:The `use_auth_token` argument is deprecated and will be removed in v4 of SentenceTransformers.:FutureWarning +markers = + resource_intensive: tests that are resource intensive + enable_rate_limit: api tests that require enabling the request rate limit + flaky: tests that exhibits intermittent or sporadic failure \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..33a17575038a77aefcc118f545438bb53a79c052 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +-r requirements.txt +pytest==9.0.2 +pytest-asyncio==1.3.0 +moto==5.1.21 +botocore[crt]==1.42.34 +coverage==7.13.4 +fpdf2==2.8.7 \ No newline at end of file diff --git a/static/app.js b/static/app.js index a4d5640bb32588cd721d674e106f9e79abd93de1..06a65fa7f185f2a12108a2f234a82ceb1e86fb77 100644 --- a/static/app.js +++ b/static/app.js @@ -1,749 +1,36 @@ -const browserLang = navigator.language.split('-')[0]; -const defaultLang = ['en', 'fr'].includes(browserLang) ? browserLang : 'en'; -let currentLang = localStorage.getItem('preferredLang') || defaultLang; - -const chatWindow = document.getElementById('chatWindow'); -const userInput = document.getElementById('userInput'); -const sendBtn = document.getElementById('sendBtn'); - -const uploadFileBtn = document.getElementById('upload-file-btn'); -const uploadFileOverlay = document.getElementById('upload-file-overlay'); -const fileDropZone = document.getElementById('file-drop-zone'); -const fileInput = document.getElementById('file-input'); -const doneFileUploadBtn = document.getElementById('done-file-upload'); -const closeFileUploadBtn = document.getElementById('close-file-upload-btn'); -const fileListHtml = document.getElementById('file-list'); - -const langSwitchContainer = document.getElementById('lang-switch-container'); -const enBtn = document.getElementById('btn-en'); -const frBtn = document.getElementById('btn-fr'); - -document.createElement('svg'); - -const HTML_UPLOAD_ICON = ` - - `; - -const HTML_SPINNER_ICON = ` - - `; - -const HTML_CHECK_ICON = ` - - - `; - -const HTML_TRASH_ICON = ` - - `; - -const FILE_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB -const TOTAL_FILE_SIZE_LIMIT = 30 * 1024 * 1024; // 30 MB -const MAX_FILE_NAME_LENGTH = 50; - -const statusEl = document.getElementById('status'); -const statusComment = document.getElementById('commentStatus'); - -const systemPresetSelect = document.getElementById('systemPreset'); -const clearBtn = document.getElementById('clearBtn'); - -const welcomePopup = document.getElementById('welcomePopup'); - -const consentModal = document.getElementById('consent-modal'); -const consentCheckbox = document.getElementById('consent-checkbox'); -const consentBtn = document.getElementById('consentBtn'); - -const frRadioBtn = document.getElementById('lang-fr'); -const enRadioBtn = document.getElementById('lang-en'); -const continueLangBtn = document.getElementById('lang-continue-btn'); - -const profileModal = document.getElementById('profile-modal'); -const profileBtn = document.getElementById('profileBtn'); -const ageGroupInput = document.getElementById('age-group'); -const genderInput = document.getElementById('gender'); -const roleInputs = document.querySelectorAll('input[name="role"]'); -const participantInput = document.getElementById('participant-id'); - -const popupSlider = document.getElementById('mainSlider'); - -const leaveCommentText = document.getElementById('leave-comment'); -const commentOverlay = document.getElementById('comment-overlay'); - -const closeCommentBtn = document.getElementById('closeCommentBtn'); -const cancelCommentBtn = document.getElementById('cancelCommentBtn'); -const sendCommentBtn = document.getElementById('sendCommentBtn'); -const commentInput = document.getElementById('commentInput'); - -const increaseFontSizeBtn = document.getElementById('increase-font-size-btn'); -const decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn'); -const resetFontSizeBtn = document.getElementById('reset-font-size-btn'); - -// Local in-browser chat history -// We store for each model its chat history and a conversation id. -const modelChats = {}; -modelChats["champ"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()}; -modelChats["openai"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()} -modelChats["google-conservative"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()} -modelChats["google-creative"] = {"messages": [], "conversation_id": 'conversation-' + crypto.randomUUID()} - -let consentGranted = false; - -let ageGroup = ''; -let gender = ''; -let roles = []; -let participantId = ''; - -let sessionId = 'session-' + crypto.randomUUID(); // Unique session ID, generated once per page load -document.body.classList.add('no-scroll'); - -let sessionFiles = []; - -function openModal() { - // Move the translation options at the top right corner of the screen - langSwitchContainer.classList.add('floating'); -} - -function closeModal() { - // Move the translation options in the toolbar - langSwitchContainer.classList.remove('floating'); -} - -function renderMessages() { - chatWindow.innerHTML = ''; - const modelType = systemPresetSelect.value; - modelChats[modelType]["messages"].forEach((m) => { - const bubble = document.createElement('div'); - bubble.classList.add( - 'msg-bubble', - m.role === 'user' ? 'user' : 'assistant' - ); - if (m.content === "no_reply") { - bubble.dataset.i18n = "no_reply"; - } else { - // convert markdown to HTML safely - bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content)); - } - chatWindow.appendChild(bubble); - }); - applyTranslation(); - chatWindow.scrollTop = chatWindow.scrollHeight; -} - -function getMachineId() { - let machineId = localStorage.getItem('MachineId'); - - if (!machineId) { - machineId = 'dev-' + crypto.randomUUID(); - localStorage.setItem('MachineId', machineId); - } - - return machineId; -} - -// ----- Chat ----- - -async function sendMessage() { - const text = userInput.value.trim(); - if (!text) return; - - // Add user message locally - const modelType = systemPresetSelect.value; - modelChats[modelType]["messages"].push({ role: 'user', content: text }); - renderMessages(); - userInput.value = ''; - - statusEl.dataset.i18n = "thinking"; - statusEl.className = 'status status-info'; - applyTranslation(); - - const payload = { - user_id: getMachineId(), - session_id: sessionId, - conversation_id: modelChats[modelType]["conversation_id"], - human_message: text, - model_type: modelType, - consent: consentGranted, - age_group: ageGroup, - gender, - roles, - participant_id: participantId, - lang: currentLang - }; - - try { - const res = await fetch('/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - statusEl.className = 'status status-error'; - if (data.error) { - statusEl.textContent = data.error; - } else { - statusEl.textContent = ""; - statusEl.dataset.i18n = "server_error"; - applyTranslation(); - } - return; - } - - const contentType = res.headers.get('content-type'); - - if (contentType && contentType.includes('application/json')) { - // Batch response - const data = await res.json(); - - const reply = data.reply || "no_reply"; - modelChats[modelType]["messages"].push({ role: 'assistant', content: reply }); - renderMessages(); - } else { - // Streaming response - const assistantMessage = { role: 'assistant', content: '' }; - modelChats[modelType]["messages"].push(assistantMessage); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let done = false; - - while (!done) { - const { value, done: readerDone } = await reader.read(); - done = readerDone; - const chunk = decoder.decode(value, { stream: true }); - assistantMessage.content += chunk; - renderMessages(); - } - } - - - statusEl.dataset.i18n = "ready" - statusEl.className = 'status status-ok'; - applyTranslation(); - } catch (err) { - statusEl.dataset.i18n = "network_error"; - statusEl.className = 'status status-error'; - applyTranslation() - } -} - -function clearConversation() { - const modelType = systemPresetSelect.value; - modelChats[modelType]["messages"] = []; - modelChats[modelType]["conversation_id"] = 'conversation-' + crypto.randomUUID(); - - renderMessages(); - statusEl.dataset.i18n = "conversation_cleared"; - statusEl.className = 'status status-ok'; - applyTranslation(); -} - -// ----- Upload file ------ -function openFileUploadOverlay(e) { - e.preventDefault(); - // Let the stylesheet take over - uploadFileOverlay.style.display = ''; - - openModal(); -} -uploadFileBtn.addEventListener('click', openFileUploadOverlay); - -// Open a file dialog when the drop zone is clicked -fileDropZone.addEventListener('click', () => fileInput.click()); - -// Prevent the browser from opening a dropped file -['dragover', 'drop'].forEach(eventName => { - fileDropZone.addEventListener(eventName, (e) => e.preventDefault()); -}); - -fileDropZone.addEventListener('dragover', () => { - fileDropZone.classList.add('active'); -}); - -// File drop logic -fileDropZone.addEventListener('drop', (e) => { - fileDropZone.classList.remove('active'); - - const addedFiles = Array.from(e.dataTransfer.files); - const isProcessingSuccessful = processFiles(addedFiles); - if (!isProcessingSuccessful) { - return; - } - sessionFiles = sessionFiles.concat(addedFiles); - addedFiles.forEach(async (file) => { - file.state = 'uploading'; - isUploadSuccessful = await uploadFile(file); - file.state = isUploadSuccessful ? 'uploaded' : 'ready'; - renderFiles(); - }); - renderFiles(); -}); - -// File browsing logic -fileInput.addEventListener('change', (e) => { - const addedFiles = Array.from(e.target.files); - const isProcessingSuccessful = processFiles(addedFiles); - if (!isProcessingSuccessful) { - return; - } - sessionFiles = sessionFiles.concat(addedFiles); - addedFiles.forEach(async (file) => { - file.state = 'uploading'; - isUploadSuccessful = await uploadFile(file); - file.state = isUploadSuccessful ? 'uploaded' : 'ready'; - renderFiles(); - }); - renderFiles(); -}); - -function processFiles(newFiles) { - const ALLOWED_TYPES = ['.pdf', '.txt', '.docx', '.jpg', '.jpeg', '.png']; - - const unallowed_files = newFiles.filter((file) => !ALLOWED_TYPES.some(ext => file.name.endsWith(ext))) - - if (unallowed_files.length > 0) { - newFiles.forEach((file) => { - removeFileFromInput(fileInput, file) - }); - showSnackbar(translations[currentLang]["error_file_format"], "error"); - return false; - } - - const large_files = newFiles.filter((file) => file.size > FILE_SIZE_LIMIT); - if (large_files.length > 0) { - newFiles.forEach((file) => { - removeFileFromInput(fileInput, file) - }); - showSnackbar(translations[currentLang]["error_file_size"], "error"); - return false; - } - - const totalFileSize = [...newFiles, ...sessionFiles].reduce((sum, file) => sum + file.size, 0); - if (totalFileSize > TOTAL_FILE_SIZE_LIMIT) { - newFiles.forEach((file) => { - removeFileFromInput(fileInput, file) - }); - showSnackbar(translations[currentLang]["error_total_file_size"], "error"); - return false; - } - - const files_with_long_name = newFiles.filter((file) => file.name.length > MAX_FILE_NAME_LENGTH); - if (files_with_long_name.length > 0) { - newFiles.forEach((file) => { - removeFileFromInput(fileInput, file) - }); - showSnackbar(translations[currentLang]["error_file_name_length"], "error"); - return false; - } - - return true; -}; - -function removeFileFromInput(fileInput, fileToRemove) { - // File inputs are read-only. We have to update them - // by assigning a new value instead of filtering out - // directly files we do not want anymore. - const dt = new DataTransfer(); - const { files } = fileInput; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (file !== fileToRemove) { - dt.items.add(file); - } - } - - fileInput.files = dt.files; -} - -function renderFiles() { - fileListHtml.innerHTML = ''; - - if (sessionFiles.length === 0) { - const noFileMessage = document.createElement('div'); - noFileMessage.classList.add('no-file'); - noFileMessage.dataset.i18n = "no_files"; - fileListHtml.appendChild(noFileMessage); - applyTranslation(); - return; - } - - sessionFiles.forEach((f) => { - const fileItem = document.createElement('div'); - fileItem.classList.add('file-item'); - - fileItem.textContent = f.name; - - const fileActions = document.createElement('div'); - fileActions.classList.add('file-actions'); - - const uploadButton = document.createElement('button'); - if (f.state === 'uploaded') { - uploadButton.innerHTML = HTML_CHECK_ICON + ``; - uploadButton.classList.add('disabled-button'); - uploadButton.disabled = true; - } else if (f.state === 'uploading') { - uploadButton.innerHTML = HTML_SPINNER_ICON + ``; - uploadButton.classList.add('disabled-button'); - uploadButton.disabled = true; - } else if (f.state == 'ready') { - uploadButton.innerHTML = HTML_UPLOAD_ICON + ``; - uploadButton.classList.add('ok-button'); - uploadButton.addEventListener('click', async () => { - f.state = 'uploading'; - renderFiles(); - isUploadSuccessful = await uploadFile(f); - f.state = isUploadSuccessful ? 'uploaded' : 'ready'; - renderFiles(); - }); - } - - const deleteButton = document.createElement('button'); - deleteButton.innerHTML = HTML_TRASH_ICON + ``; - deleteButton.classList.add('no-button'); - deleteButton.addEventListener('click', async () => { - // No need to send a request to the server if the file was not uploaded - isDeletionSuccessful = f.state === 'uploaded' ? await deleteFile(f) : true; - if (isDeletionSuccessful) { - removeFileFromInput(fileInput, f); - sessionFiles = sessionFiles.filter((file) => file !== f); - renderFiles(); - } - }); - - fileActions.appendChild(uploadButton); - fileActions.appendChild(deleteButton); - fileItem.appendChild(fileActions); - fileListHtml.appendChild(fileItem); - applyTranslation(); - }); -}; - -async function uploadFile(file) { - // Can't use JSON payloads to send PDF or DOCX files - const formData = new FormData(); - formData.append('file', file); - // formData.append('user_id', getMachineId()); - formData.append('session_id', sessionId); - // formData.append('consent', consentGranted); - // formData.append('age_group', ageGroup); - // formData.append('gender', gender); - // formData.append('roles', roles); - // formData.append('participant_id', participantId); - - try { - const res = await fetch('/file', { - method: 'PUT', - body: formData, - }); - - if (!res.ok) { - showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error'); - return false; - } - - showSnackbar(translations[currentLang]["file_upload_success"], 'success'); - return true; - } catch (err) { - showSnackbar(translations[currentLang]["file_upload_failed_network_error"], 'error'); - return false; - } -} - -async function deleteFile(file) { - const payload = { - file_name: file.name, - user_id: getMachineId(), - session_id: sessionId, - consent: consentGranted, - age_group: ageGroup, - gender, - roles, - participant_id: participantId - }; - - try { - const res = await fetch('/file', { - method: 'DELETE', - body: JSON.stringify(payload), - headers: { 'Content-Type': 'application/json' }, - }); - - if (!res.ok) { - showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error'); - return false; - } - - showSnackbar(translations[currentLang]["file_delete_success"], 'success'); - return true; - } catch (err) { - showSnackbar(translations[currentLang]["file_delete_failed_network_error"], 'error'); - return false; - } -} - -// Close the overlay -closeFileUploadBtn.addEventListener('click', () => { - uploadFileOverlay.style.display = 'none'; - closeModal(); -}); -doneFileUploadBtn.addEventListener('click', () => { - uploadFileOverlay.style.display = 'none'; - closeModal(); -}) - -// ----- Event wiring ----- - -// Language modal logic -continueLangBtn.addEventListener('click', () => { - consentModal.scrollIntoView({ - behavior: 'smooth', - inline: 'start', - block: 'nearest' - }); -}); - -frRadioBtn.addEventListener('change', () => { - currentLang = frRadioBtn.value; - setLanguage(); -}); -enRadioBtn.addEventListener('change', () => { - currentLang = enRadioBtn.value; - setLanguage(); -}); - -// Consent logic -// When the checkbox is toggled, enable or disable the button -consentCheckbox.addEventListener('change', () => { - if (consentCheckbox.checked) { - consentBtn.disabled = false; - consentBtn.classList.replace('disabled-button', 'ok-button') - } else { - consentBtn.disabled = true; - consentBtn.classList.replace('ok-button', 'disabled-button') - } -}); - -// Handle the consent acceptance -consentBtn.addEventListener('click', () => { - consentGranted = true; // Mark consent as granted - profileModal.scrollIntoView({ - behavior: 'smooth', - inline: 'start', - block: 'nearest' - }); -}); - -// When the profile is changed, enable or disable the button -function checkProfileValidity () { - // 1. Check if any gender is selected - const genderSelected = genderInput.value !== ''; - - // 2. Check if any age group is selected - const ageSelected = ageGroupInput.value !== ''; - - // 3. Check if at least one role checkbox is selected - const roleSelected = Array.from(roleInputs).some(input => input.checked); - - // 4. Check if the participant id field has a value - const participantIdEntered = participantInput.value.trim().length > 0; - - // 5. Enable button only if both are true - if (genderSelected && ageSelected && roleSelected && participantIdEntered) { - profileBtn.disabled = false; - profileBtn.classList.replace('disabled-button', 'ok-button') - } else { - profileBtn.disabled = true; - profileBtn.classList.replace('ok-button', 'disabled-button'); - } -} -// Add the listener to all gender radio buttons and role checkboxes -genderInput.addEventListener('click', checkProfileValidity); -ageGroupInput.addEventListener('click', checkProfileValidity); - - -roleInputs.forEach(input => input.addEventListener('change', checkProfileValidity)); -participantInput.addEventListener('input', checkProfileValidity); - -profileBtn.addEventListener('click', () => { - welcomePopup.style.display = 'none'; // Hide overlay - document.body.classList.remove('no-scroll'); // NEW: re-enable scrolling - - ageGroup = document.getElementById('age-group').value; - gender = document.getElementById('gender').value; - roles = Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value); - participantId = participantInput.value.trim(); - - closeModal(); -}); - -sendBtn.addEventListener('click', sendMessage); - -// Enter to send, Shift+Enter = newline -userInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } -}); -commentInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendComment(); - } -}); - - -clearBtn.addEventListener('click', clearConversation); - -systemPresetSelect.addEventListener('change', () => { - statusEl.dataset.i18n = "model_changed"; - statusEl.className = 'status status-ok'; - renderMessages(); - applyTranslation(); -}); - -// Comments -function openCommentOverlay(e) { - e.preventDefault(); - // Let the stylesheet take over - commentOverlay.style.display = ''; - - openModal(); -} -leaveCommentText.addEventListener('click', openCommentOverlay); - -// Cancelling or closing the comment overlay simply hides the comment popup -closeCommentBtn.addEventListener('click', () => { - commentOverlay.style.display = 'none'; - closeModal(); -}); -cancelCommentBtn.addEventListener('click', () => { - commentOverlay.style.display = 'none'; - closeModal(); -}); - -async function sendComment() { - const comment = commentInput.value; - if (!comment) return; - - const payload = { - user_id: getMachineId(), - session_id: sessionId, - comment, - consent: consentGranted, - age_group: ageGroup, - gender, - roles, - participant_id: participantId - }; - - statusComment.dataset.i18n = "sending"; - statusComment.className = 'status-info'; - applyTranslation(); - - try { - const res = await fetch('/comment', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - statusComment.dataset.i18n = "server_error"; - statusComment.className = 'status-error'; - applyTranslation(); - return; - } - - commentInput.value = ''; - - statusComment.dataset.i18n = "comment_sent"; - statusComment.className = 'status-ok'; - applyTranslation(); - } catch (err) { - statusComment.dataset.i18n = "network_error"; - statusComment.className = 'status-error'; - applyTranslation(); - } - -}; -sendCommentBtn.addEventListener('click', sendComment); - -// Translation -function setLanguage() { - applyTranslation(); - - document.getElementById('btn-en').classList.toggle('active', currentLang === 'en'); - document.getElementById('btn-fr').classList.toggle('active', currentLang === 'fr'); - - frRadioBtn.checked = currentLang === 'fr'; - enRadioBtn.checked = currentLang === 'en'; - - localStorage.setItem('preferredLang', currentLang); -}; - -enBtn.addEventListener('click', () => { - currentLang = 'en'; - setLanguage(); -}); -frBtn.addEventListener('click', () => { - currentLang = 'fr'; - setLanguage(); -}); - -function applyTranslation() { - document.querySelectorAll('[data-i18n]').forEach(element => { - const key = element.getAttribute('data-i18n'); - element.textContent = translations[currentLang][key]; - }); - userInput.placeholder = translations[currentLang]["input_placeholder"]; - commentInput.placeholder = translations[currentLang]["comment_placeholder"]; -}; - -const MIN_FONT_SIZE = 0.75; -const MAX_FONT_SIZE = 2.5; -const FONT_SIZE_STEP = 0.125; // 1/8 rem for smooth increments - -let currentSize = 1; // 1rem = browser default (usually 16px) - -// Font size -function updateFontSize(newSize) { - currentSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newSize)); - document.documentElement.style.fontSize = currentSize + 'rem'; -} - -increaseFontSizeBtn.addEventListener('click', () => { - updateFontSize(currentSize + FONT_SIZE_STEP); -}); - -decreaseFontSizeBtn.addEventListener('click', () => { - updateFontSize(currentSize - FONT_SIZE_STEP); -}); - -resetFontSizeBtn.addEventListener('click', () => { - updateFontSize(1); // 1rem = browser default -}); - - -// Setup -statusComment.dataset.i18n = "ready"; -statusComment.className = 'status-ok'; - -if (currentLang == "en") { - enBtn.classList.add('active'); - enRadioBtn.checked = true; -} else { - frBtn.classList.add('active'); - frRadioBtn.checked = true; -} - -applyTranslation(); -renderFiles(); - -// Open the details element by default on desktop only. -if (window.innerWidth >= 460) { - document.querySelector('details').setAttribute('open', ''); -} - -openModal(); \ No newline at end of file +// app.js - Main application initialization + +import { ChatComponent } from './components/chat-component.js'; +import { FileUploadComponent } from './components/file-upload-component.js'; +import { SettingsComponent } from './components/settings-component.js'; +import { LanguageComponent } from './components/language-component.js'; +import { ConsentComponent } from './components/consent-component.js'; +import { ProfileComponent } from './components/profile-component.js'; +import { CommentComponent } from './components/comment-component.js'; +import { FeedbackComponent } from './components/feedback-component.js'; +import { TranslationService } from './services/translation-service.js'; + +// Initialize the application when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Initialize all components + ChatComponent.init(); + FileUploadComponent.init(); + SettingsComponent.init(); + LanguageComponent.init(); + ConsentComponent.init(); + ProfileComponent.init(); + CommentComponent.init(); + FeedbackComponent.init(); + + // Make FeedbackComponent globally accessible for chat component + window.FeedbackComponent = FeedbackComponent; + + // Apply initial translations + TranslationService.applyTranslation(); + + // Open the details element by default on desktop only + if (window.innerWidth >= 460) { + const details = document.querySelector('details'); + if (details) details.setAttribute('open', ''); + } +}); \ No newline at end of file diff --git a/static/components/chat-component.js b/static/components/chat-component.js new file mode 100644 index 0000000000000000000000000000000000000000..7fe3ff0046dc03741050922e51dce3a506c1b980 --- /dev/null +++ b/static/components/chat-component.js @@ -0,0 +1,276 @@ +// components/chat-component.js - Chat functionality + +import { StateManager } from '../services/state-manager.js'; +import { ApiService } from '../services/api-service.js'; +import { TranslationService } from '../services/translation-service.js'; + +export const ChatComponent = { + elements: { + chatWindow: null, + userInput: null, + sendBtn: null, + clearBtn: null, + systemPresetSelect: null, + statusEl: null + }, + + /** + * Initialize the chat component + */ + init() { + this.elements.chatWindow = document.getElementById('chatWindow'); + this.elements.userInput = document.getElementById('userInput'); + this.elements.sendBtn = document.getElementById('sendBtn'); + this.elements.clearBtn = document.getElementById('clearBtn'); + this.elements.systemPresetSelect = document.getElementById('systemPreset'); + this.elements.statusEl = document.getElementById('status'); + + // This event is dispatched when the user rates a reply. The system + // must then mark that reply and re-render it. + window.addEventListener('feedbackSubmitted', () => { + this.renderMessages(); + }); + + this.attachEventListeners(); + this.renderMessages(); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.sendBtn.addEventListener('click', () => this.sendMessage()); + this.elements.clearBtn.addEventListener('click', () => this.clearConversation()); + this.elements.systemPresetSelect.addEventListener('change', () => this.onModelChange()); + + // Enter to send, Shift+Enter = newline + this.elements.userInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + }, + + /** + * Render all messages in the chat window + */ + renderMessages() { + this.elements.chatWindow.innerHTML = ''; + const modelType = this.elements.systemPresetSelect.value; + const messages = StateManager.getMessages(modelType); + + messages.forEach((m, index) => { + const messageContainer = document.createElement('div'); + messageContainer.classList.add('message-container'); + + const bubble = document.createElement('div'); + bubble.classList.add( + 'msg-bubble', + m.role === 'user' ? 'user' : 'assistant' + ); + + if (m.content === "no_reply") { + bubble.dataset.i18n = "no_reply"; + } else { + // convert markdown to HTML safely + bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content)); + } + + messageContainer.appendChild(bubble); + + // Add feedback buttons for assistant messages only + if (m.role === 'assistant' && m.content !== "no_reply") { + const feedbackButtons = this.createFeedbackButtons(index, modelType, m); + messageContainer.appendChild(feedbackButtons); + } + + this.elements.chatWindow.appendChild(messageContainer); + }); + + TranslationService.applyTranslation(); + this.elements.chatWindow.scrollTop = this.elements.chatWindow.scrollHeight; + }, + + /** + * Create feedback buttons for a message + * @param {number} index - Message index + * @param {string} modelType - Model type + * @param {Object} message - Message object + * @returns {HTMLElement} Feedback buttons container + */ + createFeedbackButtons(index, modelType, message) { + const container = document.createElement('div'); + container.classList.add('feedback-buttons'); + + // Check if already rated + const isRated = message.feedback?.rated; + const currentRating = message.feedback?.rating; + + // Copy button + const copyBtn = document.createElement('button'); + copyBtn.classList.add('feedback-btn', 'copy-btn'); + copyBtn.innerHTML = '📋'; + copyBtn.dataset.i18nTitle = "copy_reply_btn"; + copyBtn.title = translations[StateManager.currentLang]["copy_reply_btn"]; + copyBtn.addEventListener('click', () => { + this.copyMessage(message.content, copyBtn); + }); + + // Like button + const likeBtn = document.createElement('button'); + likeBtn.classList.add('feedback-btn', 'like-feedback-btn'); + if (isRated && currentRating === 'like') likeBtn.classList.add('active'); + likeBtn.innerHTML = '👍'; + likeBtn.dataset.i18nTitle = "feedback_like_btn"; + likeBtn.title = translations[StateManager.currentLang]["feedback_like_btn"]; + likeBtn.addEventListener('click', () => { + window.FeedbackComponent.openModal(index, modelType, 'like', message.content); + }); + + // Dislike button + const dislikeBtn = document.createElement('button'); + dislikeBtn.classList.add('feedback-btn', 'dislike-feedback-btn'); + if (isRated && currentRating === 'dislike') dislikeBtn.classList.add('active'); + dislikeBtn.innerHTML = '👎'; + dislikeBtn.dataset.i18nTitle = "feedback_dislike_btn"; + dislikeBtn.title = translations[StateManager.currentLang]["feedback_dislike_btn"]; + dislikeBtn.addEventListener('click', () => { + window.FeedbackComponent.openModal(index, modelType, 'dislike', message.content); + }); + + // Mixed button + const mixedBtn = document.createElement('button'); + mixedBtn.classList.add('feedback-btn', 'mixed-feedback-btn'); + if (isRated && currentRating === 'mixed') mixedBtn.classList.add('active'); + mixedBtn.innerHTML = '~'; + mixedBtn.dataset.i18nTitle = "feedback_mixed_btn"; + mixedBtn.title = translations[StateManager.currentLang]["feedback_mixed_btn"]; + mixedBtn.addEventListener('click', () => { + window.FeedbackComponent.openModal(index, modelType, 'mixed', message.content); + }); + + // TODO: 4 buttons is a lot. The copy button should be isolated in some way. + container.appendChild(copyBtn); + container.appendChild(likeBtn); + container.appendChild(dislikeBtn); + container.appendChild(mixedBtn); + + return container; + }, + + /** + * Copy message content to clipboard + * @param {string} content - Message content to copy + * @param {HTMLElement} button - The copy button element + */ + async copyMessage(content, button) { + // Strip HTML and get plain text + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = DOMPurify.sanitize(marked.parse(content)); + const plainText = tempDiv.innerText || tempDiv.textContent; + + // Copy to clipboard + await navigator.clipboard.writeText(plainText); + + // Visual feedback - change icon temporarily + const originalIcon = button.innerHTML; + button.innerHTML = '✓'; + button.classList.add('copied'); + + // Show snackbar + showSnackbar(translations[StateManager.currentLang]["message_copied"], 'success', 2000); + + // Reset after 2 seconds + setTimeout(() => { + button.innerHTML = originalIcon; + button.classList.remove('copied'); + }, 2000); + }, + + /** + * Send a message to the chat + */ + async sendMessage() { + const text = this.elements.userInput.value.trim(); + if (!text) return; + + const modelType = this.elements.systemPresetSelect.value; + + // Add user message locally + StateManager.addMessage(modelType, { role: 'user', content: text }); + this.renderMessages(); + this.elements.userInput.value = ''; + + // Update status + this.setStatus('thinking', 'info'); + + try { + const res = await ApiService.sendChatMessage(text, modelType); + const contentType = res.headers.get('content-type'); + + if (contentType && contentType.includes('application/json')) { + // Batch response + const data = await res.json(); + const reply = data.reply || "no_reply"; + StateManager.addMessage(modelType, { role: 'assistant', content: reply }); + this.renderMessages(); + } else { + // Streaming response + const assistantMessage = { role: 'assistant', content: '' }; + StateManager.addMessage(modelType, assistantMessage); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + + while (!done) { + const { value, done: readerDone } = await reader.read(); + done = readerDone; + const chunk = decoder.decode(value, { stream: true }); + assistantMessage.content += chunk; + this.renderMessages(); + } + } + + this.setStatus('ready', 'ok'); + } catch (err) { + if (err.message === 'HTTP 400') { + this.setStatus('empty_message_error', 'error'); + } else if (err.message.startsWith('HTTP')) { + this.setStatus('server_error', 'error'); + } else { + this.setStatus('network_error', 'error'); + } + } + }, + + /** + * Clear the conversation + */ + clearConversation() { + const modelType = this.elements.systemPresetSelect.value; + StateManager.clearConversation(modelType); + this.renderMessages(); + this.setStatus('conversation_cleared', 'ok'); + }, + + /** + * Handle model change + */ + onModelChange() { + this.setStatus('model_changed', 'ok'); + this.renderMessages(); + }, + + /** + * Set status message + * @param {string} messageKey - Translation key for the message + * @param {string} type - Status type ('ok', 'info', 'error') + */ + setStatus(messageKey, type) { + this.elements.statusEl.dataset.i18n = messageKey; + this.elements.statusEl.className = `status status-${type}`; + TranslationService.applyTranslation(); + } +}; \ No newline at end of file diff --git a/static/components/comment-component.js b/static/components/comment-component.js new file mode 100644 index 0000000000000000000000000000000000000000..4d1317b8a9282364d9328a05555a06ecf1aa9d6b --- /dev/null +++ b/static/components/comment-component.js @@ -0,0 +1,120 @@ +// components/comment-component.js - Comments functionality + +import { StateManager } from '../services/state-manager.js'; +import { ApiService } from '../services/api-service.js'; +import { TranslationService } from '../services/translation-service.js'; + +export const CommentComponent = { + elements: { + leaveCommentText: null, + commentOverlay: null, + closeCommentBtn: null, + cancelCommentBtn: null, + sendCommentBtn: null, + commentInput: null, + statusComment: null + }, + + /** + * Initialize the comment component + */ + init() { + this.elements.leaveCommentText = document.getElementById('leave-comment'); + this.elements.commentOverlay = document.getElementById('comment-overlay'); + this.elements.closeCommentBtn = document.getElementById('closeCommentBtn'); + this.elements.cancelCommentBtn = document.getElementById('cancelCommentBtn'); + this.elements.sendCommentBtn = document.getElementById('sendCommentBtn'); + this.elements.commentInput = document.getElementById('commentInput'); + this.elements.statusComment = document.getElementById('commentStatus'); + + this.attachOutsideClickListener(); + this.attachEventListeners(); + this.initializeStatus(); + }, + + attachOutsideClickListener() { + this.elements.commentOverlay.addEventListener('click', (e) => { + // Check if click is on the overlay itself (not its children) + if (e.target === this.elements.commentOverlay) { + this.closeOverlay(); + } + }); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.leaveCommentText.addEventListener('click', (e) => this.openOverlay(e)); + this.elements.closeCommentBtn.addEventListener('click', () => this.closeOverlay()); + this.elements.cancelCommentBtn.addEventListener('click', () => this.closeOverlay()); + this.elements.sendCommentBtn.addEventListener('click', () => this.sendComment()); + + // Enter to send, Shift+Enter = newline + this.elements.commentInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendComment(); + } + }); + }, + + /** + * Initialize status display + */ + initializeStatus() { + this.elements.statusComment.dataset.i18n = "ready"; + this.elements.statusComment.className = 'status-ok'; + TranslationService.applyTranslation(); + }, + + /** + * Open the comment overlay + */ + openOverlay(e) { + e.preventDefault(); + this.elements.commentOverlay.style.display = ''; + }, + + /** + * Close the comment overlay + */ + closeOverlay() { + this.elements.commentOverlay.style.display = 'none'; + }, + + /** + * Send a comment to the server + */ + async sendComment() { + const comment = this.elements.commentInput.value; + if (!comment) return; + + this.setStatus('sending', 'info'); + + const result = await ApiService.sendComment(comment); + + if (!result.success) { + if (result.status === 400) { + this.setStatus('empty_message_error', 'error'); + } else { + this.setStatus('server_error', 'error'); + } + return; + } + + this.elements.commentInput.value = ''; + this.setStatus('comment_sent', 'ok'); + }, + + /** + * Set status message + * @param {string} messageKey - Translation key for the message + * @param {string} type - Status type ('ok', 'info', 'error') + */ + setStatus(messageKey, type) { + this.elements.statusComment.dataset.i18n = messageKey; + this.elements.statusComment.className = `status-${type}`; + TranslationService.applyTranslation(); + } +}; \ No newline at end of file diff --git a/static/components/consent-component.js b/static/components/consent-component.js new file mode 100644 index 0000000000000000000000000000000000000000..5effb258c31a688ea712e5523433ed01d0d8f2d0 --- /dev/null +++ b/static/components/consent-component.js @@ -0,0 +1,50 @@ +// components/consent-component.js - Consent modal functionality + +import { StateManager } from '../services/state-manager.js'; + +export const ConsentComponent = { + elements: { + consentModal: null, + consentCheckbox: null, + consentBtn: null, + profileModal: null + }, + + /** + * Initialize the consent component + */ + init() { + this.elements.consentModal = document.getElementById('consent-modal'); + this.elements.consentCheckbox = document.getElementById('consent-checkbox'); + this.elements.consentBtn = document.getElementById('consentBtn'); + this.elements.profileModal = document.getElementById('profile-modal'); + + this.attachEventListeners(); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + // When the checkbox is toggled, enable or disable the button + this.elements.consentCheckbox.addEventListener('change', () => { + if (this.elements.consentCheckbox.checked) { + this.elements.consentBtn.disabled = false; + this.elements.consentBtn.classList.replace('disabled-button', 'ok-button'); + } else { + this.elements.consentBtn.disabled = true; + this.elements.consentBtn.classList.replace('ok-button', 'disabled-button'); + } + }); + + // Handle the consent acceptance + this.elements.consentBtn.addEventListener('click', () => { + StateManager.setConsent(true); + this.elements.profileModal.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + block: 'nearest' + }); + }); + } +}; \ No newline at end of file diff --git a/static/components/feedback-component.js b/static/components/feedback-component.js new file mode 100644 index 0000000000000000000000000000000000000000..ff4a84fa333c364663f407990557c40e90bf0316 --- /dev/null +++ b/static/components/feedback-component.js @@ -0,0 +1,200 @@ +// components/feedback-component.js - Message feedback functionality + +import { StateManager } from '../services/state-manager.js'; +import { ApiService } from '../services/api-service.js'; +import { TranslationService } from '../services/translation-service.js'; +import { Utils } from '../utils.js'; + +export const FeedbackComponent = { + elements: { + feedbackOverlay: null, + closeFeedbackBtn: null, + cancelFeedbackBtn: null, + submitFeedbackBtn: null, + feedbackInput: null, + feedbackRatingDisplay: null, + feedbackMessagePreview: null + }, + + currentFeedback: { + messageIndex: null, + modelType: null, + rating: null, // 'like', 'dislike', 'mixed' + messageContent: null + }, + + /** + * Initialize the feedback component + */ + init() { + // Get DOM elements + this.elements.feedbackOverlay = document.getElementById('feedback-overlay'); + this.elements.closeFeedbackBtn = document.getElementById('closeFeedbackBtn'); + this.elements.cancelFeedbackBtn = document.getElementById('cancelFeedbackBtn'); + this.elements.submitFeedbackBtn = document.getElementById('submitFeedbackBtn'); + this.elements.feedbackInput = document.getElementById('feedbackInput'); + this.elements.feedbackRatingDisplay = document.getElementById('feedbackRatingDisplay'); + this.elements.feedbackMessagePreview = document.getElementById('feedbackMessagePreview'); + + this.attachOutsideClickListener(); + this.attachEventListeners(); + }, + + attachOutsideClickListener() { + this.elements.feedbackOverlay.addEventListener('click', (e) => { + // Check if click is on the overlay itself (not its children) + if (e.target === this.elements.feedbackOverlay) { + this.closeModal(); + } + }); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.closeFeedbackBtn.addEventListener('click', () => this.closeModal()); + this.elements.cancelFeedbackBtn.addEventListener('click', () => this.closeModal()); + this.elements.submitFeedbackBtn.addEventListener('click', () => this.submitFeedback()); + + // Enter to submit (optional comment) + this.elements.feedbackInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.submitFeedback(); + } + }); + }, + + /** + * Open feedback modal + * @param {number} messageIndex - Index of the message + * @param {string} modelType - Type of model + * @param {string} rating - 'like', 'dislike', or 'mixed' + * @param {string} messageContent - Content of the message being rated + */ + openModal(messageIndex, modelType, rating, messageContent) { + this.currentFeedback = { + messageIndex, + modelType, + rating, + messageContent + }; + + // Update modal content + this.updateModalContent(rating, messageContent); + + // Show modal + this.elements.feedbackOverlay.style.display = ''; + + // Focus on textarea + setTimeout(() => this.elements.feedbackInput.focus(), 100); + }, + + /** + * Update modal content based on rating + * @param {string} rating - The rating type + * @param {string} messageContent - The message content + */ + updateModalContent(rating, messageContent) { + // Update rating display + const ratingEmoji = { + 'like': '👍', + 'dislike': '👎', + 'mixed': '~' + }; + + const ratingText = { + 'like': 'feedback_like_title', + 'dislike': 'feedback_dislike_title', + 'mixed': 'feedback_neutral_title' + }; + + this.elements.feedbackRatingDisplay.textContent = ratingEmoji[rating] + ' '; + this.elements.feedbackRatingDisplay.dataset.i18n = ratingText[rating]; + + // Update message preview (truncate if too long) + const preview = messageContent.length > 150 + ? messageContent.substring(0, 150) + '...' + : messageContent; + this.elements.feedbackMessagePreview.textContent = preview; + + // Clear previous comment + this.elements.feedbackInput.value = ''; + + // Apply translations + TranslationService.applyTranslation(); + }, + + /** + * Close the feedback modal + */ + closeModal() { + this.elements.feedbackOverlay.style.display = 'none'; + this.currentFeedback = { + messageIndex: null, + modelType: null, + rating: null, + messageContent: null + }; + }, + + /** + * Submit feedback to the server + */ + async submitFeedback() { + const comment = this.elements.feedbackInput.value.trim(); + + const feedbackData = { + message_index: this.currentFeedback.messageIndex, + model_type: this.currentFeedback.modelType, + rating: this.currentFeedback.rating, + comment: comment || "", // Optional + reply_content: this.currentFeedback.messageContent, + user_id: Utils.getMachineId(), + session_id: StateManager.sessionId, + conversation_id: StateManager.getConversationId(this.currentFeedback.modelType) + }; + + try { + const result = await ApiService.submitFeedback(feedbackData); + + if (result.success) { + showSnackbar(translations[StateManager.currentLang]["feedback_submitted"], 'success'); + + // Mark message as rated in state (optional - for UI indication) + this.markMessageAsRated( + this.currentFeedback.modelType, + this.currentFeedback.messageIndex, + this.currentFeedback.rating + ); + + this.closeModal(); + } else { + showSnackbar(translations[StateManager.currentLang]["feedback_failed_server_error"], 'error'); + } + } catch (err) { + showSnackbar(translations[StateManager.currentLang]["feedback_failed_network_error"], 'error'); + } + }, + + /** + * Mark a message as rated (for UI purposes) + * @param {string} modelType - Model type + * @param {number} messageIndex - Message index + * @param {string} rating - Rating given + */ + markMessageAsRated(modelType, messageIndex, rating) { + const messages = StateManager.getMessages(modelType); + if (messages[messageIndex]) { + messages[messageIndex].feedback = { + rated: true, + rating: rating + }; + + window.dispatchEvent(new CustomEvent('feedbackSubmitted', { + detail: { modelType, messageIndex, rating } + })); + } + } +}; \ No newline at end of file diff --git a/static/components/file-upload-component.js b/static/components/file-upload-component.js new file mode 100644 index 0000000000000000000000000000000000000000..378cdcc845b395a8a8b397a191b6ac42ec249612 --- /dev/null +++ b/static/components/file-upload-component.js @@ -0,0 +1,273 @@ +// components/file-upload-component.js - File upload and management + +import { Utils } from '../utils.js'; +import { StateManager } from '../services/state-manager.js'; +import { ApiService } from '../services/api-service.js'; +import { TranslationService } from '../services/translation-service.js'; + +export const FileUploadComponent = { + elements: { + uploadFileBtn: null, + uploadFileOverlay: null, + fileDropZone: null, + fileInput: null, + doneFileUploadBtn: null, + closeFileUploadBtn: null, + fileListHtml: null + }, + + constants: { + FILE_SIZE_LIMIT: 10 * 1024 * 1024, // 10 MB + TOTAL_FILE_SIZE_LIMIT: 30 * 1024 * 1024, // 30 MB + MAX_FILE_NAME_LENGTH: 50, + ALLOWED_TYPES: ['.pdf', '.txt', '.docx', '.jpg', '.jpeg', '.png'] + }, + + icons: { + upload: ` + + `, + spinner: ` + + `, + check: ` + + `, + trash: ` + + ` + }, + + /** + * Initialize the file upload component + */ + init() { + this.elements.uploadFileBtn = document.getElementById('upload-file-btn'); + this.elements.uploadFileOverlay = document.getElementById('upload-file-overlay'); + this.elements.fileDropZone = document.getElementById('file-drop-zone'); + this.elements.fileInput = document.getElementById('file-input'); + this.elements.doneFileUploadBtn = document.getElementById('done-file-upload'); + this.elements.closeFileUploadBtn = document.getElementById('close-file-upload-btn'); + this.elements.fileListHtml = document.getElementById('file-list'); + + this.attachOutsideClickListener(); + this.attachEventListeners(); + this.renderFiles(); + }, + + attachOutsideClickListener() { + this.elements.uploadFileOverlay.addEventListener('click', (e) => { + // Check if click is on the overlay itself (not its children) + if (e.target === this.elements.uploadFileOverlay) { + this.closeOverlay(); + } + }); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.uploadFileBtn.addEventListener('click', (e) => this.openOverlay(e)); + this.elements.fileDropZone.addEventListener('click', () => this.elements.fileInput.click()); + this.elements.closeFileUploadBtn.addEventListener('click', () => this.closeOverlay()); + this.elements.doneFileUploadBtn.addEventListener('click', () => this.closeOverlay()); + + // Prevent the browser from opening a dropped file + ['dragover', 'drop'].forEach(eventName => { + this.elements.fileDropZone.addEventListener(eventName, (e) => e.preventDefault()); + }); + + this.elements.fileDropZone.addEventListener('dragover', () => { + this.elements.fileDropZone.classList.add('active'); + }); + + // File drop logic + this.elements.fileDropZone.addEventListener('drop', (e) => { + this.elements.fileDropZone.classList.remove('active'); + const addedFiles = Array.from(e.dataTransfer.files); + this.handleFileAddition(addedFiles); + }); + + // File browsing logic + this.elements.fileInput.addEventListener('change', (e) => { + const addedFiles = Array.from(e.target.files); + this.handleFileAddition(addedFiles); + }); + }, + + /** + * Open the upload overlay + */ + openOverlay(e) { + e.preventDefault(); + this.elements.uploadFileOverlay.style.display = ''; + }, + + /** + * Close the upload overlay + */ + closeOverlay() { + this.elements.uploadFileOverlay.style.display = 'none'; + }, + + /** + * Handle file addition (drop or browse) + * @param {Array} newFiles - Array of new files + */ + async handleFileAddition(newFiles) { + const isProcessingSuccessful = this.processFiles(newFiles); + if (!isProcessingSuccessful) { + return; + } + + newFiles.forEach(file => StateManager.addFile(file)); + + // Upload files + newFiles.forEach(async (file) => { + file.state = 'uploading'; + this.renderFiles(); + const isUploadSuccessful = await ApiService.uploadFile(file); + file.state = isUploadSuccessful ? 'uploaded' : 'ready'; + this.renderFiles(); + }); + + this.renderFiles(); + }, + + /** + * Validate files before adding + * @param {Array} newFiles - Array of files to validate + * @returns {boolean} Whether files are valid + */ + processFiles(newFiles) { + // Check file types + const unallowedFiles = newFiles.filter((file) => + !this.constants.ALLOWED_TYPES.some(ext => file.name.endsWith(ext)) + ); + + if (unallowedFiles.length > 0) { + newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file)); + showSnackbar(translations[StateManager.currentLang]["error_file_format"], "error"); + return false; + } + + // Check individual file size + const largeFiles = newFiles.filter((file) => file.size > this.constants.FILE_SIZE_LIMIT); + if (largeFiles.length > 0) { + newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file)); + showSnackbar(translations[StateManager.currentLang]["error_file_size"], "error"); + return false; + } + + // Check total file size + const totalFileSize = [...newFiles, ...StateManager.getFiles()].reduce((sum, file) => sum + file.size, 0); + if (totalFileSize > this.constants.TOTAL_FILE_SIZE_LIMIT) { + newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file)); + showSnackbar(translations[StateManager.currentLang]["error_total_file_size"], "error"); + return false; + } + + // Check file name length + const filesWithLongName = newFiles.filter((file) => file.name.length > this.constants.MAX_FILE_NAME_LENGTH); + if (filesWithLongName.length > 0) { + newFiles.forEach((file) => Utils.removeFileFromInput(this.elements.fileInput, file)); + showSnackbar(translations[StateManager.currentLang]["error_file_name_length"], "error"); + return false; + } + + return true; + }, + + /** + * Render the file list + */ + renderFiles() { + this.elements.fileListHtml.innerHTML = ''; + const sessionFiles = StateManager.getFiles(); + + if (sessionFiles.length === 0) { + const noFileMessage = document.createElement('div'); + noFileMessage.classList.add('no-file'); + noFileMessage.dataset.i18n = "no_files"; + this.elements.fileListHtml.appendChild(noFileMessage); + TranslationService.applyTranslation(); + return; + } + + sessionFiles.forEach((f) => { + const fileItem = document.createElement('div'); + fileItem.classList.add('file-item'); + fileItem.textContent = f.name; + + const fileActions = document.createElement('div'); + fileActions.classList.add('file-actions'); + + const uploadButton = this.createUploadButton(f); + const deleteButton = this.createDeleteButton(f); + + fileActions.appendChild(uploadButton); + fileActions.appendChild(deleteButton); + fileItem.appendChild(fileActions); + this.elements.fileListHtml.appendChild(fileItem); + }); + + TranslationService.applyTranslation(); + }, + + /** + * Create upload button for a file + * @param {File} file - File object + * @returns {HTMLButtonElement} Upload button + */ + createUploadButton(file) { + const uploadButton = document.createElement('button'); + + if (file.state === 'uploaded') { + uploadButton.innerHTML = this.icons.check + ``; + uploadButton.classList.add('disabled-button'); + uploadButton.disabled = true; + } else if (file.state === 'uploading') { + uploadButton.innerHTML = this.icons.spinner + ``; + uploadButton.classList.add('disabled-button'); + uploadButton.disabled = true; + } else if (file.state === 'ready') { + uploadButton.innerHTML = this.icons.upload + ``; + uploadButton.classList.add('ok-button'); + uploadButton.addEventListener('click', async () => { + file.state = 'uploading'; + this.renderFiles(); + const isUploadSuccessful = await ApiService.uploadFile(file); + file.state = isUploadSuccessful ? 'uploaded' : 'ready'; + this.renderFiles(); + }); + } + + return uploadButton; + }, + + /** + * Create delete button for a file + * @param {File} file - File object + * @returns {HTMLButtonElement} Delete button + */ + createDeleteButton(file) { + const deleteButton = document.createElement('button'); + deleteButton.innerHTML = this.icons.trash + ``; + deleteButton.classList.add('no-button'); + deleteButton.addEventListener('click', async () => { + // No need to send a request to the server if the file was not uploaded + const isDeletionSuccessful = file.state === 'uploaded' + ? await ApiService.deleteFile(file) + : true; + + if (isDeletionSuccessful) { + Utils.removeFileFromInput(this.elements.fileInput, file); + StateManager.removeFile(file); + this.renderFiles(); + } + }); + + return deleteButton; + } +}; \ No newline at end of file diff --git a/static/components/language-component.js b/static/components/language-component.js new file mode 100644 index 0000000000000000000000000000000000000000..7d2c79c36c6121eb6de70e3c26533748fc7cb2dc --- /dev/null +++ b/static/components/language-component.js @@ -0,0 +1,58 @@ +// components/language-component.js - Language selection modal + +import { StateManager } from '../services/state-manager.js'; +import { TranslationService } from '../services/translation-service.js'; + +export const LanguageComponent = { + elements: { + frRadioBtn: null, + enRadioBtn: null, + continueLangBtn: null, + consentModal: null + }, + + /** + * Initialize the language component + */ + init() { + this.elements.frRadioBtn = document.getElementById('lang-fr'); + this.elements.enRadioBtn = document.getElementById('lang-en'); + this.elements.continueLangBtn = document.getElementById('lang-continue-btn'); + this.elements.consentModal = document.getElementById('consent-modal'); + + this.attachEventListeners(); + this.setInitialLanguage(); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.frRadioBtn.addEventListener('change', () => { + TranslationService.setLanguage(this.elements.frRadioBtn.value); + }); + + this.elements.enRadioBtn.addEventListener('change', () => { + TranslationService.setLanguage(this.elements.enRadioBtn.value); + }); + + this.elements.continueLangBtn.addEventListener('click', () => { + this.elements.consentModal.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + block: 'nearest' + }); + }); + }, + + /** + * Set initial language radio button state + */ + setInitialLanguage() { + if (StateManager.currentLang === 'en') { + this.elements.enRadioBtn.checked = true; + } else { + this.elements.frRadioBtn.checked = true; + } + } +}; \ No newline at end of file diff --git a/static/components/profile-component.js b/static/components/profile-component.js new file mode 100644 index 0000000000000000000000000000000000000000..32731b36d6d66dc881539ef3b526209b777bd538 --- /dev/null +++ b/static/components/profile-component.js @@ -0,0 +1,108 @@ +// components/profile-component.js - Profile modal functionality + +import { StateManager } from '../services/state-manager.js'; + +export const ProfileComponent = { + elements: { + profileModal: null, + profileBtn: null, + ageGroupInput: null, + genderInput: null, + roleInputs: null, + participantInput: null, + welcomePopup: null + }, + + /** + * Initialize the profile component + */ + init() { + this.elements.profileModal = document.getElementById('profile-modal'); + this.elements.profileBtn = document.getElementById('profileBtn'); + this.elements.ageGroupInput = document.getElementById('age-group'); + this.elements.genderInput = document.getElementById('gender'); + this.elements.roleInputs = document.querySelectorAll('input[name="role"]'); + this.elements.participantInput = document.getElementById('participant-id'); + this.elements.welcomePopup = document.getElementById('welcomePopup'); + + this.attachEventListeners(); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + // Add listeners to validate profile on input change + this.elements.genderInput.addEventListener('click', () => this.checkProfileValidity()); + this.elements.ageGroupInput.addEventListener('click', () => this.checkProfileValidity()); + this.elements.roleInputs.forEach(input => + input.addEventListener('change', () => this.checkProfileValidity()) + ); + this.elements.participantInput.addEventListener('input', () => this.checkParticipantIdInput()); + this.elements.participantInput.addEventListener('input', () => this.checkProfileValidity()); + + // Handle profile submission + this.elements.profileBtn.addEventListener('click', () => this.submitProfile()); + }, + + /** + * Check if profile form is valid and enable/disable button accordingly + */ + checkProfileValidity() { + // 1. Check if any gender is selected + const genderSelected = this.elements.genderInput.value !== ''; + + // 2. Check if any age group is selected + const ageSelected = this.elements.ageGroupInput.value !== ''; + + // 3. Check if at least one role checkbox is selected + const roleSelected = Array.from(this.elements.roleInputs).some(input => input.checked); + + // 4. Check if the participant id field has a value + const participantIdEntered = this.elements.participantInput.value.trim().length > 0; + + // 5. Enable button only if all are true + if (genderSelected && ageSelected && roleSelected && participantIdEntered) { + this.elements.profileBtn.disabled = false; + this.elements.profileBtn.classList.replace('disabled-button', 'ok-button'); + } else { + this.elements.profileBtn.disabled = true; + this.elements.profileBtn.classList.replace('ok-button', 'disabled-button'); + } + }, + + /** + * Submit profile and close welcome popup + */ + submitProfile() { + const profileData = { + ageGroup: this.elements.ageGroupInput.value, + gender: this.elements.genderInput.value, + roles: Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value), + participantId: this.elements.participantInput.value.trim() + }; + + StateManager.updateProfile(profileData); + + // Close welcome popup and re-enable scrolling + this.elements.welcomePopup.style.display = 'none'; + document.body.classList.remove('no-scroll'); + }, + + checkParticipantIdInput() { + const input = this.elements.participantInput; + // Save current cursor position + const start = input.selectionStart; + const end = input.selectionEnd; + + // Remove any character that is NOT a-z, A-Z, 0-9, _, or - + const newValue = input.value.replace(/[^-a-zA-Z0-9_]/g, ''); + + // Only update if something was actually removed + if (input.value !== newValue) { + input.value = newValue; + // Restore cursor position so it doesn't jump to the end + input.setSelectionRange(start - 1, end - 1); + } + } +}; \ No newline at end of file diff --git a/static/components/settings-component.js b/static/components/settings-component.js new file mode 100644 index 0000000000000000000000000000000000000000..24235136c202d90a868d939313c08fc9657e0451 --- /dev/null +++ b/static/components/settings-component.js @@ -0,0 +1,118 @@ +// components/settings-component.js - Settings modal functionality + +import { StateManager } from '../services/state-manager.js'; +import { TranslationService } from '../services/translation-service.js'; + +export const SettingsComponent = { + elements: { + settingsBtn: null, + settingsModal: null, + doneSettingsBtn: null, + closeSettingsBtn: null, + frRadioBtnSettings: null, + enRadioBtnSettings: null, + increaseFontSizeBtn: null, + decreaseFontSizeBtn: null, + resetFontSizeBtn: null + }, + + constants: { + MIN_FONT_SIZE: 0.75, + MAX_FONT_SIZE: 1.625, + FONT_SIZE_STEP: 0.125 // 1/8 rem for smooth increments + }, + + /** + * Initialize the settings component + */ + init() { + this.elements.settingsBtn = document.getElementById('settings-btn'); + this.elements.settingsModal = document.getElementById('settings-modal'); + this.elements.doneSettingsBtn = document.getElementById('done-settings'); + this.elements.closeSettingsBtn = document.getElementById('close-settings-btn'); + this.elements.frRadioBtnSettings = document.getElementById('lang-fr-settings'); + this.elements.enRadioBtnSettings = document.getElementById('lang-en-settings'); + this.elements.increaseFontSizeBtn = document.getElementById('increase-font-size-btn'); + this.elements.decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn'); + this.elements.resetFontSizeBtn = document.getElementById('reset-font-size-btn'); + + this.attachOutsideClickListener(); + this.attachEventListeners(); + this.updateLanguageRadioButtons(); + }, + + attachOutsideClickListener() { + this.elements.settingsModal.addEventListener('click', (e) => { + // Check if click is on the overlay itself (not its children) + if (e.target === this.elements.settingsModal) { + this.closeModal(); + } + }); + }, + + /** + * Attach event listeners + */ + attachEventListeners() { + this.elements.settingsBtn.addEventListener('click', (e) => this.openModal(e)); + this.elements.closeSettingsBtn.addEventListener('click', () => this.closeModal()); + this.elements.doneSettingsBtn.addEventListener('click', () => this.closeModal()); + + // Language change listeners + this.elements.frRadioBtnSettings.addEventListener('change', () => { + TranslationService.setLanguage(this.elements.frRadioBtnSettings.value); + this.updateLanguageRadioButtons(); + }); + this.elements.enRadioBtnSettings.addEventListener('change', () => { + TranslationService.setLanguage(this.elements.enRadioBtnSettings.value); + this.updateLanguageRadioButtons(); + }); + + // Font size listeners + this.elements.increaseFontSizeBtn.addEventListener('click', () => { + this.updateFontSize(StateManager.fontSize + this.constants.FONT_SIZE_STEP); + }); + this.elements.decreaseFontSizeBtn.addEventListener('click', () => { + this.updateFontSize(StateManager.fontSize - this.constants.FONT_SIZE_STEP); + }); + this.elements.resetFontSizeBtn.addEventListener('click', () => { + this.updateFontSize(1); // 1rem = browser default + }); + }, + + /** + * Open the settings modal + */ + openModal(e) { + e.preventDefault(); + this.elements.settingsModal.style.display = ''; + }, + + /** + * Close the settings modal + */ + closeModal() { + this.elements.settingsModal.style.display = 'none'; + }, + + /** + * Update font size + * @param {number} newSize - New font size in rem + */ + updateFontSize(newSize) { + const clampedSize = Math.min( + this.constants.MAX_FONT_SIZE, + Math.max(this.constants.MIN_FONT_SIZE, newSize) + ); + StateManager.setFontSize(clampedSize); + document.documentElement.style.fontSize = clampedSize + 'rem'; + }, + + /** + * Update language radio buttons to reflect current language + */ + updateLanguageRadioButtons() { + this.elements.frRadioBtnSettings.checked = StateManager.currentLang === 'fr'; + this.elements.enRadioBtnSettings.checked = StateManager.currentLang === 'en'; + } +}; \ No newline at end of file diff --git a/static/services/api-service.js b/static/services/api-service.js new file mode 100644 index 0000000000000000000000000000000000000000..fdac1ee8a5ac579885f8d49bef9d49da56142d36 --- /dev/null +++ b/static/services/api-service.js @@ -0,0 +1,201 @@ +// services/api-service.js - All API interactions + +import { Utils } from '../utils.js'; +import { StateManager } from './state-manager.js'; + +export const ApiService = { + /** + * Send a chat message to the server + * @param {string} text - User message text + * @param {string} modelType - Model type to use + * @returns {Promise} Response data + */ + async sendChatMessage(text, modelType) { + const payload = { + user_id: Utils.getMachineId(), + session_id: StateManager.sessionId, + conversation_id: StateManager.getConversationId(modelType), + human_message: text, + model_type: modelType, + consent: StateManager.consentGranted, + age_group: StateManager.profile.ageGroup, + gender: StateManager.profile.gender, + roles: StateManager.profile.roles, + participant_id: StateManager.profile.participantId, + lang: StateManager.currentLang + }; + + const res = await fetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + return res; + }, + + /** + * Upload a file to the server + * @param {File} file - File to upload + * @returns {Promise} Success status + */ + async uploadFile(file) { + const formData = new FormData(); + formData.append('file', file); + formData.append('session_id', StateManager.sessionId); + + try { + const res = await fetch('/file', { + method: 'PUT', + body: formData, + }); + + if (!res.ok) { + if (res.status === 413) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_file_too_large"], 'error'); + } else if (res.status === 400) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_malformed_file"], 'error'); + } else if (res.status === 415) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_unsupported_mime_type"], 'error'); + } else if (res.status === 419) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_exceed_session_size"], 'error'); + } else if (res.status === 500) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_server_error"], 'error'); + } else { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_unknown_error"], 'error'); + } + return false; + } + + showSnackbar(translations[StateManager.currentLang]["file_upload_success"], 'success'); + return true; + } catch (err) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_network_error"], 'error'); + return false; + } + }, + + /** + * Delete a file from the server + * @param {File} file - File to delete + * @returns {Promise} Success status + */ + async deleteFile(file) { + const payload = { + file_name: file.name, + user_id: Utils.getMachineId(), + session_id: StateManager.sessionId, + consent: StateManager.consentGranted, + age_group: StateManager.profile.ageGroup, + gender: StateManager.profile.gender, + roles: StateManager.profile.roles, + participant_id: StateManager.profile.participantId + }; + + try { + const res = await fetch('/file', { + method: 'DELETE', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + showSnackbar(translations[StateManager.currentLang]["file_upload_failed_server_error"], 'error'); + return false; + } + + showSnackbar(translations[StateManager.currentLang]["file_delete_success"], 'success'); + return true; + } catch (err) { + showSnackbar(translations[StateManager.currentLang]["file_delete_failed_network_error"], 'error'); + return false; + } + }, + + /** + * Send a comment to the server + * @param {string} comment - Comment text + * @returns {Promise} Response object with status + */ + async sendComment(comment) { + const payload = { + user_id: Utils.getMachineId(), + session_id: StateManager.sessionId, + comment, + consent: StateManager.consentGranted, + age_group: StateManager.profile.ageGroup, + gender: StateManager.profile.gender, + roles: StateManager.profile.roles, + participant_id: StateManager.profile.participantId + }; + + try { + const res = await fetch('/comment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + return { + success: false, + status: res.status + }; + } + + return { + success: true + }; + } catch (err) { + return { + success: false, + error: err + }; + } + }, + + /** + * Submit message feedback to the server + * @param {Object} feedbackData - Feedback data object + * @returns {Promise} Response object with status + */ + async submitFeedback(feedbackData) { + const payload = { + ...feedbackData, + consent: StateManager.consentGranted, + age_group: StateManager.profile.ageGroup, + gender: StateManager.profile.gender, + roles: StateManager.profile.roles, + participant_id: StateManager.profile.participantId, + lang: StateManager.currentLang + }; + + try { + const res = await fetch('/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + return { + success: false, + status: res.status + }; + } + + return { + success: true + }; + } catch (err) { + return { + success: false, + error: err + }; + } + } +}; \ No newline at end of file diff --git a/static/services/state-manager.js b/static/services/state-manager.js new file mode 100644 index 0000000000000000000000000000000000000000..32f738ebef33d2847291e2aa232d0deaff9aed90 --- /dev/null +++ b/static/services/state-manager.js @@ -0,0 +1,145 @@ +// services/state-manager.js - Central state management + +import { Utils } from '../utils.js'; + +export const StateManager = { + // Session data + sessionId: Utils.generateSessionId(), + + // User profile + profile: { + ageGroup: '', + gender: '', + roles: [], + participantId: '' + }, + + // Consent + consentGranted: false, + + // Language + currentLang: (() => { + const browserLang = navigator.language.split('-')[0]; + const defaultLang = ['en', 'fr'].includes(browserLang) ? browserLang : 'en'; + return localStorage.getItem('preferredLang') || defaultLang; + })(), + + // Chat data - stores messages and conversation IDs for each model + modelChats: { + "champ": { + messages: [], + conversation_id: Utils.generateConversationId() + }, + "openai": { + messages: [], + conversation_id: Utils.generateConversationId() + }, + "google-conservative": { + messages: [], + conversation_id: Utils.generateConversationId() + }, + "google-creative": { + messages: [], + conversation_id: Utils.generateConversationId() + } + }, + + // File upload state + sessionFiles: [], + + // Font size + fontSize: 1, // 1rem = browser default + + /** + * Update user profile + * @param {Object} profileData - Profile data object + */ + updateProfile(profileData) { + this.profile = { ...this.profile, ...profileData }; + }, + + /** + * Set consent status + * @param {boolean} granted - Whether consent is granted + */ + setConsent(granted) { + this.consentGranted = granted; + }, + + /** + * Set current language and save to localStorage + * @param {string} lang - Language code ('en' or 'fr') + */ + setLanguage(lang) { + this.currentLang = lang; + localStorage.setItem('preferredLang', lang); + }, + + /** + * Add a message to the current model's chat + * @param {string} modelType - The model type + * @param {Object} message - Message object with role and content + */ + addMessage(modelType, message) { + this.modelChats[modelType].messages.push(message); + }, + + /** + * Get messages for a specific model + * @param {string} modelType - The model type + * @returns {Array} Array of messages + */ + getMessages(modelType) { + return this.modelChats[modelType].messages; + }, + + /** + * Get conversation ID for a specific model + * @param {string} modelType - The model type + * @returns {string} Conversation ID + */ + getConversationId(modelType) { + return this.modelChats[modelType].conversation_id; + }, + + /** + * Clear conversation for a specific model + * @param {string} modelType - The model type + */ + clearConversation(modelType) { + this.modelChats[modelType].messages = []; + this.modelChats[modelType].conversation_id = Utils.generateConversationId(); + }, + + /** + * Add file to session + * @param {File} file - File object + */ + addFile(file) { + this.sessionFiles.push(file); + }, + + /** + * Remove file from session + * @param {File} file - File object to remove + */ + removeFile(file) { + this.sessionFiles = this.sessionFiles.filter(f => f !== file); + }, + + /** + * Get all session files + * @returns {Array} Array of files + */ + getFiles() { + return this.sessionFiles; + }, + + /** + * Set font size + * @param {number} size - Font size in rem + */ + setFontSize(size) { + this.fontSize = size; + } +}; \ No newline at end of file diff --git a/static/services/translation-service.js b/static/services/translation-service.js new file mode 100644 index 0000000000000000000000000000000000000000..d6186d62803d992f5840e5c2f2a8204d6d3d33f8 --- /dev/null +++ b/static/services/translation-service.js @@ -0,0 +1,48 @@ +// services/translation-service.js - Translation and i18n logic + +import { StateManager } from './state-manager.js'; + +export const TranslationService = { + /** + * Apply translations to all elements with data-i18n attribute + */ + applyTranslation() { + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + element.textContent = translations[StateManager.currentLang][key]; + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(element => { + const key = element.getAttribute('data-i18n-placeholder'); + element.placeholder = translations[StateManager.currentLang][key]; + }); + document.querySelectorAll('[data-i18n-title]').forEach(element => { + const key = element.getAttribute('data-i18n-title'); + element.title = translations[StateManager.currentLang][key]; + }); + }, + + /** + * Set the language and apply translations + * @param {string} lang - Language code ('en' or 'fr') + */ + setLanguage(lang) { + StateManager.setLanguage(lang); + this.applyTranslation(); + this.updateLanguageRadioButtons(); + }, + + /** + * Update all language radio buttons to reflect current language + */ + updateLanguageRadioButtons() { + const frRadioBtn = document.getElementById('lang-fr'); + const enRadioBtn = document.getElementById('lang-en'); + const frRadioBtnSettings = document.getElementById('lang-fr-settings'); + const enRadioBtnSettings = document.getElementById('lang-en-settings'); + + if (frRadioBtn) frRadioBtn.checked = StateManager.currentLang === 'fr'; + if (enRadioBtn) enRadioBtn.checked = StateManager.currentLang === 'en'; + if (frRadioBtnSettings) frRadioBtnSettings.checked = StateManager.currentLang === 'fr'; + if (enRadioBtnSettings) enRadioBtnSettings.checked = StateManager.currentLang === 'en'; + } +}; \ No newline at end of file diff --git a/static/styles/base.css b/static/styles/base.css new file mode 100644 index 0000000000000000000000000000000000000000..3cbb2b2215c1cfd56f02ec83bd16c7af20f28d99 --- /dev/null +++ b/static/styles/base.css @@ -0,0 +1,359 @@ +/* Dark theme page background */ +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', + sans-serif; + background: #0b1020; + color: #f5f5f5; +} + +body.no-scroll { + overflow: hidden; +} + +button { + font-size: 1rem; + user-select: none; +} + +a { + color: #4da6ff; +} + +label, select, legend { + user-select: none; +} + +.unselectable { + user-select: none; +} + + +/* SVG ICONS */ +svg { + width: 16px; + height: 16px; +} + +/* Spinning animation for the uploading button */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.spinning { + animation: spin 1s linear infinite; +} + +/* Generic buttons */ +.ok-button { + padding: 8px 18px; + border-radius: 10px; + border: none; + background: #4c6fff; + color: white; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + max-width: 200px; +} + +.ok-button:hover { + background: #3453e6; +} + +.no-button { + padding: 8px 18px; + border-radius: 10px; + border: none; + background: #dc2626; + color: white; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + max-width: 200px; +} + +.no-button:hover { + background-color: #b91c1c; +} + +.disabled-button { + padding: 8px 18px; + border-radius: 10px; + border: none; + font-weight: 600; + background-color: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; + max-width: 200px; +} + +.cancelBtn { + background: #0d132475; + color: #9ca3af; + border: 1px solid #2c3554; + padding: 8px 18px; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + max-width: 200px; +} + +.cancelBtn:hover { + background: #0d1324; + color: #f5f5f5; + border-color: #4a5f8f; +} + +.center-button { + text-align: center; +} + +/* Modals */ +.modal { + /* Covers the entier view port */ + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + + /* Center the content of the modal */ + display: flex; + align-items: center; + justify-content: center; + + /* Put the modal in front */ + z-index: 1; + + /* Mask what is behind the modal */ + background-color: rgba(0, 0, 0, 0.8); +} + +.modal h2 { + margin-top: 0; + margin-bottom: 0; +} + +/* Dark theme overlay box */ +.modal-content { + /* Snap this slide to the left edge of the slider */ + scroll-snap-align: start; + + /* Center the content of the modal */ + display: flex; + justify-content: flex-start; + + /* Looks */ + background: #141b2f; /* CHANGED: match theme */ + color: #f5f5f5; /* NEW: readable on dark bg */ + padding: 24px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + box-sizing: border-box; + margin: 0 auto; + + /* Enable scrolling */ + overflow-y: auto; +} + +/* Modals with slider animation */ +.slider { + /* Smooth scrolling */ + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + + /* Clip slides that are off-screen */ + overflow-x: hidden; + + /* Constrain the slider so children can scroll */ + max-height: 90dvh; + + /* Place the elements next to the others horizontally*/ + display: flex; +} + +.slide { + /* Each slide fills the full width of the slider */ + min-width: 100%; +} + +.modal-content.slide { + max-width: 400px; +} + +.language-modal, +.consent-box, +.profile, +.feedback-modal { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.language-modal, +.consent-box { + max-height: 350px; +} + +/* Checkbox & Radio Groups */ +.form-group { + margin-bottom: 1.5rem; +} + +label, .group-label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.radio-group { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); /* Creates two equal columns */ + gap: 12px; + margin-top: 8px; +} + +.checkbox-grid-lang { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 12px; + margin-top: 8px; +} + +.checkbox-grid-lang label, +.checkbox-grid label { + font-weight: 400; + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border: 1px solid #eee; /* Light border makes it look like a contained element */ + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; +} + +.checkbox-grid-lang label:hover, +.checkbox-grid label:hover { + background-color: #ffffff; /* The white background you wanted */ + color: #111111; /* Forces the text to be dark/visible */ + border-color: #007bff; /* Optional: adds a blue border to show it's active */ + box-shadow: 0 2px 8px rgba(0,0,0,0.1); /* Optional: adds a soft depth */ +} + +.radio-group label, .checkbox-grid label, .checkbox-grid-lang label { + font-weight: 400; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +/* Modern Inputs */ +input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #007bff; /* Modern way to color native inputs */ +} + +select, input[type="text"] { + width: 100%; + box-sizing: border-box; + padding: 12px 6px 12px 6px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} +select:focus, input[type="text"]:focus { + border-color: #007bff; +} + +/* Close button (X) */ +.closeBtn { + position: absolute; + top: 15px; + right: 15px; + width: 28px; /* Explicit small width */ + height: 28px; /* Explicit small height */ + padding: 0; + background: transparent; + color: #6b7280; + border: none; + border-radius: 4px; + font-size: 20px; + line-height: 28px; /* Center the × vertically */ + text-align: center; /* Center the × horizontally */ + cursor: pointer; + transition: all 0.2s ease; +} + + +/* RESPONSIVE DESIGN */ +@media (max-width: 460px) { + /* Hide the text descriptions of the file action buttons */ + .file-actions button span { + display: none; + } + + /* Enlarge the chat container on mobile */ + .chat-container { + margin: 0; + width: 100dvw; + height: 100dvh; + } + + /* Reduce the font size of the title on mobile */ + /* Also, add a gap between the title and the details */ + .chat-header h1 { + margin: 0 0 10px 0; + font-size: 1.4rem; + } + + /* Increase the size of the modals on mobile */ + .modal-content { + width: 90%; + } +} + +@media (max-height: 720px) { + /* Enlarge the chat container on small screens */ + .chat-container { + margin: 0; + width: 100dvw; + height: 100dvh; + } + + /* Reduce the font size of the title */ + .chat-header h1 { + font-size: 1.4rem; + } + + /* Increase the size of the modals */ + .modal-content { + width: 90%; + } +} + +@media (min-width: 460px) { + details { + display: block; + } + details[open] { + display: block; + } + details summary { + display: none; + } +} \ No newline at end of file diff --git a/static/styles/components/chat.css b/static/styles/components/chat.css new file mode 100644 index 0000000000000000000000000000000000000000..97a4e6535b310e3a56763212cc7b0e9165fb08f0 --- /dev/null +++ b/static/styles/components/chat.css @@ -0,0 +1,133 @@ +.chat-container { + width: 90dvw; + height: 90dvh; + margin: 5dvh auto; + background: #141b2f; + border-radius: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: 16px; +} + +.chat-header { + padding: 0px 4px 12px; + border-bottom: 1px solid #2c3554; +} + +.chat-header h1 { + margin: 0; + font-size: 1.8rem; +} + +.chat-header .subtitle { + margin: 4px 0 0; + color: #c0c6e0; + font-size: 0.95rem; +} + +/* Chat window */ +.chat-window { + flex: 1; + margin-top: 10px; + padding: 10px; + overflow-y: auto; + background: #0d1324; + border-radius: 12px; +} + +/* Message bubbles */ +.msg-bubble { + max-width: 75%; + padding: 8px 12px; + margin-bottom: 8px; + border-radius: 12px; + font-size: 0.95rem; + line-height: 1.4; +} + +.msg-bubble.user { + margin-left: auto; + background: #4c6fff; + color: #ffffff; + border-bottom-right-radius: 4px; +} + +.msg-bubble.assistant { + margin-right: auto; + background: #1f2840; + color: #f5f5f5; + border-bottom-left-radius: 4px; +} + +/* Input area */ +.chat-input-area { + display: flex; + gap: 8px; + margin-top: 12px; + border-top: 1px solid #2c3554; + padding-top: 8px; +} + +.chat-input-container { + flex: 1; + border-radius: 10px; + border: 1px solid #2c3554; + background: #0d1324; + padding: 8px; + resize: none; +} + +.chat-input-area textarea { + background: transparent; + border: none; + resize: none; + outline: none; + color: #f5f5f5; + font-size: 0.95rem; + width: 100%; +} + +.chat-toolbar { + display: flex; + justify-content: space-between; +} + + +/* Chat toolbar */ +.toolbar-btn { + background: transparent; + border: none; + resize: none; + outline: none; + color: #f5f5f5; + cursor: pointer; + transition: background 0.2s; +} + +.toolbar-btn { + /* background-color: rgba(255, 255, 255, 0.1); */ + margin-left: auto; +} + +/* Status and comment text */ +.status-comment { + margin-top: 6px; + font-size: 0.85rem; + + display: flex; + justify-content: space-between; +} + +.status-info { + color: #ffce56; +} + +.status-ok { + color: #8be48b; +} + +.status-error { + color: #ff8080; +} diff --git a/static/styles/components/comment.css b/static/styles/components/comment.css new file mode 100644 index 0000000000000000000000000000000000000000..d4583f51dd5604459e4224be6dc1ddb2a25289f1 --- /dev/null +++ b/static/styles/components/comment.css @@ -0,0 +1,50 @@ +/* Comment area */ +.comment-area { + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + background: #141b2f; + padding: 24px; + border-radius: 15px; + border: 1px solid #2c3554; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + width: 90%; +} + +.comment-area h2 { + margin: 0 0 8px 0; + font-size: 1.5rem; + color: #f5f5f5; + font-weight: 600; +} + +.comment-area textarea { + /* max-width: 425px; */ + min-height: 120px; + border-radius: 10px; + border: 1px solid #2c3554; + background: #0d1324; + color: #f5f5f5; + padding: 12px; + resize: vertical; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s ease; +} + +.comment-area textarea:focus { + outline: none; + border-color: #4a5f8f; +} + +.comment-area textarea::placeholder { + color: #6b7280; +} + +/* Button container */ +.comment-area .button-group { + display: flex; + gap: 12px; + margin: auto; +} diff --git a/static/styles/components/consent.css b/static/styles/components/consent.css new file mode 100644 index 0000000000000000000000000000000000000000..3bb57ec5ee95320cdea84202b8cbfea0b4e0bfa7 --- /dev/null +++ b/static/styles/components/consent.css @@ -0,0 +1,6 @@ +.consent-check { + display: flex; + align-items: center; + margin: 16px 0; + gap: 10px; +} diff --git a/static/styles/components/feedback.css b/static/styles/components/feedback.css new file mode 100644 index 0000000000000000000000000000000000000000..e8bdbff2a81636325c4e94208bdede8a20ee2508 --- /dev/null +++ b/static/styles/components/feedback.css @@ -0,0 +1,198 @@ +/* styles/components/feedback.css - Message feedback buttons and modal */ + +/* Message container to hold bubble + feedback buttons */ +.message-container { + display: flex; + flex-direction: column; + margin-bottom: 8px; +} + +.message-container .msg-bubble { + margin-bottom: 4px; +} + +/* Feedback buttons */ +.feedback-buttons { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease; + margin-left: 8px; + align-self: flex-start; +} + +/* Show feedback buttons on hover of the message container */ +.message-container:hover .feedback-buttons { + opacity: 1; +} + +/* Always show if a button is active (rated) */ +.feedback-buttons:has(.feedback-btn.active) { + opacity: 1; +} + +.feedback-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 4px 8px; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + color: #c0c6e0; + width: 20px; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: content-box; +} + +.feedback-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: scale(1.1); +} + +.feedback-btn.active { + background: rgba(76, 111, 255, 0.2); + border-color: #4c6fff; + color: #4c6fff; +} + +/* Copy button */ +.copy-btn.copied { + background: rgba(40, 167, 69, 0.2); + border-color: #28a745; + color: #28a745; +} + +.copy-btn.copied:hover { + transform: scale(1); + cursor: default; +} + +/* Feedback modal */ +.feedback-modal { + position: relative; + width: 90%; +} + +.feedback-modal h2 { + margin-bottom: 1rem; + color: #f5f5f5; + font-size: 1.3rem; +} + +.feedback-message-preview { + background: rgba(13, 19, 36, 0.5); + border: 1px solid #2c3554; + border-radius: 8px; + margin-bottom: 1rem; + padding: 8px; + margin-top: 16px; +} + +.feedback-label { + font-size: 0.85rem; + color: #c0c6e0; + margin-top: 0; + margin-bottom: 6px; + font-weight: 500; +} + +.message-preview-text { + font-size: 0.9rem; + color: #f5f5f5; + line-height: 1.4; + max-height: 100px; + overflow-y: auto; +} + +.feedback-modal .form-group { + margin-bottom: 1rem; +} + +.feedback-modal label { + display: block; + margin-bottom: 8px; + color: #c0c6e0; + font-weight: 500; +} + +.feedback-modal textarea { + width: 100%; + background: #0d1324; + border: 1px solid #2c3554; + border-radius: 8px; + padding: 10px; + color: #f5f5f5; + font-family: inherit; + font-size: 0.95rem; + resize: vertical; + min-height: 80px; + box-sizing: border-box; +} + +.feedback-modal textarea:focus { + outline: none; + border-color: #4c6fff; +} + +.form-hint { + display: block; + margin-top: 6px; + font-size: 0.8rem; + color: #c0c6e0; + font-style: italic; +} + +.feedback-modal .button-group { + display: flex; + gap: 12px; + margin: auto; +} + +.feedback-modal .ok-button, +.feedback-modal .cancelBtn { + padding: 10px 20px; + border-radius: 8px; + font-weight: 500; +} + +.feedback-modal .ok-button { + background: #4c6fff; + color: white; + border: none; +} + +.feedback-modal .ok-button:hover { + background: #3d5ae6; +} + +.feedback-modal .cancelBtn { + background: transparent; + border: 1px solid #2c3554; + color: #c0c6e0; +} + +.feedback-modal .cancelBtn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Responsive */ +@media (max-width: 768px) { + .feedback-modal { + min-width: unset; + width: 95%; + } + + .feedback-buttons { + opacity: 1; /* Always show on mobile */ + } + + .feedback-modal .button-group button { + width: 100%; + } +} \ No newline at end of file diff --git a/static/styles/components/file-upload.css b/static/styles/components/file-upload.css new file mode 100644 index 0000000000000000000000000000000000000000..61a199e8949cbf72e8be2285395be98c72c5c10f --- /dev/null +++ b/static/styles/components/file-upload.css @@ -0,0 +1,68 @@ +/* File upload modal */ +.file-drop-area { +/* 1. Dimensions */ + min-height: 150px; + + /* 2. Layout */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + /* 3. Appearance */ + border: 2px dashed #444; /* Dashed line makes it look like a 'slot' */ + border-radius: 12px; + background-color: #111; /* Slightly lighter than your black background */ + color: #888; + cursor: pointer; + + /* 4. Spacing */ + margin-top: 20px; + padding: 20px; + + /* 5. Text */ + text-align: center; +} + +.file-drop-area.active { + border-color: #4285f4; + background-color: rgba(66, 133, 244, 0.05); + color: white; +} + +.upload-file-area { + position: relative; + max-height: 90dvh; + display: flex; + flex-direction: column; +} + +/* File list */ +.file-list { + background-color: #111; + border: 1px solid black; + border-radius: 8px; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; +} + +.no-file { + display: flex; + justify-content: center; + padding: 16px; +} + +.file-actions { + display: flex; + gap: 8px; +} + +.file-actions button { + display: flex; + gap: 6px; +} diff --git a/static/styles/components/settings.css b/static/styles/components/settings.css new file mode 100644 index 0000000000000000000000000000000000000000..20adbc9f14d7bf882962a85fbc8c15a267dffa52 --- /dev/null +++ b/static/styles/components/settings.css @@ -0,0 +1,58 @@ +/* Settings modal */ +.settings-button { + align-self: center; + padding: 12px 12px; + border-radius: 8px; + border: 1px solid #2c3554; + background: #1f2840; + color: #f5f5f5; + font-size: 0.85rem; + cursor: pointer; + margin-left: auto; +} + +.settings-button:hover { + background: #273256; +} + +.settings-modal-content { + width: 480px; + max-width: 95%; + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +/* Font size */ +.font-size-container { + display: flex; + gap: 12px; + margin-top: 8px; + margin-bottom: 8px; + justify-content: center; +} + +.font-size-btn { + padding: 12px 20px; + border-radius: 8px; + border: 1px solid #2c3554; + background: #1f2840; + color: #f5f5f5; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + min-width: 80px; +} + +.font-size-btn:hover { + background: #273256; + border-color: #3d4a6e; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.font-size-btn:active { + transform: translateY(0); +} diff --git a/static/styles/control-bar.css b/static/styles/control-bar.css new file mode 100644 index 0000000000000000000000000000000000000000..7e49d5087468c56564aaa8d1d3aba85630144d87 --- /dev/null +++ b/static/styles/control-bar.css @@ -0,0 +1,38 @@ +/* Controls bar */ +.controls-bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 8px 4px; + border-bottom: 1px solid #2c3554; +} + +.control-group { + display: flex; + align-items: center; + gap: 8px; +} + +.control-group select { + background: #0d1324; + border-radius: 8px; + border: 1px solid #2c3554; + color: #f5f5f5; + padding: 4px 8px; + font-size: 0.85rem; +} + +.clear-button { + align-self: center; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid #2c3554; + background: #dc2626ba; + color: #f5f5f5; + font-size: 0.85rem; + cursor: pointer; +} + +.clear-button:hover { + background: #dc2626; +} diff --git a/static/styles/snackbar.css b/static/styles/snackbar.css new file mode 100644 index 0000000000000000000000000000000000000000..80027776d4b69c12f239874002da9fce05a7eeec --- /dev/null +++ b/static/styles/snackbar.css @@ -0,0 +1,51 @@ +.snackbar { + position: fixed; + top: 20px; + right: 20px; + padding: 16px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + transform: translateX(400px); + transition: all 0.3s ease; + z-index: 9999; + max-width: 350px; + word-wrap: break-word; +} + +.snackbar.show { + opacity: 1; + transform: translateX(0); +} + +.snackbar-success { + background: #10b981; +} + +.snackbar-error { + background: #ef4444; +} + +.snackbar-info { + background: #3b82f6; +} + +.snackbar-warning { + background: #f59e0b; +} + +/* Stack multiple snackbars */ +.snackbar:nth-child(n+2) { + top: calc(20px + (70px * var(--index, 0))); +} + +@media (max-width: 460px) { + .snackbar { + left: 20px; + right: 20px; + max-width: none; + } +} \ No newline at end of file diff --git a/static/translations.js b/static/translations.js index 0666b9067f5136ed47a70d2534a50f6e9cb26963..1f1ddee8c4d9384035b0cc34481a4c1c93030e40 100644 --- a/static/translations.js +++ b/static/translations.js @@ -1,7 +1,7 @@ const translations = { en: { header: "CHAMP Model Comparison", - sub_header: "Talk to and compare chatbots powered by different models. Please remember to avoid sharing any sensitive or private details during the conversation.", + sub_header: "Talk to different models and compare their reponses. Please remember to avoid sharing any sensitive or private details during the conversation.", user_guide_label: "User guide:", user_guide_link: "CHAMP Model Comparison – Participant Testing Guide", @@ -14,7 +14,9 @@ const translations = { conversation_cleared: "Conversation cleared. Start a new chat!", choose_language_title: "Choose your language", - change_language_instructions: "You can change the language at any time using the options in the toolbar, or in the top right corner when a dialog is open.", + change_language_instructions: "You can change the language at any time in the Settings menu located in the toolbar.", + change_language: "Change language", + change_font_size: "Change font size", consent_title: "Before you continue", consent_desc: "By using this demo you agree that your messages will be shared with us for processing. Do not provide sensitive or private details.", @@ -22,7 +24,7 @@ const translations = { btn_agree_continue: "Agree and Continue", profile_title: "Profile", - profile_desc: "We collect this information to help us understand how different groups of users interact with the system. This data allows us to improve the system and ensure the tool is effective for everyone.", + profile_desc: "We collect this information to help us understand how different groups of users interact with the system.", select_option: "(Please select an option)", label_age: "Age group", label_gender: "Gender", @@ -42,13 +44,13 @@ const translations = { link_comment: "Leave a comment", comment_title: "Leave a comment", - comment_placeholder: "Type your comment and click Send...", + comment_placeholder: "Type your comment and press Enter or click Send...", comment_sent: "Comment sent!", file_title: "Add a file", file_inactivity: "Uploaded files are automatically deleted after 4 hours of inactivity.", - file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max 10MB).", - file_size_limit: "The total size of all uploaded files cannot exceed 30MB.", + file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max 10 MB).", + file_size_limit: "The total size of all uploaded files cannot exceed 30 MB.", error_file_format: "Please upload a picture or a document in PDF, TXT, or DOCX format. Other file types are not supported.", error_file_size: "File size exceeds limit. Maximum allowed: 10MB.", error_total_file_size: "The total size of the files would exceed the maximum limit of 30 MB. Please free up space by deleting files.", @@ -64,14 +66,42 @@ const translations = { file_add_instructions_suffix: " to browse", click: "Click", - file_upload_failed_server_error: "File upload was unsuccessful due to a server error.", - file_upload_failed_network_error: "File upload was unsuccessful due to a network error.", + settings_title: "Settings", + + file_upload_failed_server_error: "File upload failed: server error.", + file_upload_failed_file_too_large: "File upload failed: size exceeds 10 MB limit", + file_upload_failed_malformed_file: "File upload failed: file invalid", + file_upload_failed_unsupported_mime_type: "File upload failed: file must be in PDF, TXT, DOCX, JPEG or PNG format", + file_upload_failed_exceed_session_size: "File upload failed: the total size of all uploaded files exceeds 30 MB", + file_upload_failed_network_error: "File upload failed: network error", + file_upload_failed_unknown_error: "File upload failed: unknown error", file_upload_success: "File upload successful!", - file_delete_failed_server_error: "File deletion was unsuccessful due to a server error.", - file_delete_failed_network_error: "File deletion was unsuccessful due to a network error.", + file_delete_failed_server_error: "File deletion failed: server error", + file_delete_failed_network_error: "File deletion failed: network error", file_delete_success: "File deletion successful!", + copy_reply_btn: "Copy the message to clipboard", + feedback_like_btn: "Give positive feedback", + feedback_dislike_btn: "Give negative feedback", + feedback_mixed_btn: "Give mixed feedback", + + feedback_like_title: "You liked this response", + feedback_dislike_title: "You disliked this response", + feedback_neutral_title: "You think this response could be improved", + + feedback_for_message: "Message:", + feedback_comment_label: "Tell us why (optional)", + feedback_comment_placeholder: "Type your comment and press Enter or click Send...", + feedback_optional: "You can submit without a comment", + + message_copied: "Message copied to clipboard!", + feedback_submitted: "Feedback submitted successfully!", + feedback_failed_server_error: "Feedback submission failed: server error", + feedback_failed_network_error: "Feedback submission failed: network error", + + settings_btn: "Settings", + done_btn: "Done", ready: "Ready", @@ -79,18 +109,20 @@ const translations = { model_changed: "Model changed", sending: "Sending...", no_reply: "(No reply)", + empty_message_error: "Message cannot be empty", server_error: "Error from server", network_error: "Network error", btn_send: "Send", + btn_submit: "Submit", btn_cancel: "Cancel", show_more: "About this demo", }, fr: { header: "Comparaison de Modèles CHAMP", - sub_header: "Discutez avec des chatbots propulsés par différents modèles et comparez-les. Veillez à ne partager aucune information sensible ou privée durant la conversation.", + sub_header: "Discutez avec différents modèles et comparez leurs réponses. Veillez à ne partager aucune information sensible ou privée durant la conversation.", user_guide_label: "Guide de l'utilisateur :", user_guide_link: "Comparaison de Modèles CHAMP – Guide de test du participant", @@ -99,10 +131,12 @@ const translations = { gemini_conservative: "Gemini-3 (Prudent)", gemini_creative: "Gemini-3 (Créatif)", btn_clear: "Réinitialiser", - conversation_cleared: "Conversation réinitialisée. Commencer une nouvelle conversation !", + conversation_cleared: "Conversation réinitialisée.", choose_language_title: "Choisissez votre langue", - change_language_instructions: "Vous pouvez changer la langue à tout moment grâce aux options dans la barre d'outils, ou en haut à droite lorsqu'une fenêtre est ouverte.", + change_language_instructions: "Vous pouvez changer la langue à tout moment dans le menu Paramètres situé dans la barre d'outils.", + change_language: "Changer la langue", + change_font_size: "Modifier la taille de la police", consent_title: "Avant de poursuivre", consent_desc: "En interagissant avec cette démo, vous acceptez que vos messages soient partagés avec nous à des fins de traitement. Veillez à ne partager aucune information sensible ou privée.", @@ -110,7 +144,7 @@ const translations = { btn_agree_continue: "Accepter et continuer", profile_title: "Profil", - profile_desc: "Nous collectons ces informations pour nous aider à comprendre comment différents groupes d'utilisateurs interagissent avec le système. Ces données nous permettent d'améliorer le système et d'assurer que cet outil est efficace pour tout le monde.", + profile_desc: "Nous collectons ces informations pour nous aider à comprendre comment différents groupes d'utilisateurs interagissent avec le système.", select_option: "(Veuillez sélectionner une option)", label_age: "Tranche d'âge", label_gender: "Genre", @@ -152,14 +186,41 @@ const translations = { file_add_instructions_suffix: " pour parcourir", click: "Cliquez", - file_upload_failed_server_error: "Le téléversement du fichier a échoué en raison d'une erreur du serveur.", - file_upload_failed_network_error: "Le téléversement du fichier a échoué en raison d'une erreur réseau.", + settings_title: "Paramètres", + + file_upload_failed_server_error: "Échec du téléversement: erreur du serveur.", + file_upload_failed_file_too_large: "Échec du téléversement: la taille du fichier dépasse la limite de 10 Mo", + file_upload_failed_malformed_file: "Échec du téléversement: le fichier est invalide", + file_upload_failed_unsupported_mime_type: "Échec du téléversement: le fichier doit être en format PDF, TXT, DOCX, PNG ou JPEG", + file_upload_failed_exceed_session_size: "Échec du téléversement: la taille totale des fichiers téléversés dépassent 30 Mo", + file_upload_failed_network_error: "Échec du téléversement: erreur réseau", + file_upload_failed_unknown_error: "Échec du téléversement: erreur inconnue", file_upload_success: "Téléversement du fichier réussi !", - file_delete_failed_server_error: "La suppression du fichier a échoué en raison d'une erreur du serveur.", - file_delete_failed_network_error: "La suppression du fichier a échoué en raison d'une erreur réseau.", + file_delete_failed_server_error: "Échec de la suppression: erreur du serveur", + file_delete_failed_network_error: "Échec de la suppression: erreur réseau", file_delete_success: "Suppression du fichier réussie !", + copy_reply_btn: "Copier le message dans le presse-papiers", + feedback_like_btn: "Donner un retour positif", + feedback_dislike_btn: "Donner un retour négatif", + feedback_mixed_btn: "Donner un retour mixte", + + feedback_like_title: "Vous aimez cette réponse", + feedback_dislike_title: "Vous n'aimez pas cette réponse", + feedback_neutral_title: "Vous pensez que cette réponse peut être améliorée", + feedback_for_message: "Message :", + feedback_comment_label: "Dites-nous pourquoi (facultatif)", + feedback_comment_placeholder: "Tapez votre commentaire et appuyez sur Entrée ou cliquez sur Envoyer...", + feedback_optional: "Vous pouvez soumettre sans commentaire", + + message_copied: "Message copié dans le presse-papiers !", + feedback_submitted: "Retour envoyé avec succès !", + feedback_failed_server_error: "Échec de l'envoi du retour: erreur du serveur", + feedback_failed_network_error: "Échec de l'envoi du retour: erreur réseau", + + settings_btn: "Paramètres", + done_btn: "Terminer", ready: "Prêt", @@ -167,11 +228,13 @@ const translations = { model_changed: "Changement de modèle", sending: "Envoi...", no_reply: "(Aucune réponse)", + empty_message_error: "Le message ne peut pas être vide.", server_error: "Erreur du serveur", network_error: "Erreur réseau", btn_send: "Envoyer", + btn_submit: "Soumettre", btn_cancel: "Annuler", show_more: "À propos de cette démo", diff --git a/static/utils.js b/static/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..11bf450d4ab70941bdbe90cda4e191623e1825a3 --- /dev/null +++ b/static/utils.js @@ -0,0 +1,56 @@ +// utils.js - Utility functions + +export const Utils = { + /** + * Get or create a unique machine ID stored in localStorage + * @returns {string} Machine ID + */ + getMachineId() { + let machineId = localStorage.getItem('MachineId'); + + if (!machineId) { + machineId = 'dev-' + crypto.randomUUID(); + localStorage.setItem('MachineId', machineId); + } + + return machineId; + }, + + /** + * Generate a unique session ID + * @returns {string} Session ID + */ + generateSessionId() { + return 'session-' + crypto.randomUUID(); + }, + + /** + * Generate a unique conversation ID + * @returns {string} Conversation ID + */ + generateConversationId() { + return 'conversation-' + crypto.randomUUID(); + }, + + /** + * Remove a file from a file input element + * @param {HTMLInputElement} fileInput - The file input element + * @param {File} fileToRemove - The file to remove + */ + removeFileFromInput(fileInput, fileToRemove) { + // File inputs are read-only. We have to update them + // by assigning a new value instead of filtering out + // directly files we do not want anymore. + const dt = new DataTransfer(); + const { files } = fileInput; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file !== fileToRemove) { + dt.items.add(file); + } + } + + fileInput.files = dt.files; + } +}; \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 1bf4db2cd1bc260bb873d4b297cb01354cd887cc..f142b711330fe5c7eb89354a152ee612282d1f06 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,12 +7,18 @@ CHAMP Chatbot Demo - - - - - - + + + + + + + + + + + +
@@ -30,8 +36,8 @@
-
- +
+ -
+ + + + +
- + +
- +
@@ -144,6 +173,36 @@
+ + +
@@ -151,10 +210,11 @@
- +
@@ -164,37 +224,42 @@
- +