qyle commited on
Commit
389d6f7
·
verified ·
1 Parent(s): f80f41e

deployment

Browse files
README.md CHANGED
@@ -81,6 +81,11 @@ To run the tests, simply execute `pytest` at the project root. Make sure your vi
81
  pip install -r requirements-dev.txt
82
  ```
83
 
 
 
 
 
 
84
  ## Load testing
85
  [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 <filename>.js`.
86
 
 
81
  pip install -r requirements-dev.txt
82
  ```
83
 
84
+ Some tests are marked as `resource_intensive`. They take longer to run and might consume significant memory or CPU. To run them:
85
+ ```bash
86
+ pytest -m resource_intensive
87
+ ```
88
+
89
  ## Load testing
90
  [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 <filename>.js`.
91
 
champ/service.py CHANGED
@@ -1,5 +1,6 @@
1
  # app/champ/service.py
2
 
 
3
  from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
4
 
5
  from langchain_community.vectorstores import FAISS as LCFAISS
@@ -8,6 +9,8 @@ from langchain_core.messages import HumanMessage
8
  from .agent import build_champ_agent
9
  from .triage import safety_triage
10
 
 
 
11
 
12
  class ChampService:
13
  vector_store: Optional[LCFAISS] = None
@@ -33,6 +36,7 @@ class ChampService:
33
  Tuple[str, Dict[str, Any], List[str]]: The replay, the triage_triggered object and the retrieved passages
34
  """
35
  if self.agent is None:
 
36
  raise RuntimeError("CHAMP is not initialized yet.")
37
  # --- Safety triage micro-layer (before LLM) ---
38
  last_user_text = None
 
1
  # app/champ/service.py
2
 
3
+ import logging
4
  from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
5
 
6
  from langchain_community.vectorstores import FAISS as LCFAISS
 
9
  from .agent import build_champ_agent
10
  from .triage import safety_triage
11
 
12
+ logger = logging.getLogger("uvicorn")
13
+
14
 
15
  class ChampService:
16
  vector_store: Optional[LCFAISS] = None
 
36
  Tuple[str, Dict[str, Any], List[str]]: The replay, the triage_triggered object and the retrieved passages
37
  """
38
  if self.agent is None:
39
+ logger.error("CHAMP invoked before initialization")
40
  raise RuntimeError("CHAMP is not initialized yet.")
41
  # --- Safety triage micro-layer (before LLM) ---
42
  last_user_text = None
classes/base_models.py CHANGED
@@ -7,7 +7,7 @@ from constants import (
7
  MAX_MESSAGE_LENGTH,
8
  )
9
  from pydantic import BaseModel, Field, field_validator
10
- from typing import List, Literal, Set
11
 
12
 
13
  class IdentifierBase(BaseModel):
 
7
  MAX_MESSAGE_LENGTH,
8
  )
9
  from pydantic import BaseModel, Field, field_validator
10
+ from typing import Literal, Set
11
 
12
 
13
  class IdentifierBase(BaseModel):
classes/ocr_reader.py CHANGED
@@ -1,8 +1,11 @@
 
1
  from typing import Optional
2
  import cv2
3
  import easyocr
4
  import torch
5
 
 
 
6
 
7
  class OCRReader:
8
  _instance: Optional["OCRReader"] = None
@@ -10,6 +13,7 @@ class OCRReader:
10
 
11
  def __new__(cls):
12
  if cls._instance is None:
 
13
  cls._instance = super(OCRReader, cls).__new__(cls)
14
  cls._instance.ocr_reader = easyocr.Reader(
15
  ["en", "fr"], gpu=torch.cuda.is_available()
 
1
+ import logging
2
  from typing import Optional
3
  import cv2
4
  import easyocr
5
  import torch
6
 
7
+ logger = logging.getLogger("uvicorn")
8
+
9
 
10
  class OCRReader:
11
  _instance: Optional["OCRReader"] = None
 
13
 
14
  def __new__(cls):
15
  if cls._instance is None:
16
+ logger.info("Loading the OCR model into memory...")
17
  cls._instance = super(OCRReader, cls).__new__(cls)
18
  cls._instance.ocr_reader = easyocr.Reader(
19
  ["en", "fr"], gpu=torch.cuda.is_available()
classes/pii_filter.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from typing import List, Optional
2
  from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
3
  from presidio_analyzer.nlp_engine import NlpEngineProvider
@@ -5,6 +6,7 @@ from presidio_anonymizer import AnonymizerEngine
5
  from presidio_anonymizer.entities import OperatorConfig
6
 
7
  # from lingua import Language, LanguageDetector
 
8
 
9
 
10
  def create_ssn_pattern_recognizer():
@@ -92,7 +94,7 @@ class PIIFilter:
92
 
93
  def __new__(cls):
94
  if cls._instance is None:
95
- print("Initializing Presidio Engines (this should happen only once)...")
96
  cls._instance = super(PIIFilter, cls).__new__(cls)
97
 
98
  # Define which models to use for which language
 
1
+ import logging
2
  from typing import List, Optional
3
  from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
4
  from presidio_analyzer.nlp_engine import NlpEngineProvider
 
6
  from presidio_anonymizer.entities import OperatorConfig
7
 
8
  # from lingua import Language, LanguageDetector
9
+ logger = logging.getLogger("uvicorn")
10
 
11
 
12
  def create_ssn_pattern_recognizer():
 
94
 
95
  def __new__(cls):
96
  if cls._instance is None:
97
+ logger.info("Loading the prompt sanitizer into memory...")
98
  cls._instance = super(PIIFilter, cls).__new__(cls)
99
 
100
  # Define which models to use for which language
exceptions.py CHANGED
@@ -53,7 +53,7 @@ class FileExtractionException(Exception):
53
 
54
  FILE_EXTRACTION_ERROR_STATUS_CODES = {
55
  FileExtractionError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
56
- FileExtractionError.NO_TEXT: STATUS_CODE_INTERNAL_SERVER_ERROR,
57
  FileExtractionError.TEXT_EXTRACTION_TIMEOUT: STATUS_CODE_INTERNAL_SERVER_ERROR,
58
  FileExtractionError.UNSAFE_ZIP: STATUS_CODE_INTERNAL_SERVER_ERROR,
59
  FileExtractionError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE,
 
53
 
54
  FILE_EXTRACTION_ERROR_STATUS_CODES = {
55
  FileExtractionError.INVALID_MIME_TYPE: STATUS_CODE_UNSUPPORTED_MEDIA_TYPE,
56
+ FileExtractionError.NO_TEXT: STATUS_CODE_BAD_REQUEST,
57
  FileExtractionError.TEXT_EXTRACTION_TIMEOUT: STATUS_CODE_INTERNAL_SERVER_ERROR,
58
  FileExtractionError.UNSAFE_ZIP: STATUS_CODE_INTERNAL_SERVER_ERROR,
59
  FileExtractionError.FILE_TOO_LARGE: STATUS_CODE_CONTENT_TOO_LARGE,
helpers/file_helper.py CHANGED
@@ -85,15 +85,17 @@ def safe_unzip_check(file_bytes: bytes) -> bool:
85
  break
86
  total += len(chunk)
87
  if total > MAX_FILE_SIZE:
88
- return False # bail out immediately
 
 
89
  return True
90
  except zipfile.BadZipFile:
91
- return False
92
 
93
 
94
  def extract_text_from_docx(binary_content: bytes):
95
  if not safe_unzip_check(binary_content):
96
- raise FileExtractionException(FileExtractionError.UNSAFE_ZIP)
97
 
98
  # Load the binary data into a stream
99
  stream = io.BytesIO(binary_content)
 
85
  break
86
  total += len(chunk)
87
  if total > MAX_FILE_SIZE:
88
+ raise FileExtractionException(
89
+ FileExtractionError.FILE_TOO_LARGE
90
+ )
91
  return True
92
  except zipfile.BadZipFile:
93
+ raise FileExtractionException(FileExtractionError.UNSAFE_ZIP)
94
 
95
 
96
  def extract_text_from_docx(binary_content: bytes):
97
  if not safe_unzip_check(binary_content):
98
+ return None
99
 
100
  # Load the binary data into a stream
101
  stream = io.BytesIO(binary_content)
main.py CHANGED
@@ -1,29 +1,31 @@
1
- import os
2
  import asyncio
3
- import psutil
4
- import torch
5
-
6
  from contextlib import asynccontextmanager
 
7
 
8
- from typing import AsyncGenerator, List, Literal, Tuple, Dict, Any
9
-
10
  from dotenv import load_dotenv
11
-
12
- from fastapi import FastAPI, File, Form, Request, BackgroundTasks, Response, UploadFile
13
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
14
  from fastapi.staticfiles import StaticFiles
15
  from fastapi.templating import Jinja2Templates
16
-
 
 
17
  from slowapi import Limiter
18
  from slowapi.util import get_remote_address
19
-
20
- from opentelemetry import trace
21
 
22
  from champ.rag import (
23
  create_embedding_model,
24
  create_session_vector_store,
25
  load_vector_store,
26
  )
 
 
 
27
  from classes.base_models import (
28
  ChatMessage,
29
  ChatRequest,
@@ -36,10 +38,12 @@ from classes.ocr_reader import OCRReader
36
  from classes.pii_filter import PIIFilter
37
  from classes.prompt_injection_filter import PromptInjectionFilter
38
  from classes.session_conversation_store import SessionConversationStore
 
39
  from classes.session_tracker import SessionTracker
40
  from constants import (
41
  MAX_ID_LENGTH,
42
  MAX_RAM_USAGE_PERCENT,
 
43
  STATUS_CODE_EXCEED_SIZE_LIMIT,
44
  STATUS_CODE_INTERNAL_SERVER_ERROR,
45
  STATUS_CODE_UNPROCESSABLE_CONTENT,
@@ -53,28 +57,19 @@ from exceptions import (
53
  FileValidationException,
54
  )
55
  from helpers.dynamodb_helper import log_event
56
-
57
- from openai import AsyncOpenAI
58
- from google import genai
59
-
60
-
61
- # from lingua import Language, LanguageDetectorBuilder
62
-
63
- from champ.service import ChampService
64
-
65
  from helpers.file_helper import (
66
  extract_text_from_file,
67
  is_valid_filename,
68
  replace_spaces_in_filename,
69
  validate_file,
70
  )
71
- from classes.session_document_store import SessionDocumentStore
72
- from helpers.message_helper import convert_messages
73
- from helpers.message_helper import convert_messages_langchain
74
  from telemetry import setup_telemetry
75
 
76
  load_dotenv()
77
 
 
 
78
  # -------------------- Config --------------------
79
  DEV = os.getenv("ENV", None) == "dev"
80
 
@@ -117,17 +112,17 @@ session_conversation_store = SessionConversationStore()
117
 
118
 
119
  def run_cleanup():
120
- print("running cleanup")
121
  deleted_session_ids = session_tracker.delete_inactive_sessions()
122
  if len(deleted_session_ids) > 0:
123
- print(f"{len(deleted_session_ids)} inactive sessions will be deleted.")
124
  for session_id in deleted_session_ids:
125
  session_document_store.delete_session_documents(session_id)
126
  session_conversation_store.delete_session_conversations(session_id)
127
 
128
  while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT:
129
  oldest_session_id = session_tracker.delete_oldest_session()
130
- print(f"Deleting {oldest_session_id} session because of high RAM usage")
131
  if oldest_session_id is None:
132
  break
133
  session_document_store.delete_session_documents(oldest_session_id)
@@ -170,39 +165,44 @@ def _call_gemini(model_id: str, msgs: list[dict], temperature: float) -> str:
170
  return (resp.text or "").strip()
171
 
172
 
173
- def call_llm(
174
- session_id: str,
175
- model_type: str,
176
- lang: Literal["en", "fr"],
177
- conversation: List[ChatMessage],
178
- ) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]:
179
  tracer = trace.get_tracer(__name__)
180
 
181
- if model_type == "champ":
182
- session_documents = session_document_store.get_documents(session_id)
183
- with tracer.start_as_current_span("vector_store"):
184
- vector_store = (
185
- base_vector_store
186
- if session_documents is None
187
- else create_session_vector_store(
188
- base_vector_store, embedding_model, session_documents
189
- )
190
- )
191
 
192
- with tracer.start_as_current_span("ChampService"):
193
- champ = ChampService(vector_store=vector_store, lang=lang)
194
 
195
- with tracer.start_as_current_span("convert_messages_langchain"):
196
- msgs = convert_messages_langchain(conversation)
197
 
198
- with tracer.start_as_current_span("invoke"):
199
- reply, triage_meta, context = champ.invoke(msgs)
200
 
201
- return reply, triage_meta, context
 
 
 
 
 
 
 
 
202
 
203
  if model_type not in MODEL_MAP:
204
  raise ValueError(f"Unknown model_type: {model_type}")
205
 
 
 
 
206
  model_id = MODEL_MAP[model_type]
207
  document_contents = session_document_store.get_document_contents(session_id)
208
  msgs = convert_messages(conversation, lang=lang, docs_content=document_contents)
@@ -223,13 +223,24 @@ def call_llm(
223
  # -------------------- FastAPI setup --------------------
224
  @asynccontextmanager
225
  async def lifespan(app: FastAPI):
226
- print(f"Is CUDA available: {torch.cuda.is_available()}")
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- print("Loading the OCR model into memory...")
229
  # Loading the OCR Reader in advance, because loading the model takes time.
230
  OCRReader()
231
 
232
- print("Loading the prompt sanitizer into memory...")
233
  # Idem for the prompt sanitizer.
234
  PIIFilter()
235
 
@@ -271,14 +282,16 @@ limiter = Limiter(key_func=get_remote_address)
271
  async def chat_endpoint(
272
  payload: ChatRequest, background_tasks: BackgroundTasks, request: Request
273
  ):
274
- if not payload.human_message:
275
- return JSONResponse({"error": "No message provided"}, status_code=400)
276
-
277
  session_id = payload.session_id
278
  model_type = payload.model_type
279
  lang = payload.lang
280
  conversation_id = payload.conversation_id
281
 
 
 
 
 
 
282
  session_tracker.update_session(session_id)
283
 
284
  prompt_injection_filter = PromptInjectionFilter()
@@ -370,7 +383,6 @@ async def chat_endpoint(
370
  },
371
  )
372
 
373
- # Ajouter les passages récupérés
374
  background_tasks.add_task(
375
  log_event,
376
  user_id=payload.user_id,
@@ -402,7 +414,12 @@ def comment_endpoint(
402
  payload: CommentRequest, background_tasks: BackgroundTasks, request: Request
403
  ):
404
  if not payload.comment:
405
- return JSONResponse({"error": "No comment provided"}, status_code=400)
 
 
 
 
 
406
 
407
  background_tasks.add_task(
408
  log_event,
@@ -459,6 +476,8 @@ async def upload_file(
459
  # )
460
  pii_filtered_file_text = pii_filter.sanitize(injection_filtered_file_text)
461
 
 
 
462
  if session_document_store.create_document(
463
  session_id, pii_filtered_file_text, file_name, file_size
464
  ):
@@ -478,11 +497,4 @@ def delete_file(
478
 
479
  file_name = replace_spaces_in_filename(file_name)
480
 
481
- if not is_valid_filename(file_name):
482
- return Response(status_code=STATUS_CODE_UNPROCESSABLE_CONTENT)
483
-
484
- _, extension = os.path.splitext(file_name)
485
- if extension not in SUPPORTED_FILE_EXTENSIONS:
486
- return Response(status_code=STATUS_CODE_UNSUPPORTED_MEDIA_TYPE)
487
-
488
  session_document_store.delete_document(session_id, file_name)
 
 
1
  import asyncio
2
+ import logging
3
+ import os
 
4
  from contextlib import asynccontextmanager
5
+ from typing import Any, AsyncGenerator, Dict, List, Literal, Tuple
6
 
7
+ import psutil
8
+ import torch
9
  from dotenv import load_dotenv
10
+ from fastapi import BackgroundTasks, FastAPI, File, Form, Request, Response, UploadFile
 
11
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
12
  from fastapi.staticfiles import StaticFiles
13
  from fastapi.templating import Jinja2Templates
14
+ from google import genai
15
+ from openai import AsyncOpenAI
16
+ from opentelemetry import trace
17
  from slowapi import Limiter
18
  from slowapi.util import get_remote_address
19
+ from uvicorn.logging import DefaultFormatter
 
20
 
21
  from champ.rag import (
22
  create_embedding_model,
23
  create_session_vector_store,
24
  load_vector_store,
25
  )
26
+
27
+ # from lingua import Language, LanguageDetectorBuilder
28
+ from champ.service import ChampService
29
  from classes.base_models import (
30
  ChatMessage,
31
  ChatRequest,
 
38
  from classes.pii_filter import PIIFilter
39
  from classes.prompt_injection_filter import PromptInjectionFilter
40
  from classes.session_conversation_store import SessionConversationStore
41
+ from classes.session_document_store import SessionDocumentStore
42
  from classes.session_tracker import SessionTracker
43
  from constants import (
44
  MAX_ID_LENGTH,
45
  MAX_RAM_USAGE_PERCENT,
46
+ STATUS_CODE_BAD_REQUEST,
47
  STATUS_CODE_EXCEED_SIZE_LIMIT,
48
  STATUS_CODE_INTERNAL_SERVER_ERROR,
49
  STATUS_CODE_UNPROCESSABLE_CONTENT,
 
57
  FileValidationException,
58
  )
59
  from helpers.dynamodb_helper import log_event
 
 
 
 
 
 
 
 
 
60
  from helpers.file_helper import (
61
  extract_text_from_file,
62
  is_valid_filename,
63
  replace_spaces_in_filename,
64
  validate_file,
65
  )
66
+ from helpers.message_helper import convert_messages, convert_messages_langchain
 
 
67
  from telemetry import setup_telemetry
68
 
69
  load_dotenv()
70
 
71
+ logger = logging.getLogger("uvicorn")
72
+
73
  # -------------------- Config --------------------
74
  DEV = os.getenv("ENV", None) == "dev"
75
 
 
112
 
113
 
114
  def run_cleanup():
115
+ logger.info("Running cleanup")
116
  deleted_session_ids = session_tracker.delete_inactive_sessions()
117
  if len(deleted_session_ids) > 0:
118
+ logger.info(f"{len(deleted_session_ids)} inactive sessions will be deleted.")
119
  for session_id in deleted_session_ids:
120
  session_document_store.delete_session_documents(session_id)
121
  session_conversation_store.delete_session_conversations(session_id)
122
 
123
  while psutil.virtual_memory().percent > MAX_RAM_USAGE_PERCENT:
124
  oldest_session_id = session_tracker.delete_oldest_session()
125
+ logger.info(f"Deleting {oldest_session_id} session because of high RAM usage")
126
  if oldest_session_id is None:
127
  break
128
  session_document_store.delete_session_documents(oldest_session_id)
 
165
  return (resp.text or "").strip()
166
 
167
 
168
+ def _call_champ(
169
+ session_id: str, lang: Literal["en", "fr"], conversation: List[ChatMessage]
170
+ ):
 
 
 
171
  tracer = trace.get_tracer(__name__)
172
 
173
+ session_documents = session_document_store.get_documents(session_id)
174
+ if session_documents is None:
175
+ vector_store = base_vector_store
176
+ else:
177
+ vector_store = create_session_vector_store(
178
+ base_vector_store, embedding_model, session_documents
179
+ )
 
 
 
180
 
181
+ with tracer.start_as_current_span("ChampService"):
182
+ champ = ChampService(vector_store=vector_store, lang=lang)
183
 
184
+ with tracer.start_as_current_span("convert_messages_langchain"):
185
+ msgs = convert_messages_langchain(conversation)
186
 
187
+ with tracer.start_as_current_span("invoke"):
188
+ reply, triage_meta, context = champ.invoke(msgs)
189
 
190
+ return reply, triage_meta, context
191
+
192
+
193
+ def call_llm(
194
+ session_id: str,
195
+ model_type: str,
196
+ lang: Literal["en", "fr"],
197
+ conversation: List[ChatMessage],
198
+ ) -> AsyncGenerator[str, None] | Tuple[str, Dict[str, Any], List[str]]:
199
 
200
  if model_type not in MODEL_MAP:
201
  raise ValueError(f"Unknown model_type: {model_type}")
202
 
203
+ if model_type == "champ":
204
+ return _call_champ(session_id, lang, conversation)
205
+
206
  model_id = MODEL_MAP[model_type]
207
  document_contents = session_document_store.get_document_contents(session_id)
208
  msgs = convert_messages(conversation, lang=lang, docs_content=document_contents)
 
223
  # -------------------- FastAPI setup --------------------
224
  @asynccontextmanager
225
  async def lifespan(app: FastAPI):
226
+ logger = logging.getLogger("uvicorn")
227
+
228
+ if logger.handlers:
229
+ colored_formatter = DefaultFormatter(
230
+ fmt="%(levelprefix)s %(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
231
+ )
232
+ logger.handlers[0].setFormatter(colored_formatter)
233
+
234
+ logger.info("Logging configured!")
235
+
236
+ if torch.cuda.is_available():
237
+ logger.info("CUDA is available")
238
+ else:
239
+ logger.warning("CUDA is NOT available")
240
 
 
241
  # Loading the OCR Reader in advance, because loading the model takes time.
242
  OCRReader()
243
 
 
244
  # Idem for the prompt sanitizer.
245
  PIIFilter()
246
 
 
282
  async def chat_endpoint(
283
  payload: ChatRequest, background_tasks: BackgroundTasks, request: Request
284
  ):
 
 
 
285
  session_id = payload.session_id
286
  model_type = payload.model_type
287
  lang = payload.lang
288
  conversation_id = payload.conversation_id
289
 
290
+ if not payload.human_message:
291
+ return JSONResponse(
292
+ {"error": "No message provided"}, status_code=STATUS_CODE_BAD_REQUEST
293
+ )
294
+
295
  session_tracker.update_session(session_id)
296
 
297
  prompt_injection_filter = PromptInjectionFilter()
 
383
  },
384
  )
385
 
 
386
  background_tasks.add_task(
387
  log_event,
388
  user_id=payload.user_id,
 
414
  payload: CommentRequest, background_tasks: BackgroundTasks, request: Request
415
  ):
416
  if not payload.comment:
417
+ logger.warning("Received empty comment")
418
+ return JSONResponse(
419
+ {"error": "No comment provided"}, status_code=STATUS_CODE_BAD_REQUEST
420
+ )
421
+
422
+ logger.info("Received comment")
423
 
424
  background_tasks.add_task(
425
  log_event,
 
476
  # )
477
  pii_filtered_file_text = pii_filter.sanitize(injection_filtered_file_text)
478
 
479
+ # TODO: total session file size should be related to the amount of text in the file
480
+ # instead of the size of the file
481
  if session_document_store.create_document(
482
  session_id, pii_filtered_file_text, file_name, file_size
483
  ):
 
497
 
498
  file_name = replace_spaces_in_filename(file_name)
499
 
 
 
 
 
 
 
 
500
  session_document_store.delete_document(session_id, file_name)
static/app.js CHANGED
@@ -14,10 +14,6 @@ const doneFileUploadBtn = document.getElementById('done-file-upload');
14
  const closeFileUploadBtn = document.getElementById('close-file-upload-btn');
15
  const fileListHtml = document.getElementById('file-list');
16
 
17
- const langSwitchContainer = document.getElementById('lang-switch-container');
18
- const enBtn = document.getElementById('btn-en');
19
- const frBtn = document.getElementById('btn-fr');
20
-
21
  document.createElement('svg');
22
 
23
  const HTML_UPLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -56,6 +52,8 @@ const consentBtn = document.getElementById('consentBtn');
56
  const frRadioBtn = document.getElementById('lang-fr');
57
  const enRadioBtn = document.getElementById('lang-en');
58
  const continueLangBtn = document.getElementById('lang-continue-btn');
 
 
59
 
60
  const profileModal = document.getElementById('profile-modal');
61
  const profileBtn = document.getElementById('profileBtn');
@@ -78,6 +76,11 @@ const increaseFontSizeBtn = document.getElementById('increase-font-size-btn');
78
  const decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn');
79
  const resetFontSizeBtn = document.getElementById('reset-font-size-btn');
80
 
 
 
 
 
 
81
  // Local in-browser chat history
82
  // We store for each model its chat history and a conversation id.
83
  const modelChats = {};
@@ -98,16 +101,6 @@ document.body.classList.add('no-scroll');
98
 
99
  let sessionFiles = [];
100
 
101
- function openModal() {
102
- // Move the translation options at the top right corner of the screen
103
- langSwitchContainer.classList.add('floating');
104
- }
105
-
106
- function closeModal() {
107
- // Move the translation options in the toolbar
108
- langSwitchContainer.classList.remove('floating');
109
- }
110
-
111
  function renderMessages() {
112
  chatWindow.innerHTML = '';
113
  const modelType = systemPresetSelect.value;
@@ -178,14 +171,14 @@ async function sendMessage() {
178
  });
179
 
180
  if (!res.ok) {
181
- statusEl.className = 'status status-error';
182
- if (data.error) {
183
- statusEl.textContent = data.error;
184
  } else {
185
- statusEl.textContent = "";
186
  statusEl.dataset.i18n = "server_error";
187
- applyTranslation();
188
  }
 
 
189
  return;
190
  }
191
 
@@ -243,8 +236,6 @@ function openFileUploadOverlay(e) {
243
  e.preventDefault();
244
  // Let the stylesheet take over
245
  uploadFileOverlay.style.display = '';
246
-
247
- openModal();
248
  }
249
  uploadFileBtn.addEventListener('click', openFileUploadOverlay);
250
 
@@ -438,7 +429,23 @@ async function uploadFile(file) {
438
  });
439
 
440
  if (!res.ok) {
441
- showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  return false;
443
  }
444
 
@@ -485,14 +492,24 @@ async function deleteFile(file) {
485
  // Close the overlay
486
  closeFileUploadBtn.addEventListener('click', () => {
487
  uploadFileOverlay.style.display = 'none';
488
- closeModal();
489
  });
490
  doneFileUploadBtn.addEventListener('click', () => {
491
  uploadFileOverlay.style.display = 'none';
492
- closeModal();
493
  })
494
 
495
- // ----- Event wiring -----
 
 
 
 
 
 
 
 
 
 
 
 
496
 
497
  // Language modal logic
498
  continueLangBtn.addEventListener('click', () => {
@@ -511,6 +528,14 @@ enRadioBtn.addEventListener('change', () => {
511
  currentLang = enRadioBtn.value;
512
  setLanguage();
513
  });
 
 
 
 
 
 
 
 
514
 
515
  // Consent logic
516
  // When the checkbox is toggled, enable or disable the button
@@ -573,8 +598,6 @@ profileBtn.addEventListener('click', () => {
573
  gender = document.getElementById('gender').value;
574
  roles = Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value);
575
  participantId = participantInput.value.trim();
576
-
577
- closeModal();
578
  });
579
 
580
  sendBtn.addEventListener('click', sendMessage);
@@ -608,19 +631,15 @@ function openCommentOverlay(e) {
608
  e.preventDefault();
609
  // Let the stylesheet take over
610
  commentOverlay.style.display = '';
611
-
612
- openModal();
613
  }
614
  leaveCommentText.addEventListener('click', openCommentOverlay);
615
 
616
  // Cancelling or closing the comment overlay simply hides the comment popup
617
  closeCommentBtn.addEventListener('click', () => {
618
  commentOverlay.style.display = 'none';
619
- closeModal();
620
  });
621
  cancelCommentBtn.addEventListener('click', () => {
622
  commentOverlay.style.display = 'none';
623
- closeModal();
624
  });
625
 
626
  async function sendComment() {
@@ -650,7 +669,11 @@ async function sendComment() {
650
  });
651
 
652
  if (!res.ok) {
653
- statusComment.dataset.i18n = "server_error";
 
 
 
 
654
  statusComment.className = 'status-error';
655
  applyTranslation();
656
  return;
@@ -674,24 +697,14 @@ sendCommentBtn.addEventListener('click', sendComment);
674
  function setLanguage() {
675
  applyTranslation();
676
 
677
- document.getElementById('btn-en').classList.toggle('active', currentLang === 'en');
678
- document.getElementById('btn-fr').classList.toggle('active', currentLang === 'fr');
679
-
680
  frRadioBtn.checked = currentLang === 'fr';
681
  enRadioBtn.checked = currentLang === 'en';
682
-
 
 
683
  localStorage.setItem('preferredLang', currentLang);
684
  };
685
 
686
- enBtn.addEventListener('click', () => {
687
- currentLang = 'en';
688
- setLanguage();
689
- });
690
- frBtn.addEventListener('click', () => {
691
- currentLang = 'fr';
692
- setLanguage();
693
- });
694
-
695
  function applyTranslation() {
696
  document.querySelectorAll('[data-i18n]').forEach(element => {
697
  const key = element.getAttribute('data-i18n');
@@ -702,7 +715,7 @@ function applyTranslation() {
702
  };
703
 
704
  const MIN_FONT_SIZE = 0.75;
705
- const MAX_FONT_SIZE = 2.5;
706
  const FONT_SIZE_STEP = 0.125; // 1/8 rem for smooth increments
707
 
708
  let currentSize = 1; // 1rem = browser default (usually 16px)
@@ -731,11 +744,11 @@ statusComment.dataset.i18n = "ready";
731
  statusComment.className = 'status-ok';
732
 
733
  if (currentLang == "en") {
734
- enBtn.classList.add('active');
735
  enRadioBtn.checked = true;
 
736
  } else {
737
- frBtn.classList.add('active');
738
  frRadioBtn.checked = true;
 
739
  }
740
 
741
  applyTranslation();
@@ -745,5 +758,3 @@ renderFiles();
745
  if (window.innerWidth >= 460) {
746
  document.querySelector('details').setAttribute('open', '');
747
  }
748
-
749
- openModal();
 
14
  const closeFileUploadBtn = document.getElementById('close-file-upload-btn');
15
  const fileListHtml = document.getElementById('file-list');
16
 
 
 
 
 
17
  document.createElement('svg');
18
 
19
  const HTML_UPLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
 
52
  const frRadioBtn = document.getElementById('lang-fr');
53
  const enRadioBtn = document.getElementById('lang-en');
54
  const continueLangBtn = document.getElementById('lang-continue-btn');
55
+ const frRadioBtnSettings = document.getElementById('lang-fr-settings');
56
+ const enRadioBtnSettings = document.getElementById('lang-en-settings');
57
 
58
  const profileModal = document.getElementById('profile-modal');
59
  const profileBtn = document.getElementById('profileBtn');
 
76
  const decreaseFontSizeBtn = document.getElementById('decrease-font-size-btn');
77
  const resetFontSizeBtn = document.getElementById('reset-font-size-btn');
78
 
79
+ const settingsBtn = document.getElementById('settings-btn')
80
+ const settingsModal = document.getElementById('settings-modal');
81
+ const doneSettingsBtn = document.getElementById('done-settings');
82
+ const closeSettingsBtn = document.getElementById('close-settings-btn');
83
+
84
  // Local in-browser chat history
85
  // We store for each model its chat history and a conversation id.
86
  const modelChats = {};
 
101
 
102
  let sessionFiles = [];
103
 
 
 
 
 
 
 
 
 
 
 
104
  function renderMessages() {
105
  chatWindow.innerHTML = '';
106
  const modelType = systemPresetSelect.value;
 
171
  });
172
 
173
  if (!res.ok) {
174
+ statusEl.textContent = "";
175
+ if (res.status === 400) {
176
+ statusEl.dataset.i18n = "empty_message_error";
177
  } else {
 
178
  statusEl.dataset.i18n = "server_error";
 
179
  }
180
+ statusEl.className = 'status status-error';
181
+ applyTranslation();
182
  return;
183
  }
184
 
 
236
  e.preventDefault();
237
  // Let the stylesheet take over
238
  uploadFileOverlay.style.display = '';
 
 
239
  }
240
  uploadFileBtn.addEventListener('click', openFileUploadOverlay);
241
 
 
429
  });
430
 
431
  if (!res.ok) {
432
+ if (res.status === 413) {
433
+ showSnackbar(translations[currentLang]["file_upload_failed_file_too_large"], 'error');
434
+ } else if (res.status === 400) {
435
+ // missing filename, filename too large, invalid file name, empty file, malformed file, no text in file
436
+ showSnackbar(translations[currentLang]["file_upload_failed_malformed_file"], 'error');
437
+ } else if (res.status === 415) {
438
+ // invalid mimetype or unsupported extension
439
+ showSnackbar(translations[currentLang]["file_upload_failed_unsupported_mime_type"], 'error');
440
+ } else if (res.status === 419) {
441
+ showSnackbar(translations[currentLang]["file_upload_failed_exceed_session_size"], 'error');
442
+ } else if (res.status === 500) {
443
+ // generic error, timeout, unsafe zip/docx
444
+ showSnackbar(translations[currentLang]["file_upload_failed_server_error"], 'error');
445
+ } else {
446
+ // unknown error occured.
447
+ showSnackbar(translations[currentLang]["file_upload_failed_unknown_error"], 'error');
448
+ }
449
  return false;
450
  }
451
 
 
492
  // Close the overlay
493
  closeFileUploadBtn.addEventListener('click', () => {
494
  uploadFileOverlay.style.display = 'none';
 
495
  });
496
  doneFileUploadBtn.addEventListener('click', () => {
497
  uploadFileOverlay.style.display = 'none';
 
498
  })
499
 
500
+ // Settings modal
501
+ function openSettingsModal(e) {
502
+ e.preventDefault();
503
+ settingsModal.style.display = '';
504
+ }
505
+ settingsBtn.addEventListener('click', openSettingsModal);
506
+
507
+ closeSettingsBtn.addEventListener('click', () => {
508
+ settingsModal.style.display = 'none';
509
+ });
510
+ doneSettingsBtn.addEventListener('click', () => {
511
+ settingsModal.style.display = 'none';
512
+ });
513
 
514
  // Language modal logic
515
  continueLangBtn.addEventListener('click', () => {
 
528
  currentLang = enRadioBtn.value;
529
  setLanguage();
530
  });
531
+ frRadioBtnSettings.addEventListener('change', () => {
532
+ currentLang = frRadioBtnSettings.value;
533
+ setLanguage();
534
+ });
535
+ enRadioBtnSettings.addEventListener('change', () => {
536
+ currentLang = enRadioBtnSettings.value;
537
+ setLanguage();
538
+ });
539
 
540
  // Consent logic
541
  // When the checkbox is toggled, enable or disable the button
 
598
  gender = document.getElementById('gender').value;
599
  roles = Array.from(document.querySelectorAll('input[name="role"]:checked')).map(input => input.value);
600
  participantId = participantInput.value.trim();
 
 
601
  });
602
 
603
  sendBtn.addEventListener('click', sendMessage);
 
631
  e.preventDefault();
632
  // Let the stylesheet take over
633
  commentOverlay.style.display = '';
 
 
634
  }
635
  leaveCommentText.addEventListener('click', openCommentOverlay);
636
 
637
  // Cancelling or closing the comment overlay simply hides the comment popup
638
  closeCommentBtn.addEventListener('click', () => {
639
  commentOverlay.style.display = 'none';
 
640
  });
641
  cancelCommentBtn.addEventListener('click', () => {
642
  commentOverlay.style.display = 'none';
 
643
  });
644
 
645
  async function sendComment() {
 
669
  });
670
 
671
  if (!res.ok) {
672
+ if (res.status === 400) {
673
+ statusComment.dataset.i18n = "empty_message_error";
674
+ } else {
675
+ statusComment.dataset.i18n = "server_error";
676
+ }
677
  statusComment.className = 'status-error';
678
  applyTranslation();
679
  return;
 
697
  function setLanguage() {
698
  applyTranslation();
699
 
 
 
 
700
  frRadioBtn.checked = currentLang === 'fr';
701
  enRadioBtn.checked = currentLang === 'en';
702
+ frRadioBtnSettings.checked = currentLang === 'fr';
703
+ enRadioBtnSettings.checked = currentLang === 'en';
704
+
705
  localStorage.setItem('preferredLang', currentLang);
706
  };
707
 
 
 
 
 
 
 
 
 
 
708
  function applyTranslation() {
709
  document.querySelectorAll('[data-i18n]').forEach(element => {
710
  const key = element.getAttribute('data-i18n');
 
715
  };
716
 
717
  const MIN_FONT_SIZE = 0.75;
718
+ const MAX_FONT_SIZE = 1.625;
719
  const FONT_SIZE_STEP = 0.125; // 1/8 rem for smooth increments
720
 
721
  let currentSize = 1; // 1rem = browser default (usually 16px)
 
744
  statusComment.className = 'status-ok';
745
 
746
  if (currentLang == "en") {
 
747
  enRadioBtn.checked = true;
748
+ enRadioBtnSettings.checked = true;
749
  } else {
 
750
  frRadioBtn.checked = true;
751
+ frRadioBtnSettings.checked = true;
752
  }
753
 
754
  applyTranslation();
 
758
  if (window.innerWidth >= 460) {
759
  document.querySelector('details').setAttribute('open', '');
760
  }
 
 
static/style.css CHANGED
@@ -34,7 +34,7 @@ a {
34
  }
35
 
36
  .chat-header {
37
- padding: 8px 4px 12px;
38
  border-bottom: 1px solid #2c3554;
39
  }
40
 
@@ -54,21 +54,17 @@ a {
54
  display: flex;
55
  flex-wrap: wrap;
56
  gap: 12px;
57
- margin-top: 10px;
58
  padding: 8px 4px;
59
  border-bottom: 1px solid #2c3554;
60
  }
61
 
62
  .control-group {
63
  display: flex;
64
- flex-direction: column;
65
- gap: 4px;
66
- font-size: 0.85rem;
67
- color: #d3dbff;
68
  }
69
 
70
- .control-group select,
71
- .control-group input[type='range'] {
72
  background: #0d1324;
73
  border-radius: 8px;
74
  border: 1px solid #2c3554;
@@ -77,21 +73,36 @@ a {
77
  font-size: 0.85rem;
78
  }
79
 
80
- .secondary-button {
81
- align-self: flex-end;
82
  padding: 6px 12px;
83
  border-radius: 8px;
84
  border: 1px solid #2c3554;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  background: #1f2840;
86
  color: #f5f5f5;
87
  font-size: 0.85rem;
88
  cursor: pointer;
 
89
  }
90
 
91
- .secondary-button:hover {
92
  background: #273256;
93
  }
94
-
95
  /* Chat window */
96
  .chat-window {
97
  flex: 1;
@@ -325,45 +336,6 @@ svg {
325
  color: #ff8080;
326
  }
327
 
328
- /* RESPONSIVE DESIGN */
329
- @media (max-width: 460px) {
330
- /* Hide the text descriptions of the file action buttons */
331
- .file-actions button span {
332
- display: none;
333
- }
334
-
335
- /* Enlarge the chat container on mobile */
336
- .chat-container {
337
- margin: 0;
338
- width: 100dvw;
339
- height: 100dvh;
340
- }
341
-
342
- /* Reduce the font size of the title on mobile */
343
- /* Also, add a gap between the title and the details */
344
- .chat-header h1 {
345
- margin: 0 0 10px 0;
346
- font-size: 1.4rem;
347
- }
348
-
349
- /* Increase the size of the modals on mobile */
350
- .modal-content {
351
- width: 90%;
352
- }
353
- }
354
-
355
- @media (min-width: 460px) {
356
- details {
357
- display: block;
358
- }
359
- details[open] {
360
- display: block;
361
- }
362
- details summary {
363
- display: none;
364
- }
365
- }
366
-
367
  /* CONSENT OVERLAY FIXED VERSION */
368
  .modal {
369
  /* Covers the entier view port */
@@ -385,6 +357,11 @@ svg {
385
  background-color: rgba(0, 0, 0, 0.8);
386
  }
387
 
 
 
 
 
 
388
  .slider {
389
  /* Smooth scrolling */
390
  scroll-snap-type: x mandatory;
@@ -435,17 +412,17 @@ svg {
435
  }
436
 
437
  .language-modal,
438
- .consent-box {
 
 
439
  display: flex;
440
  flex-direction: column;
441
  justify-content: space-between;
442
- max-height: 400px;
443
  }
444
 
445
- .profile {
446
- display: flex;
447
- flex-direction: column;
448
- justify-content: space-between;
449
  }
450
 
451
  .form-group {
@@ -525,7 +502,7 @@ input[type="checkbox"] {
525
  accent-color: #007bff; /* Modern way to color native inputs */
526
  }
527
 
528
- .radio-group label, .checkbox-grid label, .checkbox-grid-lang label, {
529
  font-weight: 400;
530
  display: flex;
531
  align-items: center;
@@ -634,81 +611,97 @@ input[type='range'].disabled {
634
  transition: all 0.2s ease;
635
  }
636
 
637
-
638
- /* Translation */
639
- .lang-switch-container {
640
- display: flex;
641
- align-items: center;
642
- font-family: sans-serif;
643
- gap: 5px;
644
-
645
- /* By default */
646
- position: static;
647
- margin-left: auto;
648
  }
649
 
650
- .lang-switch-container.floating {
651
- /* At the top right corner, when a modal is opened */
652
- position: fixed;
653
- top: 20px;
654
- right: 20px;
655
- z-index: 100; /* In front of the modals */
656
  }
657
 
658
- .lang-btn {
659
- background: none;
660
- border: none;
 
 
 
 
 
661
  cursor: pointer;
662
- font-weight: bold;
663
- color: #999999; /* Light grey for "disabled" look */
664
- padding: 5px;
665
- transition: color 0.3s;
666
  }
667
 
668
- .lang-btn.active {
669
- color: #999999a1; /* Darker/Bold for active */
670
- cursor: default;
671
- pointer-events: none; /* User can't click the already active language */
 
672
  }
673
 
674
- .separator {
675
- color: #ccc;
676
  }
677
 
678
- /* Font size */
679
- .font-size-container {
680
- /* Center the container at the middle of the right screen edge. */
681
- position: fixed;
682
- top: 50%;
683
- transform: translateY(-50%);
684
- right: 20px;
685
 
686
- display: flex;
687
- flex-direction: column;
688
- align-items: center;
689
- gap: 6px;
690
- background: #0d0d0d;
691
- border: 1px solid #2c3554;
692
- border-radius: 8px;
693
- padding: 10px 8px;
694
- }
695
 
696
- .font-size-container button {
697
- width: 36px;
698
- height: 36px;
699
- background: transparent;
700
- color: white;
701
- border: 1px solid #2c3554;
702
- border-radius: 6px;
703
- font-size: 1rem;
704
- font-family: monospace;
705
- cursor: pointer;
706
- transition: background 0.2s, box-shadow 0.2s;
707
 
708
- font-size: 14px; /* px so it ignores root font-size changes */
 
 
 
709
  }
710
 
711
- .font-size-container button:hover {
712
- background: transparent;
713
- box-shadow: 0 0 8px #007bff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  .chat-header {
37
+ padding: 0px 4px 12px;
38
  border-bottom: 1px solid #2c3554;
39
  }
40
 
 
54
  display: flex;
55
  flex-wrap: wrap;
56
  gap: 12px;
 
57
  padding: 8px 4px;
58
  border-bottom: 1px solid #2c3554;
59
  }
60
 
61
  .control-group {
62
  display: flex;
63
+ align-items: center;
64
+ gap: 8px;
 
 
65
  }
66
 
67
+ .control-group select {
 
68
  background: #0d1324;
69
  border-radius: 8px;
70
  border: 1px solid #2c3554;
 
73
  font-size: 0.85rem;
74
  }
75
 
76
+ .clear-button {
77
+ align-self: center;
78
  padding: 6px 12px;
79
  border-radius: 8px;
80
  border: 1px solid #2c3554;
81
+ background: #dc2626ba;
82
+ color: #f5f5f5;
83
+ font-size: 0.85rem;
84
+ cursor: pointer;
85
+ }
86
+
87
+ .clear-button:hover {
88
+ background: #dc2626;
89
+ }
90
+
91
+ .settings-button {
92
+ align-self: center;
93
+ padding: 12px 12px;
94
+ border-radius: 8px;
95
+ border: 1px solid #2c3554;
96
  background: #1f2840;
97
  color: #f5f5f5;
98
  font-size: 0.85rem;
99
  cursor: pointer;
100
+ margin-left: auto;
101
  }
102
 
103
+ .settings-button:hover {
104
  background: #273256;
105
  }
 
106
  /* Chat window */
107
  .chat-window {
108
  flex: 1;
 
336
  color: #ff8080;
337
  }
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  /* CONSENT OVERLAY FIXED VERSION */
340
  .modal {
341
  /* Covers the entier view port */
 
357
  background-color: rgba(0, 0, 0, 0.8);
358
  }
359
 
360
+ .modal h2 {
361
+ margin-top: 0;
362
+ margin-bottom: 0;
363
+ }
364
+
365
  .slider {
366
  /* Smooth scrolling */
367
  scroll-snap-type: x mandatory;
 
412
  }
413
 
414
  .language-modal,
415
+ .consent-box,
416
+ .profile,
417
+ .settings-modal-content {
418
  display: flex;
419
  flex-direction: column;
420
  justify-content: space-between;
 
421
  }
422
 
423
+ .language-modal,
424
+ .consent-box {
425
+ max-height: 350px;
 
426
  }
427
 
428
  .form-group {
 
502
  accent-color: #007bff; /* Modern way to color native inputs */
503
  }
504
 
505
+ .radio-group label, .checkbox-grid label, .checkbox-grid-lang label {
506
  font-weight: 400;
507
  display: flex;
508
  align-items: center;
 
611
  transition: all 0.2s ease;
612
  }
613
 
614
+ /* Settings modal */
615
+ .settings-modal-content {
616
+ position: relative;
 
 
 
 
 
 
 
 
617
  }
618
 
619
+ /* Font size */
620
+ .font-size-container {
621
+ display: flex;
622
+ gap: 12px;
623
+ margin-top: 8px;
624
+ justify-content: flex-start;
625
  }
626
 
627
+ .font-size-btn {
628
+ padding: 12px 20px;
629
+ border-radius: 8px;
630
+ border: 1px solid #2c3554;
631
+ background: #1f2840;
632
+ color: #f5f5f5;
633
+ font-size: 1rem;
634
+ font-weight: 600;
635
  cursor: pointer;
636
+ transition: all 0.2s ease;
637
+ min-width: 60px;
 
 
638
  }
639
 
640
+ .font-size-btn:hover {
641
+ background: #273256;
642
+ border-color: #3d4a6e;
643
+ transform: translateY(-1px);
644
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
645
  }
646
 
647
+ .font-size-btn:active {
648
+ transform: translateY(0);
649
  }
650
 
651
+ /* RESPONSIVE DESIGN */
652
+ @media (max-width: 460px) {
653
+ /* Hide the text descriptions of the file action buttons */
654
+ .file-actions button span {
655
+ display: none;
656
+ }
 
657
 
658
+ /* Enlarge the chat container on mobile */
659
+ .chat-container {
660
+ margin: 0;
661
+ width: 100dvw;
662
+ height: 100dvh;
663
+ }
 
 
 
664
 
665
+ /* Reduce the font size of the title on mobile */
666
+ /* Also, add a gap between the title and the details */
667
+ .chat-header h1 {
668
+ margin: 0 0 10px 0;
669
+ font-size: 1.4rem;
670
+ }
 
 
 
 
 
671
 
672
+ /* Increase the size of the modals on mobile */
673
+ .modal-content {
674
+ width: 90%;
675
+ }
676
  }
677
 
678
+ @media (max-height: 720px) {
679
+ /* Enlarge the chat container on small screens */
680
+ .chat-container {
681
+ margin: 0;
682
+ width: 100dvw;
683
+ height: 100dvh;
684
+ }
685
+
686
+ /* Reduce the font size of the title */
687
+ .chat-header h1 {
688
+ font-size: 1.4rem;
689
+ }
690
+
691
+ /* Increase the size of the modals */
692
+ .modal-content {
693
+ width: 90%;
694
+ }
695
  }
696
+
697
+ @media (min-width: 460px) {
698
+ details {
699
+ display: block;
700
+ }
701
+ details[open] {
702
+ display: block;
703
+ }
704
+ details summary {
705
+ display: none;
706
+ }
707
+ }
static/translations.js CHANGED
@@ -14,7 +14,9 @@ const translations = {
14
  conversation_cleared: "Conversation cleared. Start a new chat!",
15
 
16
  choose_language_title: "Choose your language",
17
- 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.",
 
 
18
 
19
  consent_title: "Before you continue",
20
  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.",
@@ -47,8 +49,8 @@ const translations = {
47
 
48
  file_title: "Add a file",
49
  file_inactivity: "Uploaded files are automatically deleted after 4 hours of inactivity.",
50
- file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max 10MB).",
51
- file_size_limit: "The total size of all uploaded files cannot exceed 30MB.",
52
  error_file_format: "Please upload a picture or a document in PDF, TXT, or DOCX format. Other file types are not supported.",
53
  error_file_size: "File size exceeds limit. Maximum allowed: 10MB.",
54
  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,12 +66,19 @@ const translations = {
64
  file_add_instructions_suffix: " to browse",
65
  click: "Click",
66
 
67
- file_upload_failed_server_error: "File upload was unsuccessful due to a server error.",
68
- file_upload_failed_network_error: "File upload was unsuccessful due to a network error.",
 
 
 
 
 
 
 
69
  file_upload_success: "File upload successful!",
70
 
71
- file_delete_failed_server_error: "File deletion was unsuccessful due to a server error.",
72
- file_delete_failed_network_error: "File deletion was unsuccessful due to a network error.",
73
  file_delete_success: "File deletion successful!",
74
 
75
  done_btn: "Done",
@@ -79,6 +88,7 @@ const translations = {
79
  model_changed: "Model changed",
80
  sending: "Sending...",
81
  no_reply: "(No reply)",
 
82
 
83
  server_error: "Error from server",
84
  network_error: "Network error",
@@ -99,10 +109,12 @@ const translations = {
99
  gemini_conservative: "Gemini-3 (Prudent)",
100
  gemini_creative: "Gemini-3 (Créatif)",
101
  btn_clear: "Réinitialiser",
102
- conversation_cleared: "Conversation réinitialisée. Commencer une nouvelle conversation !",
103
 
104
  choose_language_title: "Choisissez votre langue",
105
- 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.",
 
 
106
 
107
  consent_title: "Avant de poursuivre",
108
  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.",
@@ -152,12 +164,19 @@ const translations = {
152
  file_add_instructions_suffix: " pour parcourir",
153
  click: "Cliquez",
154
 
155
- file_upload_failed_server_error: "Le téléversement du fichier a échoué en raison d'une erreur du serveur.",
156
- file_upload_failed_network_error: "Le téléversement du fichier a échoué en raison d'une erreur réseau.",
 
 
 
 
 
 
 
157
  file_upload_success: "Téléversement du fichier réussi !",
158
 
159
- file_delete_failed_server_error: "La suppression du fichier a échoué en raison d'une erreur du serveur.",
160
- file_delete_failed_network_error: "La suppression du fichier a échoué en raison d'une erreur réseau.",
161
  file_delete_success: "Suppression du fichier réussie !",
162
 
163
  done_btn: "Terminer",
@@ -167,6 +186,7 @@ const translations = {
167
  model_changed: "Changement de modèle",
168
  sending: "Envoi...",
169
  no_reply: "(Aucune réponse)",
 
170
 
171
  server_error: "Erreur du serveur",
172
  network_error: "Erreur réseau",
 
14
  conversation_cleared: "Conversation cleared. Start a new chat!",
15
 
16
  choose_language_title: "Choose your language",
17
+ change_language_instructions: "You can change the language at any time in the Settings menu located in the toolbar.",
18
+ change_language: "Change language",
19
+ change_font_size: "Change font size",
20
 
21
  consent_title: "Before you continue",
22
  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.",
 
49
 
50
  file_title: "Add a file",
51
  file_inactivity: "Uploaded files are automatically deleted after 4 hours of inactivity.",
52
+ file_format: "Accepted formats: PDF, TXT, DOCX, JPG, JPEG, PNG (Max 10 MB).",
53
+ file_size_limit: "The total size of all uploaded files cannot exceed 30 MB.",
54
  error_file_format: "Please upload a picture or a document in PDF, TXT, or DOCX format. Other file types are not supported.",
55
  error_file_size: "File size exceeds limit. Maximum allowed: 10MB.",
56
  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.",
 
66
  file_add_instructions_suffix: " to browse",
67
  click: "Click",
68
 
69
+ settings_title: "Settings",
70
+
71
+ file_upload_failed_server_error: "File upload failed: server error.",
72
+ file_upload_failed_file_too_large: "File upload failed: size exceeds 10 MB limit",
73
+ file_upload_failed_malformed_file: "File upload failed: file invalid",
74
+ file_upload_failed_unsupported_mime_type: "File upload failed: file must be in PDF, TXT, DOCX, JPEG or PNG format",
75
+ file_upload_failed_exceed_session_size: "File upload failed: the total size of all uploaded files exceeds 30 MB",
76
+ file_upload_failed_network_error: "File upload failed: network error",
77
+ file_upload_failed_unknown_error: "File upload failed: unknown error",
78
  file_upload_success: "File upload successful!",
79
 
80
+ file_delete_failed_server_error: "File deletion failed: server error",
81
+ file_delete_failed_network_error: "File deletion failed: network error",
82
  file_delete_success: "File deletion successful!",
83
 
84
  done_btn: "Done",
 
88
  model_changed: "Model changed",
89
  sending: "Sending...",
90
  no_reply: "(No reply)",
91
+ empty_message_error: "Message cannot be empty",
92
 
93
  server_error: "Error from server",
94
  network_error: "Network error",
 
109
  gemini_conservative: "Gemini-3 (Prudent)",
110
  gemini_creative: "Gemini-3 (Créatif)",
111
  btn_clear: "Réinitialiser",
112
+ conversation_cleared: "Conversation réinitialisée.",
113
 
114
  choose_language_title: "Choisissez votre langue",
115
+ change_language_instructions: "Vous pouvez changer la langue à tout moment dans le menu Paramètres situé dans la barre d'outils.",
116
+ change_language: "Changer la langue",
117
+ change_font_size: "Modifier la taille de la police",
118
 
119
  consent_title: "Avant de poursuivre",
120
  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.",
 
164
  file_add_instructions_suffix: " pour parcourir",
165
  click: "Cliquez",
166
 
167
+ settings_title: "Paramètres",
168
+
169
+ file_upload_failed_server_error: "Échec du téléversement: erreur du serveur.",
170
+ file_upload_failed_file_too_large: "Échec du téléversement: la taille du fichier dépasse la limite de 10 Mo",
171
+ file_upload_failed_malformed_file: "Échec du téléversement: le fichier est invalide",
172
+ file_upload_failed_unsupported_mime_type: "Échec du téléversement: le fichier doit être en format PDF, TXT, DOCX, PNG ou JPEG",
173
+ file_upload_failed_exceed_session_size: "Échec du téléversement: la taille totale des fichiers téléversés dépassent 30 Mo",
174
+ file_upload_failed_network_error: "Échec du téléversement: erreur réseau",
175
+ file_upload_failed_unknown_error: "Échec du téléversement: erreur inconnu",
176
  file_upload_success: "Téléversement du fichier réussi !",
177
 
178
+ file_delete_failed_server_error: "Échec de la suppression: erreur du serveur",
179
+ file_delete_failed_network_error: "Échec de la suppression: erreur réseau",
180
  file_delete_success: "Suppression du fichier réussie !",
181
 
182
  done_btn: "Terminer",
 
186
  model_changed: "Changement de modèle",
187
  sending: "Envoi...",
188
  no_reply: "(Aucune réponse)",
189
+ empty_message_error: "Le message ne peut pas être vide.",
190
 
191
  server_error: "Erreur du serveur",
192
  network_error: "Erreur réseau",
templates/index.html CHANGED
@@ -31,22 +31,47 @@
31
  <!-- Controls bar -->
32
  <div class="controls-bar">
33
  <div class="control-group">
34
- <label for="systemPreset" data-i18n="model_selection"></label>
35
- <select id="systemPreset">
36
- <option value="champ" selected>CHAMP</option>
37
- <!-- champ is our model -->
38
- <option value="openai">GPT-5.2</option>
39
- <option value="google-conservative" data-i18n="gemini_conservative"></option>
40
- <option value="google-creative" data-i18n="gemini_creative"></option>
41
- </select>
 
 
 
42
  </div>
43
 
44
- <button id="clearBtn" class="secondary-button" data-i18n="btn_clear"></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- <div class="lang-switch-container" id="lang-switch-container">
47
- <button id="btn-en" class="lang-btn">EN</button>
48
- <span class="separator">|</span>
49
- <button id="btn-fr" class="lang-btn">FR</button>
50
  </div>
51
  </div>
52
 
@@ -170,7 +195,7 @@
170
  <!-- Comment overlay -->
171
  <div id="comment-overlay" class="modal" style="display:none">
172
  <div class="modal-content comment-area">
173
- <button id="closeCommentBtn" class="closeBtn" aria-label="Close">×</button>
174
  <h2 data-i18n="comment_title"></h2>
175
  <textarea
176
  id="commentInput"
@@ -185,7 +210,7 @@
185
  <!-- Upload file overlay -->
186
  <div id="upload-file-overlay" class="modal" style="display:none">
187
  <div class="modal-content upload-file-area">
188
- <button id="close-file-upload-btn" class="closeBtn" aria-label="Close">×</button>
189
  <h2 data-i18n="file_title"></h2>
190
  <p data-i18n="file_inactivity"></p>
191
  <p data-i18n="file_format"></p>
@@ -216,12 +241,6 @@
216
 
217
  <div id="snackbar-container"></div>
218
 
219
- <div class="font-size-container">
220
- <button id="increase-font-size-btn" class="font-size-btn">Aa+</button>
221
- <button id="reset-font-size-btn" class="font-size-btn">Aa</button>
222
- <button id="decrease-font-size-btn" class="font-size-btn">Aa-</button>
223
- </div>
224
-
225
  <script src="/static/translations.js"></script>
226
  <script src="/static/snackbar.js"></script>
227
  <script src="/static/app.js"></script>
 
31
  <!-- Controls bar -->
32
  <div class="controls-bar">
33
  <div class="control-group">
34
+ <fieldset class="control-group">
35
+ <legend for="systemPreset" data-i18n="model_selection"></legend>
36
+ <select id="systemPreset">
37
+ <option value="champ" selected>CHAMP</option>
38
+ <!-- champ is our model -->
39
+ <option value="openai">GPT-5.2</option>
40
+ <option value="google-conservative" data-i18n="gemini_conservative"></option>
41
+ <option value="google-creative" data-i18n="gemini_creative"></option>
42
+ </select>
43
+ <button id="clearBtn" class="clear-button" data-i18n="btn_clear"></button>
44
+ </fieldset>
45
  </div>
46
 
47
+ <button id="settings-btn" class="settings-button">⚙️</button>
48
+ </div>
49
+
50
+ <!-- Settings overlay -->
51
+ <div id="settings-modal" class="modal" style="display: none;">
52
+ <div class="modal-content settings-modal-content">
53
+ <button id="close-settings-btn" class="closeBtn">×</button>
54
+ <h2 data-i18n="settings_title"></h2>
55
+ <h3 data-i18n="change_language"></h3>
56
+ <div class="form-group">
57
+ <span class="group-label" data-i18n="language"></span>
58
+ <div class="checkbox-grid-lang">
59
+ <label for="lang-fr-settings"><input type="radio" name="lang-settings" value="fr" id="lang-fr-settings"><span>Français</span></label>
60
+ <label for="lang-en-settings"><input type="radio" name="lang-settings" value="en" id="lang-en-settings"><span>English</span></label>
61
+ </div>
62
+ </div>
63
+
64
+ <h3 data-i18n="change_font_size"></h3>
65
+ <div class="font-size-container">
66
+ <button id="increase-font-size-btn" class="font-size-btn" style="font-size: 1.2rem;">Aa+</button>
67
+ <button id="reset-font-size-btn" class="font-size-btn" style="font-size: 1rem;">Aa</button>
68
+ <button id="decrease-font-size-btn" class="font-size-btn" style="font-size: 0.8rem;">Aa-</button>
69
+ </div>
70
 
71
+ <!-- div to center the button -->
72
+ <div class="center-button">
73
+ <button id="done-settings" class="ok-button" style="margin-top: 20px;" data-i18n="done_btn"></button>
74
+ </div>
75
  </div>
76
  </div>
77
 
 
195
  <!-- Comment overlay -->
196
  <div id="comment-overlay" class="modal" style="display:none">
197
  <div class="modal-content comment-area">
198
+ <button id="closeCommentBtn" class="closeBtn">×</button>
199
  <h2 data-i18n="comment_title"></h2>
200
  <textarea
201
  id="commentInput"
 
210
  <!-- Upload file overlay -->
211
  <div id="upload-file-overlay" class="modal" style="display:none">
212
  <div class="modal-content upload-file-area">
213
+ <button id="close-file-upload-btn" class="closeBtn">×</button>
214
  <h2 data-i18n="file_title"></h2>
215
  <p data-i18n="file_inactivity"></p>
216
  <p data-i18n="file_format"></p>
 
241
 
242
  <div id="snackbar-container"></div>
243
 
 
 
 
 
 
 
244
  <script src="/static/translations.js"></script>
245
  <script src="/static/snackbar.js"></script>
246
  <script src="/static/app.js"></script>