Compare commits

..

12 Commits

Author SHA1 Message Date
Laurent Paoletti 40b2f77c55 wip 2026-02-02 15:50:23 +01:00
Quentin BEY fb297b97e6 🔇(ci) don't run trivy on mail generation yarn.lock
We don't use email generation for now, and we don't expose
anything from this yarn file.
2026-01-30 14:58:51 +01:00
Quentin BEY 7858476b84 ⬆️(next) bump version to 5.3.9
Fixes GHSA-h25m-26qc-wcjf
2026-01-30 14:47:47 +01:00
Quentin BEY c02254ec93 ⬆️(protobuf) bump version to 6.33.5
Bump with `uv lock --upgrade-package protobuf`
This fixes CVE-2026-0994
2026-01-30 14:11:27 +01:00
Quentin BEY 853305ae74 🧱(files) allow to use S3 storage without external access
Some architectures do not expose their S3, in such cases
it is only available through the backend.

This commit proposes two implementations to manage this:
- frontend can now upload files to the backend (no direct access
  to S3)
- two new modes to send file to the LLM: a temporary URL on the
  backend, or directly the file in b64.
2026-01-29 22:30:15 +01:00
Laurent Paoletti ab2ad0348b 🏗️(back) migrate to uv
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-29 15:27:49 +01:00
qbey d752334b5e 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-27 16:41:55 +01:00
Quentin BEY 30f825c337 🔖(patch) bump release to 0.0.12
Fixed

- ️(front) performance improvements on chat input
- 💄(front) i18n and standardize pdf parsing display

Removed

- 🔥(chat) consider PDF documents as other kind of documents #234
2026-01-27 16:37:54 +01:00
natoromano f52a27f218 (front) i18n and standardize pdf parsing display
fix: align i18n key

🌐fix: keep filename

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-27 12:13:36 +01:00
Laurent Paoletti 3e467cacf2 🐛(back) fix keepalives not sent during document parsing
Wrap document_store.parse_and_store_document() calls with
  asyncio.to_thread() to prevent blocking the event loop.

  Previously, synchronous document parsing (e.g., PDF) blocked the
  event loop, preventing keepalive messages from being sent and
  causing nginx timeouts in production

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-26 16:20:25 +01:00
Laurent Paoletti 120b204729 ️(front) chat input performance improvements
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-22 12:19:27 +01:00
charles d9078e75e5 🚨(backend) fix tests
I am removing hard coded datetime.
2026-01-22 11:39:47 +01:00
93 changed files with 7637 additions and 1428 deletions
+1
View File
@@ -8,6 +8,7 @@ skip =
**/node_modules/**,
**/e2e/report/**,
*.tsbuildinfo,
**/uv.lock,
check-filenames = true
ignore-words-list =
afterAll,
+30 -19
View File
@@ -85,20 +85,24 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
python-version: "3.13"
- name: Install system dependencies for lxml
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxslt-dev
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -181,25 +185,28 @@ jobs:
mc version enable conversations/conversations-media-storage"
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
python-version: "3.13"
- name: Install system dependencies for lxml
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxslt-dev
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages) and MIME support
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
security-trivy-critical:
permissions:
@@ -210,6 +217,8 @@ jobs:
- name: Run Trivy analysis for critical vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy-critical@main
with:
skip-files: src/mail/yarn.lock
security-trivy:
permissions:
@@ -219,3 +228,5 @@ jobs:
- name: Run Trivy analysis for vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy@main
with:
skip-files: src/mail/yarn.lock
-3
View File
@@ -44,9 +44,6 @@ env.d/development/*
!env.d/development/*.dist
env.d/terraform
# Configuration
**/conversations/configuration/llm/dev.json
# npm
node_modules
+15 -2
View File
@@ -10,7 +10,18 @@ and this project adheres to
### Added
- ✨(backend) add FindRagBackend
- 🧱(files) allow to use S3 storage without external access #849
### Changed
- 🏗️(back) migrate to uv
## [0.0.12] - 2026-01-27
### Fixed
- ⚡️(front) performance improvements on chat input
- 💄(front) i18n and standardize pdf parsing display
### Removed
@@ -23,6 +34,7 @@ and this project adheres to
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
### Fixed
- 🐛(e2e) fix test-e2e-chromium
@@ -188,7 +200,8 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.11...main
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.12...main
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
+32 -22
View File
@@ -3,9 +3,6 @@
# ---- base image to inherit from ----
FROM python:3.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
@@ -13,21 +10,31 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
# Install Rust and Cargo using Alpine's package manager
RUN apk add --no-cache \
build-base \
libffi-dev \
libxml2-dev \
libxslt-dev \
rust \
cargo
# Copy required python dependencies
COPY ./src/backend /builder
RUN mkdir /install && \
pip install --prefix=/install .
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:24 AS mail-builder
@@ -49,14 +56,16 @@ RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Copy conversations application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
@@ -79,6 +88,8 @@ RUN apk add \
gettext \
gdk-pixbuf \
libffi-dev \
libxml2 \
libxslt \
pango \
shared-mime-info
@@ -92,17 +103,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy conversations application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
@@ -119,10 +130,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall conversations and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y conversations
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
+1 -1
View File
@@ -231,7 +231,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
+1 -11
View File
@@ -71,12 +71,10 @@ services:
- "host.docker.internal:host-gateway"
ports:
- "8071:8000"
networks:
- default
- lasuite
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
@@ -92,9 +90,6 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
- default
- lasuite
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -183,8 +178,3 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge
-3
View File
@@ -95,9 +95,6 @@ These are the environment variables you can set for the `conversations-backend`
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | conversations |
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/conversations/configuration/theme/default.json | BASE_DIR/conversations/configuration/theme/default.json |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| FIND_API_KEY | API key of Find | |
| FIND_API_URL | URL of Find | `https://app-find/api` |
| FIND_API_TIMEOUT | Find API timeout | 30 |
## conversations-frontend image
+159
View File
@@ -0,0 +1,159 @@
# File Upload Modes
This document describes the different modes for handling file uploads in the Conversations application, and how to configure and use them.
## Overview
The application supports two independent configuration points:
1. **`FILE_UPLOAD_MODE`**: how the frontend uploads files (frontend → storage/backend)
2. **`FILE_TO_LLM_MODE`**: how the backend provides files to the LLM (backend → LLM)
Each mode has different trade-offs in terms of security, performance, and LLM accessibility. The two settings can be combined based on your network constraints.
## Configuration
### Frontend upload mode (`FILE_UPLOAD_MODE`)
```bash
# Default: presigned URL upload (backward compatible)
FILE_UPLOAD_MODE=presigned_url
# Frontend uploads directly to backend
FILE_UPLOAD_MODE=backend_to_s3
```
### Backend delivery mode (`FILE_TO_LLM_MODE`)
```bash
# Default: presigned URL mode (backward compatible)
FILE_TO_LLM_MODE=presigned_url
# Backend provides base64-encoded data URLs
FILE_TO_LLM_MODE=backend_base64
# Backend provides temporary URLs through the backend
FILE_TO_LLM_MODE=backend_temporary_url
```
Additional settings for backend temporary URL mode:
```bash
# Base URL to reach backend
FILE_BACKEND_URL="http://localhost:8071"
# Expiration time for temporary URLs (in seconds, default: 180 = 3 minutes)
FILE_BACKEND_TEMPORARY_URL_EXPIRATION=180
```
## Mode Details
### 1. Presigned URL Mode (Default)
**Frontend upload configuration:** `FILE_UPLOAD_MODE=presigned_url`
**Backend delivery configuration:** `FILE_TO_LLM_MODE=presigned_url`
**How it works:**
- Frontend requests a presigned URL from the backend
- Frontend uploads the file directly to S3 using the presigned URL
- Frontend notifies the backend when upload is complete
- Backend initiates malware detection
- Backend returns presigned S3 URLs to the LLM
**Advantages:**
- Files don't pass through the backend server (lower bandwidth usage)
- Faster uploads for large files (direct to S3)
- S3 handles the upload, no backend load
- Backward compatible with existing frontend implementations
**Disadvantages:**
- S3 bucket must be accessible from the frontend
- Presigned URLs can be leaked if not handled carefully
- Frontend needs to handle S3 credentials/configuration
**LLM Access:**
- Images: Presigned S3 URLs with expiration (default: 3 minutes)
- Documents: Presigned S3 URLs with expiration (default: 3 minutes)
**When to use:**
- When frontend has direct access to S3
- When you want to minimize backend load
- When S3 is publicly accessible or accessible via VPN
### 2. Backend Base64 Mode
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_base64`
**How it works:**
- Frontend uploads the file directly to the backend
- Backend stores the file on S3
- Backend reads the file, encodes it as base64, and creates a data URL
- LLM receives the file as a base64-encoded data URL
**Advantages:**
- S3 can be private/internal (not accessible from frontend)
- Files always go through the backend for validation
- No presigned URLs to manage
- Better control over file access
- Data URLs work with all LLMs that support file content
**Disadvantages:**
- Backend memory usage increases (entire file loaded for base64 encoding)
- Slower for very large files (encoding overhead)
- Increased bandwidth on backend
- Data URLs can be very large in responses
**LLM Access:**
- Images: Base64-encoded data URLs (format: `data:image/png;base64,...`)
- Documents: Base64-encoded data URLs (format: `data:application/pdf;base64,...`)
**When to use:**
- When S3 is not accessible from the frontend
- When you want all file uploads to go through the backend
- When the LLM supports base64-encoded data URLs
- For smaller files (< 50MB)
### 3. Backend Temporary URL Mode
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_temporary_url`
**How it works:**
- Frontend uploads the file directly to the backend
- Backend stores the file on S3
- Backend generates a secure temporary access token stored in cache (TTL: 3 minutes by default)
- Backend returns a temporary URL pointing to the backend's file-stream endpoint
- LLM receives the temporary URL and accesses the file through the backend
- Backend validates the token and streams the file content from S3 to the LLM
**Advantages:**
- S3 can be private/internal (not accessible from frontend or LLM directly)
- Files always go through the backend for validation and access control
- LLM doesn't need direct access to S3
- Tokens expire quickly (better security than long-lived presigned URLs)
- No large data URL strings in memory or responses
- Lower backend memory usage than base64 mode
- Centralized file access control through the backend
- Good balance between security and performance
**Disadvantages:**
- LLM must be able to access the backend server
- File streaming goes through the backend (adds some latency)
- Time-limited access (token expires)
**LLM Access:**
- Images: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
- Documents: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
**When to use:**
- When S3 is not accessible from the frontend or LLM
- When you want backend control over uploads and file access
- When you want time-limited access to files with centralized control
- When you want the LLM to access files through the backend gateway
- For large files (backend streams directly from S3 without loading entirely into memory)
+3 -3
View File
@@ -244,9 +244,9 @@ For Mistral AI models using the Etalab platform:
{
"models": [
{
"hrid": "mistral-medium",
"model_name": "mistral-medium-2508",
"human_readable_name": "Mistral Medium (Etalab)",
"hrid": "mistral-large",
"model_name": "mistral-large-latest",
"human_readable_name": "Mistral Large (Etalab)",
"provider_name": "mistral-etalab",
"profile": null,
"settings": {
-1
View File
@@ -357,7 +357,6 @@ The RAG backend performs semantic search to find the most relevant content:
rag_results = document_store.search(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
**kwargs, # Additional search parameters like session with access_token
)
```
@@ -1,7 +1,6 @@
"""Document parsers for RAG backends."""
import logging
from io import BytesIO
from urllib.parse import urljoin
from django.conf import settings
@@ -16,7 +15,7 @@ logger = logging.getLogger(__name__)
class BaseParser:
"""Base class for document parsers."""
def parse_document(self, name: str, content_type: str, content: BytesIO) -> str:
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
@@ -25,7 +24,7 @@ class BaseParser:
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
content (bytes): The content of the document as a bytes stream.
Returns:
str: The document content in Markdown format.
@@ -13,7 +13,7 @@ import requests
from chat.agent_rag.albert_api_constants import Searches
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
from chat.agent_rag.document_converter.parser import AlbertParser
from chat.agent_rag.document_converter.markitdown import DocumentConverter
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
logger = logging.getLogger(__name__)
@@ -26,6 +26,9 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
It provides methods to:
- Create a collection for the search operation.
- Parse documents and convert them to Markdown format:
+ Handle PDF parsing using the Albert API.
+ Use the DocumentConverter (markitdown) for other formats.
- Store parsed documents in the Albert collection.
- Perform a search operation using the Albert API.
"""
@@ -43,9 +46,10 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
}
self._collections_endpoint = urljoin(self._base_url, "/v1/collections")
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
self._pdf_parser_endpoint = urljoin(self._base_url, "/v1/parse-beta")
self._search_endpoint = urljoin(self._base_url, "/v1/search")
self._default_collection_description = "Temporary collection for RAG document search"
self.parser = AlbertParser()
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
@@ -87,7 +91,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
self.collection_id = str(response.json()["id"])
return self.collection_id
def delete_collection(self, **kwargs) -> None:
def delete_collection(self) -> None:
"""
Delete the current collection
"""
@@ -98,7 +102,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
)
response.raise_for_status()
async def adelete_collection(self, **kwargs) -> None:
async def adelete_collection(self) -> None:
"""
Asynchronously delete the current collection
"""
@@ -110,7 +114,59 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
)
response.raise_for_status()
def store_document(self, name: str, content: str, **kwargs) -> None:
def parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the PDF document content and return the text content.
This method should handle the logic to convert the PDF into
a format suitable for the Albert API.
"""
response = requests.post(
self._pdf_parser_endpoint,
headers=self._headers,
files={
"file": (
name,
content,
content_type,
), # Use the name as the filename in the request
"output_format": (None, "markdown"), # Specify the output format as Markdown,
},
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
)
response.raise_for_status()
return "\n\n".join(
document_page["content"] for document_page in response.json().get("data", [])
)
def parse_document(self, name: str, content_type: str, content: BytesIO):
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
into a format suitable for the Albert API.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
Returns:
str: The document content in Markdown format.
"""
# Implement the parsing logic here
if content_type == "application/pdf":
# Handle PDF parsing
markdown_content = self.parse_pdf_document(
name=name, content_type=content_type, content=content
)
else:
markdown_content = DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
return markdown_content
def store_document(self, name: str, content: str) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
@@ -118,7 +174,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments.
"""
response = requests.post(
urljoin(self._base_url, self._documents_endpoint),
@@ -133,7 +188,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
async def astore_document(self, name: str, content: str, **kwargs) -> None:
async def astore_document(self, name: str, content: str) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
@@ -141,7 +196,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments.
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
@@ -159,14 +213,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
def search(self, query, results_count: int = 4) -> RAGWebResults:
"""
Perform a search using the Albert API based on the provided query.
Args:
query (str): The search query.
results_count (int): The number of results to return.
**kwargs: Additional arguments.
Returns:
RAGWebResults: The search results.
@@ -203,14 +256,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
),
)
async def asearch(self, query, results_count: int = 4, **kwargs) -> RAGWebResults:
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
"""
Perform an asynchronous search using the Albert API based on the provided query.
Args:
query (str): The search query.
results_count (int): The number of results to return.
**kwargs: Additional arguments.
Returns:
RAGWebResults: The search results.
@@ -1,7 +1,6 @@
"""Implementation of the Albert API for RAG document search."""
import logging
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager, contextmanager
from io import BytesIO
from typing import List, Optional
@@ -9,12 +8,11 @@ from typing import List, Optional
from asgiref.sync import sync_to_async
from chat.agent_rag.constants import RAGWebResults
from chat.agent_rag.document_converter.parser import BaseParser
logger = logging.getLogger(__name__)
class BaseRagBackend(ABC):
class BaseRagBackend:
"""Base class for RAG backends."""
def __init__(
@@ -40,7 +38,6 @@ class BaseRagBackend(ABC):
self.collection_id = collection_id
self.read_only_collection_id = read_only_collection_id or []
self._default_collection_description = "Temporary collection for RAG document search"
self.parser: BaseParser = BaseParser()
def get_all_collection_ids(self) -> List[str]:
"""
@@ -56,14 +53,13 @@ class BaseRagBackend(ABC):
collection_ids = []
if self.collection_id:
collection_ids.append(self.collection_id)
collection_ids.append(int(self.collection_id))
if self.read_only_collection_id:
collection_ids.extend(
[int(collection_id) for collection_id in self.read_only_collection_id]
)
return collection_ids
@abstractmethod
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
@@ -92,10 +88,9 @@ class BaseRagBackend(ABC):
Returns:
str: The document content in Markdown format.
"""
return self.parser.parse_document(name, content_type, content)
raise NotImplementedError("Must be implemented in subclass.")
@abstractmethod
def store_document(self, name: str, content: str, **kwargs) -> None:
def store_document(self, name: str, content: str) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
@@ -103,11 +98,10 @@ class BaseRagBackend(ABC):
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def astore_document(self, name: str, content: str, **kwargs) -> None:
async def astore_document(self, name: str, content: str) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
@@ -115,13 +109,10 @@ class BaseRagBackend(ABC):
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
return await sync_to_async(self.store_document)(name=name, content=content, **kwargs)
return await sync_to_async(self.store_document)(name=name, content=content)
def parse_and_store_document(
self, name: str, content_type: str, content: BytesIO, **kwargs
) -> str:
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the document and store it in the Albert collection.
@@ -129,52 +120,39 @@ class BaseRagBackend(ABC):
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
if not self.collection_id:
raise RuntimeError("The RAG backend requires collection_id")
document_content = self.parse_document(name, content_type, content)
self.store_document(name, document_content, **kwargs)
self.store_document(name, document_content)
return document_content
@abstractmethod
def delete_collection(self, **kwargs) -> None:
def delete_collection(self) -> None:
"""
Delete the collection.
This method should handle the logic to delete the collection from the backend.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def adelete_collection(self, **kwargs) -> None:
async def adelete_collection(self) -> None:
"""
Delete the collection.
This method should handle the logic to delete the collection from the backend.
"""
return await sync_to_async(self.delete_collection)(**kwargs)
return await sync_to_async(self.delete_collection)()
@abstractmethod
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
def search(self, query, results_count: int = 4) -> RAGWebResults:
"""
Search the collection for the given query.
Args:
query: The search query string.
results_count: Number of results to return.
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def asearch(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
"""
Search the collection for the given query asynchronously.
Args:
query: The search query string.
results_count: Number of results to return.
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
Search the collection for the given query.
"""
return await sync_to_async(self.search)(query=query, results_count=results_count, **kwargs)
return await sync_to_async(self.search)(query=query, results_count=results_count)
@classmethod
@contextmanager
@@ -190,9 +168,7 @@ class BaseRagBackend(ABC):
@classmethod
@asynccontextmanager
async def temporary_collection_async(
cls, name: str, description: Optional[str] = None, **kwargs
):
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
"""Context manager for RAG backend with temporary collections."""
backend = cls()
@@ -200,4 +176,4 @@ class BaseRagBackend(ABC):
try:
yield backend
finally:
await backend.adelete_collection(**kwargs)
await backend.adelete_collection()
@@ -1,163 +0,0 @@
"""Implementation of the Find API for RAG document search."""
import logging
import uuid
from typing import List, Optional
from urllib.parse import urljoin
from uuid import uuid4
from django.conf import settings
from django.utils import timezone
import requests
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
from chat.agent_rag.document_converter.parser import AlbertParser
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
from utils.oidc import with_fresh_access_token
logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGE_CODES = ["en", "fr", "de", "nl"]
class FindRagBackend(BaseRagBackend):
"""
This class is a placeholder for the Find API implementation.
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
It provides methods to:
- Store parsed documents in the Find index.
- Perform a search operation using the Find API.
"""
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
# Initialize any necessary parameters or configurations here
super().__init__(collection_id, read_only_collection_id)
self.api_key = settings.FIND_API_KEY
self.search_endpoint = "api/v1.0/documents/search/"
self.indexing_endpoint = "api/v1.0/documents/index/"
self.deleting_endpoint = "api/v1.0/documents/delete/"
self.parser = AlbertParser() # Find Rag relies on Albert parser
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
init collection_id
"""
self.collection_id = self.collection_id or str(uuid.uuid4())
return self.collection_id
@with_fresh_access_token
def delete_collection(self, **kwargs) -> None:
"""
Delete the current collection
"""
response = requests.post(
urljoin(settings.FIND_API_URL, self.deleting_endpoint),
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
json={
"tags": [f"collection-{self.collection_id}"],
# "service": "conversations"
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
def store_document(self, name: str, content: str, **kwargs) -> None:
"""
index document in Find
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
user_sub (str): The user subject identifier for access control.
"""
logger.debug("index document '%s' in Find", name)
user_sub = kwargs.get("user_sub")
if not user_sub:
raise ValueError("user_sub is required to store document in FindRagBackend")
response = requests.post(
urljoin(settings.FIND_API_URL, self.indexing_endpoint),
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"id": str(uuid4()),
"title": str(name) or "",
"depth": 0,
"path": str(name) or "",
"numchild": 0,
"content": content or "",
"created_at": timezone.now().isoformat(),
"updated_at": timezone.now().isoformat(),
"tags": [f"collection-{self.collection_id}"],
"size": len(content.encode("utf-8")),
"users": [user_sub],
"groups": [],
"reach": "authenticated",
"is_active": True,
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
@with_fresh_access_token
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
Perform a search using the Find API.
Uses the user's OIDC token from the request session.
Args:
query: The search query.
results_count: Number of results to return.
**kwargs: Additional arguments. Expected: 'session' containing OIDC tokens,
Returns:
RAGWebResults: The search results.
"""
logger.debug("search documents in Find with query '%s'", query)
response = requests.post(
urljoin(settings.FIND_API_URL, self.search_endpoint),
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
json={
"q": query or "*",
"tags": [
f"collection-{collection_id}" for collection_id in self.get_all_collection_ids()
],
"k": results_count,
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
return RAGWebResults(
data=[
RAGWebResult(
url=get_language_value(result["_source"], "title"),
content=get_language_value(result["_source"], "content"),
score=result["_score"],
)
for result in response.json()
],
usage=RAGWebUsage(
prompt_tokens=0,
completion_tokens=0,
),
)
def get_language_value(source, language_field):
"""
extract the value of the language field with the correct language_code extension.
"title" and "content" have extensions like "title.en" or "title.fr".
get_language_value will return the value regardless of the extension.
"""
for language_code in SUPPORTED_LANGUAGE_CODES:
if f"{language_field}.{language_code}" in source:
return source[f"{language_field}.{language_code}"]
raise ValueError(f"No '{language_field}' field with any supported language code in object")
@@ -6,14 +6,103 @@ for the LLM to access them, and then reverting them back to local URLs when
storing the messages in the database.
"""
import base64
import logging
import mimetypes
import secrets
from typing import Dict, Iterable
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
from pydantic_ai import DocumentUrl, ImageUrl, ModelMessage, ModelRequest, UserPromptPart
from core.file_upload.enums import FileToLLMMode
from core.file_upload.utils import generate_retrieve_policy
from chat.models import ChatConversation
logger = logging.getLogger(__name__)
def generate_temporary_url(key: str) -> str:
"""
Generate a temporary URL for accessing a file through the backend.
Instead of using S3 presigned URLs, this creates a temporary access key
that's stored in cache (3 minutes TTL). The LLM accesses the file through
a backend endpoint that validates the key and streams the file content.
This approach:
- Works even when S3 is not accessible from the LLM
- Provides better security (key is time-limited and single-use)
- Allows the backend to control file access centrally
Args:
key (str): The S3 object key where the file is stored.
Returns:
str: A temporary URL with format: /api/v1.0/file-stream/{temporary_key}/
"""
# Generate a secure random key
temporary_key = secrets.token_urlsafe(32)
# Store the S3 key in cache
cache_key = f"file_access:{temporary_key}"
cache.set(cache_key, key, timeout=settings.FILE_BACKEND_TEMPORARY_URL_EXPIRATION)
logger.info("Generated temporary file access key for S3 key: %s", key)
# Return the URL that the LLM will use to access the file
return f"{settings.FILE_BACKEND_URL}/api/v1.0/file-stream/{temporary_key}/"
def _get_file_url_for_llm(key: str, mode: str | None = None) -> str:
"""
Get the appropriate URL for the LLM to access a file based on the upload mode.
Args:
key (str): The S3 object key where the file is stored.
mode (str, optional): The upload mode. Defaults to FILE_TO_LLM_MODE setting.
Returns:
str: The URL or data URL for the LLM to use.
Supported modes:
- presigned_url: Returns a presigned S3 URL (default)
- backend_temporary_url: Returns a presigned URL with shorter expiration
- backend_base64: Returns a data URL with base64-encoded file content
"""
if mode is None:
mode = settings.FILE_TO_LLM_MODE
if mode == FileToLLMMode.BACKEND_BASE64:
# Read file from S3 and encode as base64 data URL
try:
with default_storage.open(key, "rb") as file:
file_content = file.read()
# Detect MIME type from file extension or default to octet-stream
mime_type, _ = mimetypes.guess_type(key)
if not mime_type:
mime_type = "application/octet-stream"
# Create data URL
b64_content = base64.b64encode(file_content).decode("utf-8")
return f"data:{mime_type};base64,{b64_content}"
except Exception: # pylint: disable=broad-except
# Fall back to presigned URL on error
logger.exception(
"Failed to read file for base64 encoding, falling back to presigned URL"
)
return generate_retrieve_policy(key)
elif mode == FileToLLMMode.BACKEND_TEMPORARY_URL:
return generate_temporary_url(key)
# FileToLLMMode.PRESIGNED_URL or default
return generate_retrieve_policy(key)
def update_local_urls(
conversation: ChatConversation,
@@ -21,7 +110,9 @@ def update_local_urls(
updated_url: Dict[str, str] | None = None,
) -> Iterable[ImageUrl | DocumentUrl]:
"""
Replace local image or document URLs in the content list to use presigned S3 URLs.
Replace local image or document URLs in the content list to use appropriate S3 URLs
based on the configured FILE_TO_LLM_MODE.
⚠️Be careful, `media_contents` are replaced in place.
Args:
@@ -31,7 +122,7 @@ def update_local_urls(
mapping of original URLs to updated URLs.
Returns:
Iterable[ImageUrl | DocumentUrl]: Updated iterable of UserContent objects
with presigned URLs.
with appropriate S3 URLs based on the configured mode.
"""
# When images are stored locally, there is no host in the URL, so we can
# just check if the URL starts, frontend adds a prefix `/media-key/` to the key.
@@ -41,7 +132,9 @@ def update_local_urls(
# Filter only ImageUrl contents
media_contents = (c for c in contents if isinstance(c, (ImageUrl, DocumentUrl)))
# Replace URLs with presigned URLs
# Replace URLs with appropriate S3 URLs based on mode
upload_mode = settings.FILE_TO_LLM_MODE
for content in media_contents:
idx = content.url.find(local_media_url_prefix)
@@ -57,7 +150,7 @@ def update_local_urls(
# except if the user tampers with the conversation.
continue
content.url = generate_retrieve_policy(key)
content.url = _get_file_url_for_llm(key, upload_mode)
if updated_url is not None:
updated_url[content.url] = _initial_url
@@ -68,7 +161,7 @@ def update_history_local_urls(
conversation: ChatConversation, messages: list[ModelMessage]
) -> list[ModelMessage]:
"""
Replace local image/documents URLs in the message list to use presigned S3 URLs.
Replace local image/documents URLs in the message list to use appropriate S3 URLs.
⚠️Be careful, `messages` are replaced in place.
@@ -79,7 +172,7 @@ def update_history_local_urls(
Args:
messages (list[ModelMessage]): List of ModelMessage objects.
Returns:
list[ModelMessage]: Updated list of ModelMessage objects with presigned URLs.
list[ModelMessage]: Updated list of ModelMessage objects with appropriate S3 URLs.
"""
# Filter only ModelRequest messages
requests = (msg for msg in messages if isinstance(msg, ModelRequest))
+8 -14
View File
@@ -6,6 +6,7 @@ implementation while keeping the *exact* same public API so that no
changes are needed in views.py or tests.
"""
import asyncio
import dataclasses
import functools
import json
@@ -91,7 +92,6 @@ class ContextDeps:
conversation: models.ChatConversation
user: User
session: Optional[Dict] = None
web_search_enabled: bool = False
@@ -106,14 +106,7 @@ def get_model_configuration(model_hrid: str):
class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Service class for AI-related operations (Pydantic-AI edition)."""
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
conversation: models.ChatConversation,
user,
session=None,
model_hrid=None,
language=None,
):
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None):
"""
Initialize the AI agent service.
@@ -143,7 +136,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self._context_deps = ContextDeps(
conversation=conversation,
user=user,
session=session,
web_search_enabled=self._is_web_search_enabled,
)
@@ -282,21 +274,23 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
# Retrieve the document data
with default_storage.open(key, "rb") as file:
document_data = file.read()
parsed_content = document_store.parse_and_store_document(
# Run in thread to avoid blocking the event loop during parsing
parsed_content = await asyncio.to_thread(
document_store.parse_and_store_document,
name=document.identifier,
content_type=document.media_type,
content=document_data,
user_sub=self.user.sub,
)
else:
# Remote URL
raise ValueError("External document URL are not accepted yet.")
else:
parsed_content = document_store.parse_and_store_document(
# Run in thread to avoid blocking the event loop during parsing
parsed_content = await asyncio.to_thread(
document_store.parse_and_store_document,
name=document.identifier,
content_type=document.media_type,
content=document.data,
user_sub=self.user.sub,
)
if not document.media_type.startswith("text/"):
+15 -5
View File
@@ -10,7 +10,7 @@ from django_pydantic_field.rest_framework import SchemaField # pylint: disable=
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.file_upload.enums import AttachmentStatus
from core.file_upload.enums import AttachmentStatus, FileUploadMode
from core.file_upload.utils import generate_upload_policy
from chat import models
@@ -180,7 +180,11 @@ class ChatConversationAttachmentSerializer(serializers.ModelSerializer):
class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
"""Serializer for creating chat conversation attachments."""
"""Serializer for creating chat conversation attachments.
For presigned_url mode: returns 'policy' field with presigned URL for direct S3 upload
For backend modes: does not return 'policy' field (upload handled via backend endpoint)
"""
policy = serializers.SerializerMethodField()
uploaded_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
@@ -190,9 +194,15 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
model = models.ChatConversationAttachment
fields = ["id", "key", "content_type", "file_name", "size", "policy", "uploaded_by"]
def get_policy(self, attachment) -> str:
"""Return the policy to use if the item is a file."""
return generate_upload_policy(attachment.key)
def get_policy(self, attachment) -> str | None:
"""Return the policy (presigned URL) only for presigned_url mode."""
upload_mode = settings.FILE_UPLOAD_MODE
# Only return presigned URL in presigned_url mode
if upload_mode == FileUploadMode.PRESIGNED_URL:
return generate_upload_policy(attachment.key)
return None
def validate_size(self, size: Optional[int]) -> Optional[int]:
"""Validate that the size is not greater than the maximum allowed size."""
@@ -1,5 +1,6 @@
"""Tests for local_media_url_processors."""
from io import BytesIO
from unittest.mock import patch
import pytest
@@ -12,7 +13,10 @@ from pydantic_ai import (
UserPromptPart,
)
from core.file_upload.enums import FileToLLMMode
from chat.agents.local_media_url_processors import (
_get_file_url_for_llm,
update_history_local_urls,
update_local_urls,
)
@@ -121,3 +125,549 @@ def test_update_history_local_urls_no_user_prompt_parts():
result = update_history_local_urls(conversation, messages)
assert result == messages
mock.assert_not_called()
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_local_urls_uses_get_file_url_for_llm(mock_get_file_url):
"""Test that update_local_urls uses _get_file_url_for_llm for mode-aware URLs."""
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "mode-aware-url"
key = f"{conversation.pk}/test.jpg"
contents = [ImageUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
assert result[0].url == "mode-aware-url"
mock_get_file_url.assert_called_once()
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_get_file_url_presigned_mode(mock_policy):
"""Test URL generation in presigned_url mode."""
mock_policy.return_value = "presigned_s3_url"
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.PRESIGNED_URL)
assert url == "presigned_s3_url"
mock_policy.assert_called_once_with("test/key.pdf")
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
def test_get_file_url_backend_temporary_url_mode(mock_temp_url):
"""Test URL generation in backend_temporary_url mode."""
mock_temp_url.return_value = "/api/v1.0/file-stream/token123/"
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_TEMPORARY_URL)
assert url == "/api/v1.0/file-stream/token123/"
mock_temp_url.assert_called_once_with("test/key.pdf")
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_get_file_url_backend_base64_mode(mock_storage):
"""Test URL generation in backend_base64 mode."""
# Mock file content
file_content = b"PDF binary content"
mock_file = BytesIO(file_content)
mock_storage.return_value.__enter__.return_value = mock_file
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
# URL should be a data URL
assert url.startswith("data:")
assert "base64" in url
mock_storage.assert_called_once()
@patch(
"chat.agents.local_media_url_processors.default_storage.open", side_effect=Exception("S3 error")
)
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_get_file_url_backend_base64_fallback(mock_policy, _mock_storage):
"""Test fallback to presigned URL when base64 encoding fails."""
mock_policy.return_value = "fallback_presigned_url"
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
# Should fall back to presigned URL
assert url == "fallback_presigned_url"
mock_policy.assert_called_once()
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_local_urls_multiple_images_with_modes(mock_get_file_url):
"""Test handling multiple images with mode-aware URL generation."""
conversation = ChatConversationFactory()
# Mock different URLs for different calls
urls = ["url1", "url2", "url3"]
mock_get_file_url.side_effect = urls
key1 = f"{conversation.pk}/image1.jpg"
key2 = f"{conversation.pk}/image2.png"
key3 = f"{conversation.pk}/document.pdf"
contents = [
ImageUrl(url=f"/media-key/{key1}"),
ImageUrl(url=f"/media-key/{key2}"),
DocumentUrl(url=f"/media-key/{key3}"),
]
result = list(update_local_urls(conversation, contents))
assert len(result) == 3
assert result[0].url == "url1"
assert result[1].url == "url2"
assert result[2].url == "url3"
assert mock_get_file_url.call_count == 3
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_local_urls_mixed_external_and_local_urls(mock_get_file_url):
"""Test handling of mixed external and local URLs."""
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "mode-aware-url"
key = f"{conversation.pk}/test.jpg"
contents = [
ImageUrl(url=f"/media-key/{key}"), # Local URL - will be processed
ImageUrl(url="https://external.com/image.jpg"), # External URL - kept as is
ImageUrl(url="http://another.com/image.png"), # External URL - kept as is
]
result = list(update_local_urls(conversation, contents))
assert len(result) == 3
assert result[0].url == "mode-aware-url"
assert result[1].url == "https://external.com/image.jpg"
assert result[2].url == "http://another.com/image.png"
# Only one local URL was processed
assert mock_get_file_url.call_count == 1
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_history_local_urls_with_mode_detection(mock_get_file_url):
"""Test that update_history_local_urls uses mode detection for URLs."""
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "mode-aware-url"
key = f"{conversation.pk}/test.jpg"
user_prompt_content = [ImageUrl(url=f"/media-key/{key}")]
messages = [
ModelRequest(parts=[UserPromptPart(content=user_prompt_content)]),
ModelResponse(parts=[TextPart(content="I see your image.")]),
]
with patch("chat.agents.local_media_url_processors.update_local_urls") as mock_update:
mock_update.return_value = iter([ImageUrl(url="mode-aware-url")])
result = update_history_local_urls(conversation, messages)
assert len(result) == 2
mock_update.assert_called_once()
def test_update_local_urls_preserves_other_url_types():
"""Test that update_local_urls preserves other URL types unchanged."""
conversation = ChatConversationFactory()
contents = [
ImageUrl(url="data:image/png;base64,iVBORw0KG..."), # Already data URL
ImageUrl(url="https://example.com/image.jpg"), # HTTPS
ImageUrl(url="http://example.com/image.jpg"), # HTTP
]
result = list(update_local_urls(conversation, contents))
assert len(result) == 3
assert result[0].url == "data:image/png;base64,iVBORw0KG..."
assert result[1].url == "https://example.com/image.jpg"
assert result[2].url == "http://example.com/image.jpg"
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_local_urls_stores_updated_urls_mapping(mock_get_file_url):
"""Test that update_local_urls stores the mapping of new to old URLs."""
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "new-mode-aware-url"
key = f"{conversation.pk}/test.jpg"
old_url = f"/media-key/{key}"
contents = [ImageUrl(url=old_url)]
updated_urls = {}
list(update_local_urls(conversation, contents, updated_urls))
assert "new-mode-aware-url" in updated_urls
assert updated_urls["new-mode-aware-url"] == old_url
def test_update_local_urls_security_prevents_other_conversation_access():
"""Test that security check prevents accessing other conversation's files."""
conversation = ChatConversationFactory()
other_conversation_key = "other-uuid/attachments/file.jpg"
# Try to access file from different conversation
contents = [ImageUrl(url=f"/media-key/{other_conversation_key}")]
with patch("chat.agents.local_media_url_processors._get_file_url_for_llm") as mock_get:
result = list(update_local_urls(conversation, contents))
# URL should not be processed (security check failed)
assert result[0].url == f"/media-key/{other_conversation_key}"
# _get_file_url_for_llm should NOT be called
mock_get.assert_not_called()
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_get_file_url_is_called_with_correct_arguments(mock_get_file_url):
"""Test that _get_file_url_for_llm is called with correct arguments."""
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "processed-url"
key = f"{conversation.pk}/test.jpg"
contents = [ImageUrl(url=f"/media-key/{key}")]
list(update_local_urls(conversation, contents))
# Verify the function was called with the S3 key (without /media-key/ prefix)
mock_get_file_url.assert_called_once()
call_args = mock_get_file_url.call_args
assert call_args[0][0] == key # First argument should be the S3 key
# ==================== Tests for FILE_TO_LLM_MODE settings ====================
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_update_local_urls_with_presigned_url_mode(mock_policy, settings):
"""Test update_local_urls with PRESIGNED_URL mode."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
conversation = ChatConversationFactory()
mock_policy.return_value = "https://s3.example.com/presigned-url"
key = f"{conversation.pk}/test.jpg"
contents = [ImageUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
assert result[0].url == "https://s3.example.com/presigned-url"
mock_policy.assert_called_once_with(key)
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
def test_update_local_urls_with_backend_temporary_url_mode(mock_temp_url, settings):
"""Test update_local_urls with BACKEND_TEMPORARY_URL mode."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
conversation = ChatConversationFactory()
mock_temp_url.return_value = "/api/v1.0/file-stream/temp-token-123/"
key = f"{conversation.pk}/test.pdf"
contents = [DocumentUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
assert result[0].url == "/api/v1.0/file-stream/temp-token-123/"
mock_temp_url.assert_called_once_with(key)
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_update_local_urls_with_backend_base64_mode(mock_storage, settings):
"""Test update_local_urls with BACKEND_BASE64 mode."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
conversation = ChatConversationFactory()
file_content = b"Mock image binary content"
mock_file = BytesIO(file_content)
mock_storage.return_value.__enter__.return_value = mock_file
key = f"{conversation.pk}/test.jpg"
contents = [ImageUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
# Should be a data URL
assert result[0].url.startswith("data:")
assert "base64" in result[0].url
mock_storage.assert_called_once()
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_update_local_urls_backend_base64_fallback_on_error(mock_storage, mock_policy, settings):
"""Test that update_local_urls falls back to presigned URL when base64 fails."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
conversation = ChatConversationFactory()
mock_storage.side_effect = Exception("S3 connection error")
mock_policy.return_value = "https://s3.example.com/fallback-url"
key = f"{conversation.pk}/test.jpg"
contents = [ImageUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
# Should fall back to presigned URL
assert result[0].url == "https://s3.example.com/fallback-url"
mock_policy.assert_called_once_with(key)
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_update_local_urls_multiple_files_presigned_mode(mock_policy, settings):
"""Test update_local_urls with multiple files in PRESIGNED_URL mode."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
conversation = ChatConversationFactory()
mock_policy.side_effect = [
"https://s3.example.com/image1-presigned",
"https://s3.example.com/image2-presigned",
"https://s3.example.com/document-presigned",
]
key1 = f"{conversation.pk}/image1.jpg"
key2 = f"{conversation.pk}/image2.png"
key3 = f"{conversation.pk}/document.pdf"
contents = [
ImageUrl(url=f"/media-key/{key1}"),
ImageUrl(url=f"/media-key/{key2}"),
DocumentUrl(url=f"/media-key/{key3}"),
]
result = list(update_local_urls(conversation, contents))
assert len(result) == 3
assert result[0].url == "https://s3.example.com/image1-presigned"
assert result[1].url == "https://s3.example.com/image2-presigned"
assert result[2].url == "https://s3.example.com/document-presigned"
assert mock_policy.call_count == 3
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
def test_update_local_urls_multiple_files_temporary_url_mode(mock_temp_url, settings):
"""Test update_local_urls with multiple files in BACKEND_TEMPORARY_URL mode."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
conversation = ChatConversationFactory()
mock_temp_url.side_effect = [
"/api/v1.0/file-stream/token1/",
"/api/v1.0/file-stream/token2/",
"/api/v1.0/file-stream/token3/",
]
key1 = f"{conversation.pk}/image1.jpg"
key2 = f"{conversation.pk}/image2.png"
key3 = f"{conversation.pk}/document.pdf"
contents = [
ImageUrl(url=f"/media-key/{key1}"),
ImageUrl(url=f"/media-key/{key2}"),
DocumentUrl(url=f"/media-key/{key3}"),
]
result = list(update_local_urls(conversation, contents))
assert len(result) == 3
assert result[0].url == "/api/v1.0/file-stream/token1/"
assert result[1].url == "/api/v1.0/file-stream/token2/"
assert result[2].url == "/api/v1.0/file-stream/token3/"
assert mock_temp_url.call_count == 3
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_update_local_urls_presigned_mode_preserves_mapping(mock_policy, settings):
"""Test that PRESIGNED_URL mode correctly stores updated URLs mapping."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
conversation = ChatConversationFactory()
presigned_url = "https://s3.example.com/presigned-123"
mock_policy.return_value = presigned_url
key = f"{conversation.pk}/test.jpg"
original_url = f"/media-key/{key}"
contents = [ImageUrl(url=original_url)]
updated_urls = {}
list(update_local_urls(conversation, contents, updated_urls))
assert presigned_url in updated_urls
assert updated_urls[presigned_url] == original_url
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
def test_update_local_urls_temporary_url_mode_preserves_mapping(mock_temp_url, settings):
"""Test that BACKEND_TEMPORARY_URL mode correctly stores updated URLs mapping."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
conversation = ChatConversationFactory()
temp_url = "/api/v1.0/file-stream/temp-abc-def/"
mock_temp_url.return_value = temp_url
key = f"{conversation.pk}/test.pdf"
original_url = f"/media-key/{key}"
contents = [DocumentUrl(url=original_url)]
updated_urls = {}
list(update_local_urls(conversation, contents, updated_urls))
assert temp_url in updated_urls
assert updated_urls[temp_url] == original_url
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_update_local_urls_base64_mode_preserves_mapping(mock_storage, settings):
"""Test that BACKEND_BASE64 mode correctly stores updated URLs mapping."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
conversation = ChatConversationFactory()
file_content = b"test image content"
mock_file = BytesIO(file_content)
mock_storage.return_value.__enter__.return_value = mock_file
key = f"{conversation.pk}/test.jpg"
original_url = f"/media-key/{key}"
contents = [ImageUrl(url=original_url)]
updated_urls = {}
result = list(update_local_urls(conversation, contents, updated_urls))
# Verify mapping was stored
assert len(updated_urls) == 1
# The data URL should be the key in the mapping
data_url = result[0].url
assert data_url in updated_urls
assert updated_urls[data_url] == original_url
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_update_local_urls_presigned_mode_security_check(mock_policy, settings):
"""Test that PRESIGNED_URL mode respects security checks."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
conversation = ChatConversationFactory()
other_key = "other-conversation-id/test.jpg"
contents = [ImageUrl(url=f"/media-key/{other_key}")]
result = list(update_local_urls(conversation, contents))
# URL should remain unchanged
assert result[0].url == f"/media-key/{other_key}"
# generate_retrieve_policy should not be called
mock_policy.assert_not_called()
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
def test_update_local_urls_temporary_mode_security_check(mock_temp_url, settings):
"""Test that BACKEND_TEMPORARY_URL mode respects security checks."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
conversation = ChatConversationFactory()
other_key = "other-conversation-id/test.pdf"
contents = [DocumentUrl(url=f"/media-key/{other_key}")]
result = list(update_local_urls(conversation, contents))
# URL should remain unchanged
assert result[0].url == f"/media-key/{other_key}"
# generate_temporary_url should not be called
mock_temp_url.assert_not_called()
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_update_local_urls_base64_mode_security_check(mock_storage, settings):
"""Test that BACKEND_BASE64 mode respects security checks."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
conversation = ChatConversationFactory()
other_key = "other-conversation-id/test.jpg"
contents = [ImageUrl(url=f"/media-key/{other_key}")]
result = list(update_local_urls(conversation, contents))
# URL should remain unchanged
assert result[0].url == f"/media-key/{other_key}"
# Storage should not be opened
mock_storage.assert_not_called()
@pytest.mark.parametrize(
"file_to_llm_mode",
[
FileToLLMMode.PRESIGNED_URL,
FileToLLMMode.BACKEND_TEMPORARY_URL,
FileToLLMMode.BACKEND_BASE64,
],
)
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
def test_update_local_urls_all_modes_with_external_urls(
mock_get_file_url, file_to_llm_mode, settings
):
"""Test that all modes preserve external URLs unchanged."""
settings.FILE_TO_LLM_MODE = file_to_llm_mode
conversation = ChatConversationFactory()
mock_get_file_url.return_value = "processed-url"
key = f"{conversation.pk}/test.jpg"
external_urls = [
"https://example.com/image.jpg",
"http://another.com/image.png",
"data:image/png;base64,iVBORw0KG...",
]
contents = [ImageUrl(url=f"/media-key/{key}")] + [ImageUrl(url=url) for url in external_urls]
result = list(update_local_urls(conversation, contents))
assert len(result) == 4
assert result[0].url == "processed-url"
for i, external_url in enumerate(external_urls, start=1):
assert result[i].url == external_url
@patch("chat.agents.local_media_url_processors.default_storage.open")
def test_update_local_urls_base64_mode_with_different_file_types(mock_storage, settings):
"""Test BACKEND_BASE64 mode with different file MIME types."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
conversation = ChatConversationFactory()
# Test with different file types
test_cases = [
("test.jpg", b"JPEG binary"),
("test.png", b"PNG binary"),
("test.pdf", b"PDF binary"),
("test.txt", b"Text content"),
]
for filename, content in test_cases:
mock_file = BytesIO(content)
mock_storage.return_value.__enter__.return_value = mock_file
key = f"{conversation.pk}/{filename}"
contents = [ImageUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
# Should be a data URL
assert result[0].url.startswith("data:")
assert "base64" in result[0].url
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
def test_update_local_urls_presigned_mode_with_special_characters_in_key(mock_policy, settings):
"""Test PRESIGNED_URL mode handles keys with special characters."""
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
conversation = ChatConversationFactory()
mock_policy.return_value = "https://s3.example.com/presigned"
# Key with special characters (should be handled properly by S3)
key = f"{conversation.pk}/attachments/file (1).pdf"
contents = [DocumentUrl(url=f"/media-key/{key}")]
result = list(update_local_urls(conversation, contents))
assert len(result) == 1
assert result[0].url == "https://s3.example.com/presigned"
mock_policy.assert_called_once_with(key)
@@ -38,6 +38,9 @@ def brave_settings(settings):
settings.BRAVE_SEARCH_EXTRA_SNIPPETS = True
settings.BRAVE_SUMMARIZATION_ENABLED = False
settings.BRAVE_CACHE_TTL = 3600
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
)
settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = 5
@@ -9,7 +9,7 @@ from django.test import override_settings
import pytest
from core.file_upload.enums import AttachmentStatus
from core.file_upload.enums import AttachmentStatus, FileUploadMode
from chat import factories, models
from chat.tests.conftest import PIXEL_PNG
@@ -266,3 +266,40 @@ def test_upload_ended_fix_extension(api_client, name, content, _extension, conte
assert attachment.content_type == content_type # updated
assert attachment.file_name == name # updated
assert attachment.size == len(content) # updated
def test_attachment_create_presigned_url_mode_returns_policy(api_client, settings):
"""Test that presigned_url mode returns policy field."""
settings.FILE_UPLOAD_MODE = FileUploadMode.PRESIGNED_URL
conversation = factories.ChatConversationFactory()
api_client.force_login(conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
response = api_client.post(
url,
{"file_name": "test.pdf", "size": 1000, "content_type": "application/pdf"},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["policy"] is not None
assert "s3" in data["policy"].lower() or "minio" in data["policy"].lower()
def test_attachment_create_backend_to_s3_mode_no_policy(api_client, settings):
"""Test that backend_to_s3 mode does not return policy."""
settings.FILE_UPLOAD_MODE = FileUploadMode.BACKEND_TO_S3
conversation = factories.ChatConversationFactory()
api_client.force_login(conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
response = api_client.post(
url, {"file_name": "test.pdf", "content_type": "application/pdf"}, format="json"
)
assert response.status_code == 201
data = response.json()
assert data["policy"] is None
@@ -0,0 +1,112 @@
"""Tests for file upload mode `backend_to_s3`."""
from io import BytesIO
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from chat import factories, models
from chat.tests.conftest import PIXEL_PNG
pytestmark = pytest.mark.django_db
def test_backend_upload_anonymous_forbidden(api_client):
"""Anonymous users should not be able to use backend upload."""
conversation = factories.ChatConversationFactory()
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
response = api_client.post(url, {}, format="multipart")
assert response.status_code == 401
def test_backend_upload_not_owner_forbidden(api_client):
"""Users who don't own the conversation cannot upload via backend."""
conversation = factories.ChatConversationFactory()
user = factories.UserFactory()
api_client.force_login(user)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
file_obj = BytesIO(PIXEL_PNG)
file_obj.name = "test.png"
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
assert response.status_code == 404
@mock.patch("chat.views.malware_detection.analyse_file")
def test_backend_upload_success(mock_malware, api_client):
"""Test successful backend file upload."""
conversation = factories.ChatConversationFactory()
api_client.force_login(conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
file_obj = BytesIO(PIXEL_PNG)
file_obj.name = "test.png"
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
assert response.status_code == 201
data = response.json()
# Verify response structure
assert "id" in data
assert "key" in data
assert data["file_name"] == "test.png"
assert data["size"] == len(PIXEL_PNG)
assert data["content_type"] == "image/png"
# Verify attachment was created
attachment = models.ChatConversationAttachment.objects.get(pk=data["id"])
assert attachment.conversation == conversation
assert attachment.uploaded_by == conversation.owner
assert attachment.file_name == "test.png"
assert attachment.size == len(PIXEL_PNG)
# Verify malware detection was called
mock_malware.assert_called_once()
def test_backend_upload_missing_file(api_client):
"""Test that backend upload fails without file field."""
conversation = factories.ChatConversationFactory()
api_client.force_login(conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
response = api_client.post(
url,
{"file_name": "test.png"}, # No file field
format="multipart",
)
assert response.status_code == 400
assert "file" in response.json()
@mock.patch("chat.views.malware_detection.analyse_file")
def test_backend_upload_creates_s3_file(_mock_malware, api_client):
"""Test that backend upload creates file in S3."""
conversation = factories.ChatConversationFactory()
api_client.force_login(conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
file_obj = BytesIO(PIXEL_PNG)
file_obj.name = "test.png"
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
assert response.status_code == 201
data = response.json()
key = data["key"]
# Verify file exists in S3
assert default_storage.exists(key)
# Verify file content
with default_storage.open(key, "rb") as f:
content = f.read()
assert content == PIXEL_PNG
@@ -1,17 +0,0 @@
"""Common test fixtures for chat views tests."""
from unittest import mock
import pytest
@pytest.fixture(autouse=True)
def mock_process_request():
"""
Mock process_request to bypass OIDC authentication in tests.
"""
with mock.patch(
"lasuite.oidc_login.decorators.RefreshOIDCAccessToken.process_request"
) as mocked_process_request:
mocked_process_request.return_value = None
yield mocked_process_request
@@ -8,7 +8,6 @@ import logging
from io import BytesIO
from unittest import mock
from django.contrib.sessions.backends.cache import SessionStore
from django.utils import formats, timezone
import httpx
@@ -42,49 +41,28 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(
autouse=True,
params=[
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
],
)
def ai_settings(request, settings):
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
# enable on rag document search tool
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
"Based on the following document contents:\n\n{search_results}\n\n"
"Please answer the user's question: {user_prompt}"
)
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
# Albert API settings
# Enable Albert API for document search
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
)
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
settings.ALBERT_API_KEY = "albert-api-key"
# Find API settings
settings.FIND_API_URL = "https://find.api.example.com"
settings.FIND_API_KEY = "find-api-key"
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
"Based on the following document contents:\n\n{search_results}\n\n"
"Please answer the user's question: {user_prompt}"
)
return settings
@pytest.fixture(autouse=True)
def mock_refresh_access_token():
"""Mock refresh_access_token to bypass token refresh in tests."""
with mock.patch("utils.oidc.refresh_access_token") as mocked_refresh_access_token:
session = SessionStore()
session["oidc_access_token"] = "mocked-access-token"
mocked_refresh_access_token.return_value = session
yield mocked_refresh_access_token
@pytest.fixture(name="sample_pdf_content")
def fixture_sample_pdf_content():
"""Create a dummy PDF content as BytesIO."""
@@ -103,18 +81,10 @@ def fixture_sample_pdf_content():
return BytesIO(pdf_data)
@pytest.fixture(name="mock_document_api")
def fixture_mock_document_api():
@pytest.fixture(name="mock_albert_api")
def fixture_mock_albert_api():
"""Fixture to mock the Albert API endpoints."""
# Mock collection creation
document_name = "sample.pdf"
document_content = "This is the content of the PDF."
prompt_tokens = 10
completion_tokens = 20
search_method = "semantic"
search_score = 0.9
responses.post(
"https://albert.api.etalab.gouv.fr/v1/collections",
json={"id": "123", "name": "test-collection"},
@@ -131,7 +101,7 @@ def fixture_mock_document_api():
"metadata": {"document_name": "sample.pdf"},
}
],
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
status=status.HTTP_200_OK,
)
@@ -149,42 +119,20 @@ def fixture_mock_document_api():
json={
"data": [
{
"method": search_method,
"method": "semantic",
"chunk": {
"id": 123,
"content": document_content,
"metadata": {"document_name": document_name},
"content": "This is the content of the PDF.",
"metadata": {"document_name": "sample.pdf"},
},
"score": search_score,
"score": 0.9,
}
],
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
status=status.HTTP_200_OK,
)
# Mock document indexing (Find API)
responses.post(
"https://find.api.example.com/api/v1.0/documents/index/",
json={"id": "456", "status": "indexed"},
status=status.HTTP_200_OK,
)
# Mock document search (Find API)
responses.post(
"https://find.api.example.com/api/v1.0/documents/search/",
json=[
{
"_source": {
"title.fr": document_name,
"content.fr": document_content,
},
"_score": search_score,
}
],
status=status.HTTP_200_OK,
)
@pytest.fixture(name="mock_summarization_agent")
def fixture_mock_summarization_agent():
@@ -271,7 +219,7 @@ def fixture_mock_openai_stream():
def test_post_conversation_with_document_upload(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_document_api, # pylint: disable=unused-argument
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_ai_agent_service,
@@ -600,7 +548,7 @@ def test_post_conversation_with_document_upload_feature_disabled(
@freeze_time()
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
api_client,
mock_document_api, # pylint: disable=unused-argument
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_ai_agent_service,
@@ -37,19 +37,11 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(
autouse=True,
params=[
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
],
)
def ai_settings(request, settings):
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.FIND_API_KEY = "find-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
return settings
@@ -93,10 +85,6 @@ def test_post_conversation_with_local_pdf_document_url(
json={"id": "document_id", "object": "document"},
status=200,
)
responses.post(
"https://app-find/api/v1.0/documents/index/",
status=200,
)
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
@@ -813,10 +801,6 @@ def test_post_conversation_with_local_not_pdf_document_url(
json={"id": "document_id", "object": "document"},
status=200,
)
responses.post(
"https://app-find/api/v1.0/documents/index/",
status=200,
)
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
@@ -0,0 +1,55 @@
"""Tests for the file stream endpoint."""
from io import BytesIO
from unittest import mock
from django.core.cache import cache
def test_file_stream_invalid_key(api_client):
"""Test that invalid temporary keys return 404."""
cache.clear()
url = "/api/v1.0/file-stream/invalid-key/"
response = api_client.get(url)
assert response.status_code == 404
error = response.json()["detail"].lower()
assert "expired" in error or "invalid" in error
def test_file_stream_expired_key(api_client):
"""Test that expired keys return 404."""
cache.clear()
# Create a key that's already expired
cache.set("file_access:expired-key", "path/to/file.pdf", timeout=0)
url = "/api/v1.0/file-stream/expired-key/"
response = api_client.get(url)
assert response.status_code == 404
@mock.patch("chat.views.magic.Magic")
@mock.patch("chat.views.default_storage.open")
def test_file_stream_valid_key_streams_file(mock_storage_open, mock_magic, api_client):
"""Test that valid temporary keys stream file content."""
cache.clear()
# Create a valid temporary key
temporary_key = "test-valid-key"
s3_key = "test/path/file.pdf"
cache.set(f"file_access:{temporary_key}", s3_key, timeout=300)
# Mock storage.open to return file content
file_mock = BytesIO(b"PDF content here")
mock_storage_open.return_value = file_mock
# Mock magic detector
mock_magic_instance = mock.MagicMock()
mock_magic_instance.from_buffer.return_value = "application/pdf"
mock_magic.return_value = mock_magic_instance
url = f"/api/v1.0/file-stream/{temporary_key}/"
response = api_client.get(url)
assert response.status_code == 200
@@ -26,7 +26,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
document_store = document_store_backend(ctx.deps.conversation.collection_id)
rag_results = document_store.search(query, session=ctx.deps.session)
rag_results = document_store.search(query)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
+5 -12
View File
@@ -127,7 +127,7 @@ async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[st
return []
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
async def _fetch_and_store_async(url: str, document_store) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
try:
@@ -136,7 +136,7 @@ async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
logger.debug("Fetched document: %s", document)
if document:
await document_store.astore_document(url, document, **kwargs)
await document_store.astore_document(url, document)
except DocumentFetchError as e:
logger.warning("Failed to fetch and store %s: %s", url, e)
# Continue with other documents
@@ -307,26 +307,19 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
temp_collection_name = f"tmp-{uuid.uuid4()}"
try:
async with document_store_backend.temporary_collection_async(
temp_collection_name, session=ctx.deps.session
temp_collection_name
) as document_store:
# Fetch and store all documents concurrently
tasks = [
_fetch_and_store_async(
result["url"],
document_store,
user_sub=ctx.deps.user.sub,
session=ctx.deps.session,
)
_fetch_and_store_async(result["url"], document_store)
for result in raw_search_results
]
await asyncio.gather(*tasks, return_exceptions=True)
# Perform RAG search
rag_results = await document_store.asearch(
query=query,
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
session=ctx.deps.session,
user_sub=ctx.deps.user.sub,
)
logger.info("RAG search returned: %s", rag_results)
+173 -4
View File
@@ -5,24 +5,25 @@ import os
from uuid import uuid4
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
import langfuse
import magic
import posthog
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from core.api.viewsets import Pagination, SerializerPerActionMixin
from core.file_upload import enums
from core.file_upload.enums import AttachmentStatus
from core.file_upload.mixins import AttachmentMixin
from core.file_upload.serializers import FileUploadSerializer
from core.filters import remove_accents
from activation_codes.permissions import IsActivatedUser
@@ -125,7 +126,6 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
self.permission_classes = []
return super().get_permissions()
@method_decorator(refresh_oidc_access_token)
@decorators.action(
methods=["post"],
detail=True,
@@ -177,7 +177,6 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
ai_service = AIAgentService(
conversation=conversation,
user=self.request.user,
session=request.session,
model_hrid=model_hrid,
language=(
self.request.user.language
@@ -438,3 +437,173 @@ class ChatConversationAttachmentViewSet(
)
return Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(
detail=False,
methods=["post"],
url_path="backend-upload",
url_name="backend-upload",
)
def backend_upload_attachment(self, request, *args, **kwargs):
"""
Handle backend file upload for backend_to_s3 mode.
This endpoint is used when FILE_UPLOAD_MODE is set to backend_to_s3.
The frontend sends the file directly to this endpoint,
and the backend stores it on S3 and initiates malware detection.
The attachment lifecycle:
1. Frontend sends file via this endpoint
2. Backend stores file on S3
3. Backend detects MIME type and file size
4. Backend initiates malware detection
5. After detection, attachment status becomes READY or SUSPICIOUS
"""
# pylint: disable=too-many-locals
# Verify the user owns the conversation
conversation_id = self.kwargs["conversation_pk"]
if not models.ChatConversation.objects.filter(
pk=conversation_id,
owner=request.user,
).exists():
raise Http404
serializer = FileUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
file_obj = serializer.validated_data["file"]
file_name = serializer.validated_data["file_name"]
# Generate unique file ID and storage key
file_id = uuid4()
extension = file_name.rpartition(".")[-1] if "." in file_name else None
ext_suffix = f".{extension}" if extension else ""
key = f"{conversation_id}/{AttachmentMixin.ATTACHMENTS_FOLDER}/{file_id}{ext_suffix}"
# Store file on S3
try:
stored_path = default_storage.save(key, file_obj)
logger.info("File uploaded to S3: %s", stored_path)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to upload file to S3 for conversation %s", conversation_id)
return Response(
{"detail": "Failed to upload file to storage"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Detect MIME type
mime_detector = magic.Magic(mime=True)
with default_storage.open(key, "rb") as file:
mimetype = mime_detector.from_buffer(file.read(2048))
file_size = file.size
# Create attachment record with ANALYZING status
attachment = models.ChatConversationAttachment.objects.create(
conversation_id=conversation_id,
uploaded_by=request.user,
upload_state=AttachmentStatus.ANALYZING,
key=key,
file_name=file_name,
content_type=mimetype,
size=file_size,
)
logger.info(
"Created attachment %s for conversation %s, starting malware detection",
attachment.pk,
conversation_id,
)
# Start malware detection (will update status to READY or SUSPICIOUS via callbacks)
malware_detection.analyse_file(
key,
safe_callback="chat.malware_detection.conversation_safe_attachment_callback",
unknown_callback="chat.malware_detection.unknown_attachment_callback",
unsafe_callback="chat.malware_detection.conversation_unsafe_attachment_callback",
conversation_id=conversation_id,
)
# Track upload event
if settings.POSTHOG_KEY:
posthog.capture(
"item_uploaded_backend",
distinct_id=str(request.user.pk),
properties={
"id": attachment.pk,
"file_name": attachment.file_name,
"size": attachment.size,
"mimetype": attachment.content_type,
"mode": settings.FILE_UPLOAD_MODE,
},
)
serializer = self.get_serializer(attachment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class FileStreamView(APIView):
"""
Stream file content for temporary access URLs.
This view is used by LLMs to access file content when they cannot directly
access S3. A temporary key is stored in cache and validated before serving
the file.
Security:
- Temporary key expires after FILE_BACKEND_TEMPORARY_URL_EXPIRATION seconds
(default: 180 seconds / 3 minutes)
- No authentication required (key is single-use temporary token)
- Key is generated using secure random tokens
"""
permission_classes = [] # No authentication needed for temporary keys
throttle_classes = [ScopedRateThrottle]
throttle_scope = "file-stream"
def get(self, request, temporary_key):
"""
Stream file content using a temporary access key.
Args:
temporary_key: The temporary key generated by generate_temporary_url()
Returns:
StreamingHttpResponse with file content
"""
# Retrieve the S3 key from cache using the temporary key
cache_key = f"file_access:{temporary_key}"
s3_key = cache.get(cache_key)
if not s3_key:
logger.warning("Temporary file access key not found or expired: %s", temporary_key)
raise Http404("File access key expired or invalid")
# Delete the key from cache to prevent reuse
cache.delete(cache_key)
logger.info("Serving file via temporary key: %s", s3_key)
try:
# Open the file from S3
file_obj = default_storage.open(s3_key, "rb")
# Detect MIME type for proper content-type header
mime_detector = magic.Magic(mime=True)
file_content = file_obj.read(2048)
file_obj.seek(0)
content_type = mime_detector.from_buffer(file_content)
# Extract filename from S3 key (last part after /)
filename = s3_key.split("/")[-1]
# Stream the file content
response = StreamingHttpResponse(
file_obj,
content_type=content_type,
)
response["Content-Disposition"] = f'inline; filename="{filename}"'
return response
except Exception as exc:
logger.exception("Failed to serve file via temporary key: %s", temporary_key)
raise Http404("Failed to retrieve file") from exc
+1 -1
View File
@@ -22,7 +22,7 @@ def no_http_requests(monkeypatch):
Credits: https://blog.jerrycodes.com/no-http-requests/
"""
allowed_hosts = {"localhost", "127.0.0.1", "minio", "minio:9000"}
allowed_hosts = {"localhost", "minio", "minio:9000"}
original_urlopen = HTTPConnectionPool.urlopen
def urlopen_mock(self, method, url, *args, **kwargs):
+34 -17
View File
@@ -230,6 +230,27 @@ class Base(BraveSettings, Configuration):
environ_name="ATTACHMENT_MAX_SIZE",
environ_prefix=None,
)
FILE_UPLOAD_MODE = values.Value(
"presigned_url",
environ_name="FILE_UPLOAD_MODE",
environ_prefix=None,
)
FILE_TO_LLM_MODE = values.Value(
"presigned_url",
environ_name="FILE_TO_LLM_MODE",
environ_prefix=None,
)
FILE_BACKEND_URL = values.Value(
"",
environ_name="FILE_BACKEND_URL",
environ_prefix=None,
)
FILE_BACKEND_TEMPORARY_URL_EXPIRATION = values.IntegerValue(
180,
environ_name="FILE_BACKEND_TEMPORARY_URL_EXPIRATION",
environ_prefix=None,
)
MALWARE_DETECTION = {
"BACKEND": values.Value(
"lasuite.malware_detection.backends.dummy.DummyBackend",
@@ -332,6 +353,7 @@ class Base(BraveSettings, Configuration):
# Third party apps
"corsheaders",
"django_filters",
"solo.apps.SoloAppConfig",
"dockerflow.django",
"rest_framework",
"parler",
@@ -395,6 +417,11 @@ class Base(BraveSettings, Configuration):
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
"file-stream": values.Value(
default="60/minute",
environ_name="API_FILE_STREAM_THROTTLE_RATE",
environ_prefix=None,
),
},
}
@@ -841,23 +868,6 @@ USER QUESTION:
environ_prefix=None,
)
# Find
FIND_API_KEY = values.Value(
None,
environ_name="FIND_API_KEY",
environ_prefix=None,
)
FIND_API_URL = values.Value(
"https://app-find/api",
environ_name="FIND_API_URL",
environ_prefix=None,
)
FIND_API_TIMEOUT = values.PositiveIntegerValue(
default=30, # seconds
environ_name="FIND_API_TIMEOUT",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -1063,6 +1073,13 @@ USER QUESTION:
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
# File access configuration validation
if cls.FILE_TO_LLM_MODE == "backend_temporary_url" and not cls.FILE_BACKEND_URL:
raise ValueError(
"FILE_TO_LLM_MODE is set to 'backend_temporary_url' but FILE_BACKEND_URL is empty. "
"Please set FILE_BACKEND_URL to a valid URL for backend temporary file access."
)
# Langfuse initialization
if cls.LANGFUSE_ENABLED:
if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED:
+24 -1
View File
@@ -3,7 +3,7 @@
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from solo.admin import SingletonModelAdmin
from . import models
@@ -107,3 +107,26 @@ class UserAdmin(auth_admin.UserAdmin):
"allow_conversation_analytics",
)
list_editable = ("allow_conversation_analytics",)
@admin.register(models.SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin):
fieldsets = (
(_("Environment Banner"), {
"description": _("Inform users about the current environment (staging, demo...)"),
"fields": (
"environment_banner_level",
"environment_banner_title",
"environment_banner_content",
),
}),
(_("Status / Incident Banner"), {
"description": _("Communicate maintenance windows or ongoing incidents"),
"fields": (
"status_banner_level",
"status_banner_title",
"status_banner_content",
"status_banner_dismissible",
),
}),
)
+30
View File
@@ -211,6 +211,7 @@ class ConfigView(drf.views.APIView):
"LANGUAGE_CODE",
"SENTRY_DSN",
"FEATURE_FLAGS",
"FILE_UPLOAD_MODE",
]
dict_settings = {}
for setting in array_settings:
@@ -224,6 +225,8 @@ class ConfigView(drf.views.APIView):
dict_settings["chat_upload_accept"] = ",".join(settings.RAG_FILES_ACCEPTED_FORMATS)
dict_settings["banners"] = self._get_banners()
return drf.response.Response(dict_settings)
def _load_theme_customization(self):
@@ -256,3 +259,30 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
def _get_banners(self):
"""Return active banners from SiteConfiguration."""
config = models.SiteConfiguration.get_solo()
banners = {}
if config.environment_banner_enabled :
banners["environment"] = {
"type": "environment",
"level": config.environment_banner_level,
"title": config.environment_banner_title,
"content": config.environment_banner_content,
"dismissible": False,
}
if config.status_banner_enabled :
banners["status"] = {
"type": "status",
"level": config.status_banner_level,
"title": config.status_banner_title,
"content": config.status_banner_content,
"dismissible": config.status_banner_dismissible,
}
return banners
+25
View File
@@ -20,3 +20,28 @@ class AttachmentStatus(StrEnum):
def choices(cls):
"""Return a list of tuples for each enum member."""
return [(member.value, member.name) for member in cls]
class FileUploadMode(StrEnum):
"""Defines the possible modes for file upload (from frontend) handling."""
PRESIGNED_URL = "presigned_url"
BACKEND_TO_S3 = "backend_to_s3"
@classmethod
def choices(cls):
"""Return a list of tuples for each enum member."""
return [(member.value, member.name) for member in cls]
class FileToLLMMode(StrEnum):
"""Defines the possible modes to send file to the LLM."""
PRESIGNED_URL = "presigned_url"
BACKEND_BASE64 = "backend_base64"
BACKEND_TEMPORARY_URL = "backend_temporary_url"
@classmethod
def choices(cls):
"""Return a list of tuples for each enum member."""
return [(member.value, member.name) for member in cls]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-29 10:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_user_short_name'),
]
operations = [
migrations.CreateModel(
name='SiteConfiguration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('environment_banner_title', models.CharField(blank=True, verbose_name='Environment banner - title')),
('environment_banner_content', models.TextField(blank=True, verbose_name='Environment banner - content')),
('environment_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Environment banner - level')),
('status_banner_title', models.CharField(blank=True, verbose_name='Status banner - title')),
('status_banner_content', models.TextField(blank=True, verbose_name='Status banner - content')),
('status_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Status banner - level')),
('status_banner_dismissible', models.BooleanField(default=True, help_text='Allow users to dismiss this banner', verbose_name='Status banner - dismissible')),
],
options={
'verbose_name': 'Site Configuration',
},
),
]
+3
View File
@@ -0,0 +1,3 @@
from .base import BaseModel
from .users import User, DuplicateEmailError,UserManager
from .app_config import SiteConfiguration
+64
View File
@@ -0,0 +1,64 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from solo.models import SingletonModel
class BannerLevelChoice(models.TextChoices):
INFO = "info", _("Info")
WARNING = "warning", _("Warning")
ALERT = "alert", _("Alert")
class SiteConfiguration(SingletonModel):
"""Site-wide configuration for banners and announcements."""
# Environment banner (staging, demo, QA, sandbox, etc.)
environment_banner_title = models.CharField(
blank=True,
verbose_name=_("Environment banner - title"),
)
environment_banner_content = models.TextField(
blank=True,
verbose_name=_("Environment banner - content"),
)
environment_banner_level = models.CharField(
verbose_name=_("Environment banner - level"),
max_length=20,
choices=BannerLevelChoice.choices,
default=BannerLevelChoice.INFO,
)
# Status/incident banner
status_banner_title = models.CharField(
blank=True,
verbose_name=_("Status banner - title"),
)
status_banner_content = models.TextField(
blank=True,
verbose_name=_("Status banner - content"),
)
status_banner_level = models.CharField(
verbose_name=_("Status banner - level"),
max_length=20,
choices=BannerLevelChoice.choices,
default=BannerLevelChoice.INFO,
)
status_banner_dismissible = models.BooleanField(
default=True,
verbose_name=_("Status banner - dismissible"),
help_text=_("Allow users to dismiss this banner"),
)
def __str__(self):
return "Site Configuration"
@property
def environment_banner_enabled(self):
return bool(self.environment_banner_title or self.environment_banner_content)
@property
def status_banner_enabled(self):
return bool(self.status_banner_title or self.status_banner_content)
class Meta:
verbose_name = _("Site Configuration" )
+43
View File
@@ -0,0 +1,43 @@
"""
Declare and configure base models for the conversations core application
"""
from django.db import models
import uuid
from django.utils.translation import gettext_lazy as _
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta: # pylint:disable=missing-class-docstring
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
@@ -1,8 +1,8 @@
"""
Declare and configure the models for the conversations core application
Declare and configure the user models for the conversations core application
"""
import uuid
from logging import getLogger
from django.conf import settings
@@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .base import BaseModel
logger = getLogger(__name__)
@@ -27,42 +29,6 @@ class DuplicateEmailError(Exception):
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta: # pylint:disable=missing-class-docstring
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
@@ -0,0 +1,110 @@
"""Tests for generate_temporary_url utility function."""
from django.conf import settings
from django.core.cache import cache
import pytest
from chat.agents.local_media_url_processors import generate_temporary_url
pytestmark = pytest.mark.django_db
def test_generate_temporary_url_returns_string():
"""Test that generate_temporary_url returns a valid backend streaming URL."""
cache.clear()
url = generate_temporary_url("test/file.pdf")
assert isinstance(url, str)
assert url.startswith(settings.FILE_BACKEND_URL + "/api/v1.0/file-stream/")
assert url.endswith("/")
def test_generate_temporary_url_creates_cache_entry():
"""Test that a cache entry is created with correct mapping."""
cache.clear()
s3_key = "conversation-id/attachments/file-uuid.pdf"
url = generate_temporary_url(s3_key)
# Extract temporary key from URL
temporary_key = url.split("/file-stream/")[1].rstrip("/")
# Verify cache entry
cache_key = f"file_access:{temporary_key}"
cached_value = cache.get(cache_key)
assert cached_value == s3_key
def test_generate_temporary_url_unique_tokens():
"""Test that different S3 keys produce different temporary tokens."""
cache.clear()
url1 = generate_temporary_url("file1.pdf")
url2 = generate_temporary_url("file2.pdf")
assert url1 != url2
key1 = url1.split("/file-stream/")[1].rstrip("/")
key2 = url2.split("/file-stream/")[1].rstrip("/")
assert cache.get(f"file_access:{key1}") == "file1.pdf"
assert cache.get(f"file_access:{key2}") == "file2.pdf"
def test_generate_temporary_url_token_is_url_safe():
"""Test that generated tokens contain only URL-safe characters."""
cache.clear()
url = generate_temporary_url("test.pdf")
temporary_key = url.split("/file-stream/")[1].rstrip("/")
# Token should only contain alphanumeric, dash, and underscore
assert all(c.isalnum() or c in "-_" for c in temporary_key)
def test_generate_temporary_url_token_sufficient_entropy():
"""Test that generated tokens have sufficient entropy."""
cache.clear()
url = generate_temporary_url("test.pdf")
temporary_key = url.split("/file-stream/")[1].rstrip("/")
# Token should be reasonably long
assert len(temporary_key) >= 32
def test_generate_temporary_url_no_sensitive_data_in_url():
"""Test that temporary URLs don't contain S3 key information."""
cache.clear()
s3_key = "secret/conversation-123/attachments/file.pdf"
url = generate_temporary_url(s3_key)
# URL should not contain the actual S3 key
assert "secret" not in url
assert "conversation-123" not in url
assert "file.pdf" not in url
# Only the endpoint and random token
assert "/api/v1.0/file-stream/" in url
def test_generate_temporary_url_various_key_formats():
"""Test generate_temporary_url with various S3 key formats."""
cache.clear()
test_keys = [
"simple/key.pdf",
"conversation-123/attachments/file-uuid.pdf",
"nested/folder/structure/file.jpg",
"file_with_special-chars_123.png",
]
urls = []
for key in test_keys:
url = generate_temporary_url(key)
urls.append(url)
temporary_key = url.split("/file-stream/")[1].rstrip("/")
assert cache.get(f"file_access:{temporary_key}") == key
# All URLs should be different
assert len(set(urls)) == len(urls)
@@ -47,6 +47,7 @@ def test_api_config(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
"FILE_UPLOAD_MODE": "presigned_url",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
@@ -189,6 +190,7 @@ async def test_api_config_async(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
"FILE_UPLOAD_MODE": "presigned_url",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
+23 -2
View File
@@ -1,15 +1,21 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.file_upload.enums import FileToLLMMode
from activation_codes import viewsets as activation_viewsets
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
from chat.views import (
ChatConversationAttachmentViewSet,
ChatViewSet,
FileStreamView,
LLMConfigurationView,
)
# - Main endpoints
router = DefaultRouter()
@@ -37,6 +43,21 @@ urlpatterns = [
include(conversation_router.urls),
),
]
+ (
# Only allow file stream URL when configured for backend temporary URL mode
[
re_path(
r"^file-stream/(?P<temporary_key>[a-zA-Z0-9_-]+)/$",
FileStreamView.as_view(),
name="file-stream",
),
]
if (
settings.FILE_TO_LLM_MODE == FileToLLMMode.BACKEND_TEMPORARY_URL
or settings.ENVIRONMENT == "test"
)
else []
)
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-01-16 11:04\n"
"PO-Revision-Date: 2026-01-27 15:38\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
+11 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.10"
version = "0.0.12"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -23,7 +23,7 @@ description = "An application to chat with your own AI."
keywords = ["Django", "AI", "Chatbot", "OpenAI", "Pydantic AI", "Conversations"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
requires-python = "~=3.13.0"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
@@ -67,6 +67,7 @@ dependencies = [
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"django-solo>=2.5.1",
]
[project.urls]
@@ -107,6 +108,14 @@ zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [
".git",
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the conversations module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
-54
View File
@@ -1,54 +0,0 @@
"""Utility functions for OIDC token management."""
from functools import wraps
from django.conf import settings
import requests
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
from rest_framework.exceptions import AuthenticationFailed
def refresh_access_token(session):
"""Refresh the OIDC access token using the refresh token."""
refresh_token = get_oidc_refresh_token(session)
if not refresh_token:
raise AuthenticationFailed({"error": "Refresh token is missing from session"})
response = requests.post(
settings.OIDC_OP_TOKEN_ENDPOINT,
data={
"grant_type": "refresh_token",
"client_id": settings.OIDC_RP_CLIENT_ID,
"client_secret": settings.OIDC_RP_CLIENT_SECRET,
"refresh_token": refresh_token,
},
timeout=5,
)
response.raise_for_status()
token_info = response.json()
store_tokens(
session,
access_token=token_info.get("access_token"),
id_token=None,
refresh_token=token_info.get("refresh_token"),
)
return session
def with_fresh_access_token(func):
"""
Decorator to handle OIDC token refresh and extraction.
Expects 'session' in kwargs and update it with the fresh token.
"""
@wraps(func)
def wrapper(*args, **kwargs):
session = kwargs.pop("session", None)
if session is None:
raise AuthenticationFailed({"error": "Session is required but not provided"})
refreshed_session = refresh_access_token(session)
return func(*args, session=refreshed_session, **kwargs)
return wrapper
+3120
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.11",
"version": "0.0.12",
"private": true,
"scripts": {
"dev": "next dev",
@@ -40,7 +40,7 @@
"lottie-react": "^2.4.1",
"luxon": "3.6.1",
"micromark-extension-llm-math": "3.1.1-20250610",
"next": "15.3.8",
"next": "15.3.9",
"posthog-js": "1.249.3",
"react": "19.2.1",
"react-aria-components": "1.9.0",
@@ -1,16 +1,61 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { memo, useCallback, useEffect, useRef } from 'react';
import styled, { RuleSet } from 'styled-components';
export interface LinkProps {
interface StyledLinkProps {
$css?: string | RuleSet<object>;
}
export const StyledLink = styled(Link)<LinkProps>`
const Anchor = styled.a<StyledLinkProps>`
text-decoration: none;
color: #ffffff;
&[aria-current='page'] {
color: #ffffff;
}
display: flex;
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
cursor: pointer;
${({ $css }) => $css && (typeof $css === 'string' ? `${$css}` : $css)}
`;
interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
$css?: string | RuleSet<object>;
}
/**
* Link that avoids re-renders from Next.js router context.
*
* Use instead of Next.js `Link` in large lists (sidebars, tables) where
* router-triggered re-renders cause performance issues.
*
* Warning: No automatic prefetching.
*
*/
export const StyledLink = memo(function StyledLink({
href,
onClick,
...props
}: Props) {
const router = useRouter();
const routerRef = useRef(router);
// avoid rerenders
useEffect(() => {
routerRef.current = router;
}, [router]);
// Memoized click handler to maintain stable reference across re-renders.
// Necessary for memo() to work correctly
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
// Allow default browser behavior for modifier keys (new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
onClick?.(e);
routerRef.current.push(href);
},
[href, onClick],
);
return <Anchor href={href} onClick={handleClick} {...props} />;
});
@@ -0,0 +1,101 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StyledLink } from '../Link';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
describe('StyledLink', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render a link with the correct href', () => {
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
expect(link).toHaveAttribute('href', '/test-path');
});
it('should navigate using router.push on click', async () => {
const user = userEvent.setup();
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
await user.click(link);
expect(mockPush).toHaveBeenCalledWith('/test-path');
});
it('should call onClick prop when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(
<StyledLink href="/test-path" onClick={handleClick}>
Test Link
</StyledLink>,
);
const link = screen.getByRole('link', { name: 'Test Link' });
await user.click(link);
expect(handleClick).toHaveBeenCalled();
});
it('should allow default behavior when meta key is pressed', () => {
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { metaKey: true });
expect(mockPush).not.toHaveBeenCalled();
});
it('should allow default behavior when ctrl key is pressed', () => {
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { ctrlKey: true });
expect(mockPush).not.toHaveBeenCalled();
});
it('should allow default behavior when shift key is pressed', () => {
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { shiftKey: true });
expect(mockPush).not.toHaveBeenCalled();
});
it('should allow default behavior when alt key is pressed', () => {
render(<StyledLink href="/test-path">Test Link</StyledLink>);
const link = screen.getByRole('link', { name: 'Test Link' });
fireEvent.click(link, { altKey: true });
expect(mockPush).not.toHaveBeenCalled();
});
it('should pass additional props to the anchor element', () => {
render(
<StyledLink
href="/test-path"
data-testid="custom-link"
className="custom"
>
Test Link
</StyledLink>,
);
const link = screen.getByTestId('custom-link');
expect(link).toBeInTheDocument();
});
});
@@ -34,13 +34,14 @@ export interface ConfigResponse {
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
FILE_UPLOAD_MODE?: string;
theme_customization?: ThemeCustomization;
chat_upload_accept?: string;
}
const LOCAL_STORAGE_KEY = 'conversations_config';
function getCachedTranslation() {
function getCachedConfig() {
try {
const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY);
return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined;
@@ -49,7 +50,7 @@ function getCachedTranslation() {
}
}
function setCachedTranslation(translations: ConfigResponse) {
function setCachedConfig(translations: ConfigResponse) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations));
}
@@ -61,7 +62,8 @@ export const getConfig = async (): Promise<ConfigResponse> => {
}
const config = response.json() as Promise<ConfigResponse>;
setCachedTranslation(await config);
setCachedConfig(await config);
return config;
};
@@ -69,7 +71,7 @@ export const getConfig = async (): Promise<ConfigResponse> => {
export const KEY_CONFIG = 'config';
export function useConfig() {
const cachedData = getCachedTranslation();
const cachedData = getCachedConfig();
const oneHour = 1000 * 60 * 60;
return useQuery<ConfigResponse, APIError, ConfigResponse>({
@@ -1,14 +1,19 @@
import { useCallback } from 'react';
import { fetchAPI } from '@/api';
import { baseApiUrl, fetchAPI, getCSRFToken } from '@/api';
import { useConfig } from '@/core';
import { useCreateConversationAttachment } from '../api';
interface BackendUploadResponse {
key: string;
}
/**
* Upload a file, using XHR so we can report on progress through a handler.
* @param url The pre-signed URL to PUT the file to.
* @param file The raw file to upload as the request body.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
*/
export const uploadFileToServer = (
url: string,
@@ -47,6 +52,71 @@ export const uploadFileToServer = (
xhr.send(file);
});
/**
* Upload a file to the backend (for backend_base64 and backend_temporary_url modes).
* Uses XHR to track upload progress while respecting the project's API patterns.
* @param conversationId The ID of the conversation.
* @param file The file to upload.
* @param progressHandler A handler that receives progress updates.
*/
export const uploadFileToBackend = (
conversationId: string,
file: File,
progressHandler: (progress: number) => void,
): Promise<BackendUploadResponse> =>
new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
formData.append('file_name', file.name);
const xhr = new XMLHttpRequest();
const csrfToken = getCSRFToken();
xhr.addEventListener('error', reject);
xhr.addEventListener('abort', reject);
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === 4) {
if (xhr.status === 201) {
progressHandler(100);
try {
const response = JSON.parse(
xhr.responseText,
) as BackendUploadResponse;
return resolve(response);
} catch {
return reject(new Error('Failed to parse server response'));
}
}
reject(
new Error(
`Failed to upload file to backend: ${xhr.status} ${xhr.statusText}`,
),
);
}
});
xhr.upload.addEventListener('progress', (progressEvent) => {
if (progressEvent.lengthComputable) {
progressHandler(
Math.floor((progressEvent.loaded / progressEvent.total) * 100),
);
}
});
// Use the project's baseApiUrl to construct the endpoint consistently
const apiUrl = `${baseApiUrl('1.0')}chats/${conversationId}/attachments/backend-upload/`;
xhr.open('POST', apiUrl);
// Add authentication headers following the project's pattern
xhr.withCredentials = true;
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
export const useUploadFile = (conversationId: string) => {
const {
mutateAsync: createConversationAttachment,
@@ -54,8 +124,25 @@ export const useUploadFile = (conversationId: string) => {
error: errorAttachment,
} = useCreateConversationAttachment();
const { data: conf } = useConfig();
const uploadFile = useCallback(
async (file: File, progressHandler?: (progress: number) => void) => {
// Backend mode backend_to_s3 file is sent to API backend
if (conf?.FILE_UPLOAD_MODE === 'backend_to_s3') {
// Upload file to backend (backend handles S3 storage, MIME detection, and malware scanning)
const finalAttachment = await uploadFileToBackend(
conversationId,
file,
(progress) => {
progressHandler?.(progress);
},
);
return `/media-key/${finalAttachment.key}`;
}
// Presigned URL mode (default): frontend uploads directly to S3
const attachment = await createConversationAttachment({
conversationId,
content_type: file.type,
@@ -83,7 +170,7 @@ export const useUploadFile = (conversationId: string) => {
return `/media-key/${attachment.key}`;
},
[createConversationAttachment, conversationId],
[createConversationAttachment, conversationId, conf],
);
return {
@@ -1,20 +1,28 @@
import { Button } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { Box, Text } from '@/components';
import { useToast } from '@/components/ToastProvider';
import { FeatureFlagState, useConfig } from '@/core';
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
import { InputChatActions } from '@/features/chat/components/InputChatAction';
import { SuggestionCarousel } from '@/features/chat/components/SuggestionCarousel';
import { WelcomeMessage } from '@/features/chat/components/WelcomeMessage';
import { useFileDragDrop } from '@/features/chat/hooks/useFileDragDrop';
import { useFileUrls } from '@/features/chat/hooks/useFileUrls';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import FilesIcon from '../assets/files.svg';
import { AttachmentList } from './AttachmentList';
import { ModelSelector } from './ModelSelector';
import { ScrollDown } from './ScrollDown';
import { SendButton } from './SendButton';
interface InputChatProps {
messagesLength: number;
@@ -34,6 +42,76 @@ interface InputChatProps {
isUploadingFiles?: boolean;
}
const STYLES = {
form: { width: '100%' },
formPadding: { bottom: 'base' },
formPaddingMobile: { bottom: '' },
attachmentMargin: { horizontal: '0', bottom: 'xs', top: 'xs' },
attachmentPadding: { horizontal: 'base' },
horizontalPadding: { horizontal: 'base' },
} as const;
const CONTAINER_CSS = `
display: block;
position: relative;
margin: auto;
width: 100%;
max-width: 750px;
`;
const INPUT_BOX_CSS = `
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
border-radius: 12px;
border: 1px solid var(--c--theme--colors--greyscale-200);
position: relative;
background: white;
transition: all 0.2s ease;
`;
const FILE_DROP_CSS = `
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: #EDF0FF;
width: 100%;
height: 100%;
outline: 2px solid #90A7FF;
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
`;
const DRAG_FADE_CSS = `
top: 0;
left: 0;
width: 100vw;
height: 100vh;
animation: fadeIn 0.3s;
z-index: 999;
background-color: rgba(255, 255, 255, 0.1);
pointer-events: all;
`;
const TEXTAREA_STYLE: React.CSSProperties = {
padding: '1rem 1.5rem',
background: 'transparent',
outline: 'none',
fontSize: '1rem',
border: 'none',
resize: 'none',
fontFamily: 'inherit',
minHeight: '64px',
maxHeight: '200px',
overflowY: 'auto',
transition: 'all 0.2s ease',
borderRadius: '12px',
color: 'var(--c--theme--colors--greyscale-800)',
lineHeight: '1.5',
};
const SCROLL_DOWN_WRAPPER_CSS = `
position: relative;
height: 0;
width: 100%;
margin: auto;
max-width: 750px;
`;
export const InputChat = ({
messagesLength,
input,
@@ -55,14 +133,14 @@ export const InputChat = ({
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const { isDesktop, isMobile } = useResponsiveStore();
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
const { data: conf } = useConfig();
const { isFeatureFlagActivated } = useAnalytics();
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isFileAccepted = useCallback(
(file: File): boolean => {
@@ -87,13 +165,6 @@ export const InputChat = ({
[conf?.chat_upload_accept],
);
const suggestions = [
t('Ask a question'),
t('Turn this list into bullet points'),
t('Write a short product description'),
t('Find recent news about...'),
];
const showToastError = useCallback(() => {
showToast(
'error',
@@ -131,32 +202,6 @@ export const InputChat = ({
setFileUploadEnabled(isFeatureEnabled('document-upload'));
}, [conf, isFeatureFlagActivated]);
useEffect(() => {
if (messagesLength === 0) {
const interval = setInterval(() => {
setCurrentSuggestionIndex((prev) => {
if (prev === suggestions.length - 1) {
return suggestions.length;
}
return prev + 1;
});
}, 3000);
return () => clearInterval(interval);
}
}, [messagesLength, suggestions.length]);
useEffect(() => {
if (currentSuggestionIndex === suggestions.length) {
const timeout = setTimeout(() => {
setIsResetting(true);
setCurrentSuggestionIndex(0);
setTimeout(() => setIsResetting(false), 50);
}, 500);
return () => clearTimeout(timeout);
}
}, [currentSuggestionIndex, suggestions.length]);
useEffect(() => {
if (textareaRef.current && messagesLength === 0 && status === 'ready') {
textareaRef.current.focus();
@@ -169,73 +214,118 @@ export const InputChat = ({
}
}, [status, input]);
useEffect(() => {
if (!fileUploadEnabled) {
return;
}
const handleFilesAccepted = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const dt = new DataTransfer();
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
}
};
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// Only hide when leaving the window completely
if (!e.relatedTarget) {
setIsDragActive(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
// Check for rejected files during drag over (does not work on Safari)
if (e.dataTransfer?.items) {
const items = Array.from(e.dataTransfer.items);
items.some((item) => {
if (item.kind === 'file') {
// Check file type
const type = item.type;
const dummyFile = new File([], '', { type });
return !isFileAccepted(dummyFile);
// Add new files (avoiding duplicates)
acceptedFiles.forEach((f) => {
const isDuplicate = Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
);
if (!isDuplicate) {
dt.items.add(f);
}
return false;
});
return dt.files;
});
},
[setFiles],
);
const { isDragActive } = useFileDragDrop({
enabled: fileUploadEnabled,
isFileAccepted,
onFilesAccepted: handleFilesAccepted,
onFilesRejected: () => showToastError(),
});
const isInputDisabled = status !== 'ready' || isUploadingFiles;
const containerCss = useMemo(
() => `
${CONTAINER_CSS}
padding: ${isDesktop ? '0' : '0 10px'};
`,
[isDesktop],
);
const textareaStyle = useMemo(
() => ({
...TEXTAREA_STYLE,
opacity: status === 'error' ? '0.5' : '1',
}),
[status],
);
const formPadding = isDesktop ? STYLES.formPadding : STYLES.formPaddingMobile;
// handlers
const handleTextareaChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleInputChange(e);
const textarea = e.target;
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200);
textarea.style.height = `${newHeight}px`;
},
[handleInputChange],
);
const handleTextareaKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = '0';
e.currentTarget.form?.requestSubmit?.();
}
};
},
[],
);
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
const handleAttachClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
if (!fileUploadEnabled) {
const handleWebSearchToggle = useCallback(() => {
onToggleWebSearch?.();
textareaRef.current?.focus();
}, [onToggleWebSearch]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList) {
return;
}
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(droppedFiles).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (acceptedFiles.length === 0) {
return;
}
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
@@ -256,67 +346,53 @@ export const InputChat = ({
return dt.files;
});
}
};
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
e.target.value = '';
},
[isFileAccepted, showToastError, setFiles],
);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [
fileUploadEnabled,
setFiles,
showToastError,
conf?.chat_upload_accept,
isFileAccepted,
]);
const handleAttachmentRemove = useCallback(
(index: number) => {
if (!files) {
return;
}
const isInputDisabled = status !== 'ready' || isUploadingFiles;
const dt = new DataTransfer();
Array.from(files).forEach((f, i) => {
if (i !== index) {
dt.items.add(f);
}
});
setFiles(dt.files.length > 0 ? dt.files : null);
},
[files, setFiles],
);
const fileUrlMap = useFileUrls(files);
const attachments = useMemo(() => {
if (!files) {
return [];
}
return Array.from(files).map((file) => {
const key = `${file.name}-${file.size}-${file.lastModified}`;
return {
name: file.name,
contentType: file.type,
url: fileUrlMap.get(key) || '',
};
});
}, [files, fileUrlMap]);
return (
<>
{isDragActive && (
<Box
$position="fixed"
$css={`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
animation: fadeIn 0.3s;
z-index: 999;
background-color: rgba(255, 255, 255, 0.5);
pointer-events: all;
`}
/>
)}
<Box
$css={`
display: block;
position: relative;
margin: auto;
width: 100%;
padding: ${isDesktop ? '0' : '0 10px'};
max-width: 750px;
`}
>
{isDragActive && <Box $position="fixed" $css={DRAG_FADE_CSS} />}
<Box $css={containerCss}>
{/* Bouton de scroll vers le bas */}
{messagesLength > 1 && containerRef && onScrollToBottom && (
<Box
$css={`
position: relative;
height: 0;
width: 100%;
margin: auto;
max-width: 750px;
`}
>
<Box $css={SCROLL_DOWN_WRAPPER_CSS}>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
@@ -324,48 +400,16 @@ export const InputChat = ({
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && (
<Box
$padding={{ all: 'base', bottom: 'md' }}
$align="center"
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
>
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
{t('What is on your mind?')}
</Text>
</Box>
)}
{messagesLength === 0 && <WelcomeMessage />}
<form
onSubmit={handleSubmit}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(fileUploadEnabled);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
// File handling is now done by global handler
}}
style={{ width: '100%' }}
>
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
<form onSubmit={handleSubmit} style={STYLES.form}>
<Box $padding={formPadding}>
<Box
$flex={1}
$radius="12px"
$position="relative"
$background="white"
$css={`
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
border-radius: 12px;
border: 1px solid var(--c--theme--colors--greyscale-200);
position: relative;
background: white;
transition: all 0.2s ease;
`}
$css={INPUT_BOX_CSS}
>
{isDragActive && (
<Box
@@ -374,16 +418,7 @@ export const InputChat = ({
$direction="row"
$justify="center"
$gap="1rem"
$css={`
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: #EDF0FF;
width: 100%;
height: 100%;
outline: 2px solid #90A7FF;
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
`}
$css={FILE_DROP_CSS}
>
<FilesIcon />
<Box>
@@ -401,88 +436,14 @@ export const InputChat = ({
aria-label={t('Enter your message or a question')}
value={input ?? ''}
name="inputchat-textarea"
onChange={(e) => {
handleInputChange(e);
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200);
textarea.style.height = `${newHeight}px`;
textarea.focus();
}}
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
disabled={isInputDisabled}
rows={1}
style={{
padding: '1rem 1.5rem',
background: 'transparent',
outline: 'none',
fontSize: '1rem',
border: 'none',
resize: 'none',
opacity: status === 'error' ? '0.5' : '1',
fontFamily: 'inherit',
minHeight: '64px',
maxHeight: '200px',
overflowY: 'auto',
transition: 'all 0.2s ease',
borderRadius: '12px',
color: 'var(--c--theme--colors--greyscale-800)',
lineHeight: '1.5',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = '0';
e.currentTarget.form?.requestSubmit?.();
textarea.focus();
}
}}
style={textareaStyle}
/>
{!input && (
<Box
$css={`
position: absolute;
top: 1rem;
left: 1.5rem;
right: 1.5rem;
height: 1.5rem;
pointer-events: none;
color: var(--c--theme--colors--greyscale-500);
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
overflow: hidden;
`}
>
<Box
$css={`
display: flex;
flex-direction: column;
height: ${(suggestions.length + 1) * 100}%;
transform: translateY(-${currentSuggestionIndex * (100 / (suggestions.length + 1))}%);
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
`}
>
{[...suggestions, suggestions[0]].map(
(suggestion, index) => (
<Box
key={index}
$css={`
height: calc(100% / ${suggestions.length + 1});
flex-shrink: 0;
white-space: nowrap;
display: flex;
justify-content: flex-start;
`}
>
{suggestion}
</Box>
),
)}
</Box>
</Box>
)}
{!input && <SuggestionCarousel messagesLength={messagesLength} />}
<input
accept={conf?.chat_upload_accept}
@@ -490,232 +451,37 @@ export const InputChat = ({
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(e) => {
const fileList = e.target.files;
if (!fileList) {
return;
}
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length === 0) {
e.target.value = '';
return;
}
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f: File) => dt.items.add(f));
}
acceptedFiles.forEach((f: File) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
return dt.files;
});
e.target.value = '';
}}
onChange={handleFileChange}
/>
{/*Aperçu des fichiers*/}
{files && files.length > 0 && (
<Box
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
$padding={{ horizontal: 'base' }}
$margin={STYLES.attachmentMargin}
$padding={STYLES.attachmentPadding}
>
<AttachmentList
attachments={Array.from(files).map((file) => ({
name: file.name,
contentType: file.type,
url: URL.createObjectURL(file),
}))}
onRemove={(index) => {
const dt = new DataTransfer();
Array.from(files).forEach((f, i) => {
if (i !== index) {
dt.items.add(f);
}
});
setFiles(dt.files.length > 0 ? dt.files : null);
}}
attachments={attachments}
onRemove={handleAttachmentRemove}
isReadOnly={false}
/>
</Box>
)}
<Box
$direction="row"
$gap="sm"
$padding={{ bottom: 'base' }}
$align="space-between"
$css={`
opacity: ${status === 'error' ? '0.5' : '1'};
`}
>
<Box
$flex="1"
$direction="row"
$padding={{ horizontal: 'base' }}
$gap="xs"
>
<Button
size="small"
type="button"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={() => fileInputRef.current?.click()}
aria-label={t('Add attach file')}
className="c__button--neutral attach-file-button"
icon={
<Icon
iconName="attach_file"
$theme="greyscale"
$variation="550"
$size={`${isMobile ? '24px' : '16px'}`}
/>
}
>
{!isMobile && (
<Text $theme="greyscale" $variation="550" $weight="500">
{t('Attach file')}
</Text>
)}
</Button>
{onToggleWebSearch && (
<Box
$margin={{ left: '4px' }}
$css={`
${
isMobile
? `
.research-web-button {
padding-right: 8px !important;
}
`
: ''
}
${
forceWebSearch
? `
.research-web-button {
background-color: var(--c--theme--colors--primary-100) !important;
}
`
: ''
}
`}
>
<Button
size="small"
type="button"
disabled={!webSearchEnabled || isUploadingFiles}
onClick={() => {
onToggleWebSearch();
textareaRef.current?.focus();
}}
aria-label={t('Research on the web')}
className="c__button--neutral research-web-button"
icon={
<Icon
iconName="language"
$theme="greyscale"
$variation="550"
$css={`
color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
`}
/>
}
>
{!isMobile && (
<Text
$theme={forceWebSearch ? 'primary' : 'greyscale'}
$variation="550"
>
{t('Research on the web')}
</Text>
)}
{isMobile && forceWebSearch && (
<Box
$direction="row"
$align="space-between"
$gap="xs"
$css={`
display: flex;
align-items: center;
line-height: 1;
`}
>
<Text
$theme="primary"
$weight="500"
$css={`
display: flex;
align-items: center;
`}
>
{t('Web')}
</Text>
<Icon
iconName="close"
$variation="text"
$theme="primary"
$size="md"
$css={`
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding-left: 4px;
`}
/>
</Box>
)}
</Button>
</Box>
)}
</Box>
<Box
$direction="row"
$padding={{ horizontal: 'base' }}
$gap="xs"
>
<Box $padding={{ horizontal: 'xs' }}>
{onModelSelect && (
<ModelSelector
selectedModel={selectedModel || null}
onModelSelect={onModelSelect}
/>
)}
</Box>
<SendButton
status={status}
disabled={!input || !input.trim() || isUploadingFiles}
onClick={onStop}
/>
</Box>
</Box>
<InputChatActions
fileUploadEnabled={fileUploadEnabled}
webSearchEnabled={webSearchEnabled}
isUploadingFiles={isUploadingFiles}
isMobile={isMobile}
forceWebSearch={forceWebSearch}
onAttachClick={handleAttachClick}
onWebSearchToggle={
onToggleWebSearch ? handleWebSearchToggle : undefined
}
onModelSelect={onModelSelect}
selectedModel={selectedModel || null}
status={status}
inputHasContent={Boolean(input?.trim())}
onStop={onStop}
/>
</Box>
</Box>
</form>
@@ -0,0 +1,235 @@
import { Button } from '@openfun/cunningham-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
import { ModelSelector } from './ModelSelector';
import { SendButton } from './SendButton';
interface InputChatActionsProps {
/** Whether file upload feature is enabled */
fileUploadEnabled: boolean;
/** Whether web search feature is enabled */
webSearchEnabled: boolean;
/** Whether files are currently being uploaded */
isUploadingFiles: boolean;
/** Whether the device is mobile */
isMobile: boolean;
/** Whether web search is forced/active */
forceWebSearch: boolean;
/** Handler for attach button click */
onAttachClick: () => void;
/** Handler for web search toggle - if undefined, button is hidden */
onWebSearchToggle?: () => void;
/** Handler for model selection - if undefined, selector is hidden */
onModelSelect?: (model: LLMModel) => void;
/** Currently selected model */
selectedModel: LLMModel | null;
/** Current chat status */
status: string | null;
/** Whether input has content (for send button) */
inputHasContent: boolean;
/** Handler for stop button */
onStop?: () => void;
}
const STYLES = {
actionsGap: { bottom: 'base' },
horizontalPadding: { horizontal: 'base' },
horizontalPaddingXs: { horizontal: 'xs' },
webSearchMargin: { left: '4px' },
} as const;
const MOBILE_WEB_BUTTON_CSS = `
.research-web-button {
padding-right: 8px !important;
}
`;
const ACTIVE_WEB_BUTTON_CSS = `
.research-web-button {
background-color: var(--c--theme--colors--primary-100) !important;
}
`;
const MOBILE_TEXT_WRAPPER_CSS = `
display: flex;
align-items: center;
line-height: 1;
`;
const MOBILE_TEXT_CSS = `
display: flex;
align-items: center;
`;
const CLOSE_ICON_CSS = `
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding-left: 4px;
`;
/**
* Action buttons for the chat input.
* Includes: Attach file, Web search toggle, Model selector, Send button.
*
* Memoized to prevent re-renders when parent updates but props haven't changed.
*/
export const InputChatActions = memo(
({
fileUploadEnabled,
webSearchEnabled,
isUploadingFiles,
isMobile,
forceWebSearch,
onAttachClick,
onWebSearchToggle,
onModelSelect,
selectedModel,
status,
inputHasContent,
onStop,
}: InputChatActionsProps) => {
const { t } = useTranslation();
// Memoized dynamic styles
const actionsOpacityCss = useMemo(
() => `opacity: ${status === 'error' ? '0.5' : '1'};`,
[status],
);
const webSearchWrapperCss = useMemo(() => {
let css = '';
if (isMobile) {
css += MOBILE_WEB_BUTTON_CSS;
}
if (forceWebSearch) {
css += ACTIVE_WEB_BUTTON_CSS;
}
return css;
}, [isMobile, forceWebSearch]);
const webSearchIconCss = useMemo(
() =>
`color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}`,
[forceWebSearch],
);
const attachIconSize = isMobile ? '24px' : '16px';
return (
<Box
$direction="row"
$gap="sm"
$padding={STYLES.actionsGap}
$align="space-between"
$css={actionsOpacityCss}
>
{/* Left side: Attach + Web Search */}
<Box
$flex="1"
$direction="row"
$padding={STYLES.horizontalPadding}
$gap="xs"
>
{/* Attach file button */}
<Button
size="small"
type="button"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={onAttachClick}
aria-label={t('Add attach file')}
className="c__button--neutral attach-file-button"
icon={
<Icon
iconName="attach_file"
$theme="greyscale"
$variation="550"
$size={attachIconSize}
/>
}
>
{!isMobile && (
<Text $theme="greyscale" $variation="550" $weight="500">
{t('Attach file')}
</Text>
)}
</Button>
{/* Web search toggle button */}
{onWebSearchToggle && (
<Box $margin={STYLES.webSearchMargin} $css={webSearchWrapperCss}>
<Button
size="small"
type="button"
disabled={!webSearchEnabled || isUploadingFiles}
onClick={onWebSearchToggle}
aria-label={t('Research on the web')}
className="c__button--neutral research-web-button"
icon={
<Icon
iconName="language"
$theme="greyscale"
$variation="550"
$css={webSearchIconCss}
/>
}
>
{!isMobile && (
<Text
$theme={forceWebSearch ? 'primary' : 'greyscale'}
$variation="550"
>
{t('Research on the web')}
</Text>
)}
{isMobile && forceWebSearch && (
<Box
$direction="row"
$align="space-between"
$gap="xs"
$css={MOBILE_TEXT_WRAPPER_CSS}
>
<Text $theme="primary" $weight="500" $css={MOBILE_TEXT_CSS}>
{t('Web')}
</Text>
<Icon
iconName="close"
$variation="text"
$theme="primary"
$size="md"
$css={CLOSE_ICON_CSS}
/>
</Box>
)}
</Button>
</Box>
)}
</Box>
{/* Right side: Model selector + Send */}
<Box $direction="row" $padding={STYLES.horizontalPadding} $gap="xs">
{onModelSelect && (
<Box $padding={STYLES.horizontalPaddingXs}>
<ModelSelector
selectedModel={selectedModel}
onModelSelect={onModelSelect}
/>
</Box>
)}
<SendButton
status={status}
disabled={!inputHasContent || isUploadingFiles}
onClick={onStop}
/>
</Box>
</Box>
);
},
);
InputChatActions.displayName = 'InputChatActions';
@@ -4,7 +4,7 @@ import React from 'react';
import { Box } from '@/components';
import { SourceItem } from '@/features/chat/components/SourceItem';
interface SourceMetadata {
export interface SourceMetadata {
title: string | null;
favicon: string | null;
loading: boolean;
@@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
const SUGGESTION_KEYS = [
'Ask a question',
'Turn this list into bullet points',
'Write a short product description',
'Find recent news about...',
] as const;
const SUGGESTIONS_COUNT = SUGGESTION_KEYS.length;
const WRAPPER_CSS = `position: absolute;
top: 1rem;
left: 1.5rem;
right: 1.5rem;
height: 1.5rem;
pointer-events: none;
color: var(--c--theme--colors--greyscale-500);
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
overflow: hidden;`;
const ITEM_CSS = `
height: calc(100% / ${SUGGESTIONS_COUNT + 1});
flex-shrink: 0;
white-space: nowrap;
display: flex;
justify-content: flex-start;
`;
interface SuggestionCarouselProps {
messagesLength: number;
}
export const SuggestionCarousel = ({
messagesLength,
}: SuggestionCarouselProps) => {
const { t } = useTranslation();
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
const [isResetting, setIsResetting] = useState(false);
const suggestions = useMemo(() => SUGGESTION_KEYS.map((key) => t(key)), [t]);
const carouselSuggestions = useMemo(
() => [...suggestions, suggestions[0]],
[suggestions],
);
const carrouselCss = useMemo(
() => `
display: flex;
flex-direction: column;
height: ${(SUGGESTIONS_COUNT + 1) * 100}%;
transform: translateY(-${currentSuggestionIndex * (100 / (SUGGESTIONS_COUNT + 1))}%);
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
`,
[currentSuggestionIndex, isResetting],
);
useEffect(() => {
if (messagesLength === 0) {
const interval = setInterval(() => {
setCurrentSuggestionIndex((prev) => {
if (prev === SUGGESTIONS_COUNT - 1) {
return SUGGESTIONS_COUNT;
}
return prev + 1;
});
}, 3000);
return () => clearInterval(interval);
}
}, [messagesLength]);
useEffect(() => {
if (currentSuggestionIndex === suggestions.length) {
const timeout = setTimeout(() => {
setIsResetting(true);
setCurrentSuggestionIndex(0);
setTimeout(() => setIsResetting(false), 50);
}, 500);
return () => clearTimeout(timeout);
}
}, [currentSuggestionIndex, suggestions.length]);
return (
<Box $css={WRAPPER_CSS}>
<Box $css={carrouselCss}>
{carouselSuggestions.map((suggestion, index) => (
<Box key={index} $css={ITEM_CSS}>
{suggestion}
</Box>
))}
</Box>
</Box>
);
};
SuggestionCarousel.displayName = 'SuggestionCarousel';
@@ -18,7 +18,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
const { t } = useTranslation();
if (toolInvocation.toolName === 'document_parsing') {
if (toolInvocation.state === 'partial-call') {
if (
toolInvocation.state === 'partial-call' ||
toolInvocation.state === 'result'
) {
return null;
}
@@ -32,24 +35,23 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
)
? documents.map((doc) => doc.identifier)
: [];
return (
<Box
as="pre"
$direction="row"
$align="center"
$gap="6px"
key={toolInvocation.toolCallId}
$background="var(--c--theme--colors--greyscale-100)"
$color="var(--c--theme--colors--greyscale-500)"
$padding={{ all: 'sm' }}
$radius="8px"
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
>
{toolInvocation.state === 'result' ? (
<Text>{`Parsing done: ${documentIdentifiers.join(', ')}`}</Text>
) : (
<Box $direction="row" $gap="1rem" $align="center">
<Loader />
<Text>{`Parsing documents: ${documentIdentifiers.join(', ')} ...`}</Text>
</Box>
)}
<Loader />
<Text $variation="600" $size="md">
{t('Extracting documents: {{documents}} ...', {
documents: documentIdentifiers.join(', '),
})}
</Text>
</Box>
);
}
@@ -0,0 +1,25 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
const WELCOME_MARGIN = {
horizontal: 'base',
bottom: 'md',
top: '-105px',
} as const;
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
export const WelcomeMessage = memo(() => {
const { t } = useTranslation();
return (
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
{t('What is on your mind?')}
</Text>
</Box>
);
});
WelcomeMessage.displayName = 'WelcomeMessage';
@@ -0,0 +1,190 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { InputChat } from '../InputChat';
// Mock stores and hooks
jest.mock('@/stores', () => ({
useResponsiveStore: () => ({
isDesktop: true,
isMobile: false,
}),
}));
jest.mock('@/core', () => ({
useConfig: () => ({
data: {
FEATURE_FLAGS: {
'web-search': 'enabled',
'document-upload': 'enabled',
},
chat_upload_accept: '.pdf,.txt',
},
}),
FeatureFlagState: {
ENABLED: 'enabled',
DISABLED: 'disabled',
},
}));
jest.mock('@/libs', () => ({
useAnalytics: () => ({
isFeatureFlagActivated: jest.fn(() => true),
}),
}));
jest.mock('@/components/ToastProvider', () => ({
useToast: () => ({
showToast: jest.fn(),
}),
}));
jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({
useFileDragDrop: () => ({
isDragActive: false,
}),
}));
jest.mock('@/features/chat/hooks/useFileUrls', () => ({
useFileUrls: () => new Map(),
}));
// Mock child components
jest.mock('../InputChatAction', () => ({
InputChatActions: () => <div data-testid="input-chat-actions">Actions</div>,
}));
jest.mock('../SuggestionCarousel', () => ({
SuggestionCarousel: () => (
<div data-testid="suggestion-carousel">Suggestions</div>
),
}));
jest.mock('../WelcomeMessage', () => ({
WelcomeMessage: () => <div data-testid="welcome-message">Welcome</div>,
}));
jest.mock('../AttachmentList', () => ({
AttachmentList: () => <div data-testid="attachment-list">Attachments</div>,
}));
jest.mock('../ScrollDown', () => ({
ScrollDown: () => <div data-testid="scroll-down">Scroll Down</div>,
}));
jest.mock('../../assets/files.svg', () => () => (
<svg data-testid="files-icon" />
));
const defaultProps = {
messagesLength: 0,
input: '',
handleInputChange: jest.fn(),
handleSubmit: jest.fn(),
status: 'ready' as const,
files: null,
setFiles: jest.fn(),
};
describe('InputChat', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the textarea', () => {
render(<InputChat {...defaultProps} />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeInTheDocument();
});
it('should render welcome message when messagesLength is 0', () => {
render(<InputChat {...defaultProps} messagesLength={0} />);
expect(screen.getByTestId('welcome-message')).toBeInTheDocument();
});
it('should not render welcome message when messagesLength > 0', () => {
render(<InputChat {...defaultProps} messagesLength={1} />);
expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument();
});
it('should render suggestion carousel when input is empty', () => {
render(<InputChat {...defaultProps} input="" />);
expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument();
});
it('should not render suggestion carousel when input has content', () => {
render(<InputChat {...defaultProps} input="Hello" />);
expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument();
});
it('should render input chat actions', () => {
render(<InputChat {...defaultProps} />);
expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument();
});
it('should call handleInputChange when typing', async () => {
const user = userEvent.setup();
const handleInputChange = jest.fn();
render(
<InputChat {...defaultProps} handleInputChange={handleInputChange} />,
);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, 'Hello');
expect(handleInputChange).toHaveBeenCalled();
});
it('should disable textarea when status is not ready', () => {
render(<InputChat {...defaultProps} status="streaming" />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeDisabled();
});
it('should disable textarea when isUploadingFiles is true', () => {
render(<InputChat {...defaultProps} isUploadingFiles={true} />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeDisabled();
});
it('should submit form when pressing Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, '{Enter}');
expect(handleSubmit).toHaveBeenCalled();
});
it('should not submit form when pressing Shift+Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, '{Shift>}{Enter}{/Shift}');
expect(handleSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,192 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { InputChatActions } from '../InputChatAction';
jest.mock('../ModelSelector', () => ({
ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => (
<button onClick={onModelSelect} data-testid="model-selector">
Model Selector
</button>
),
}));
jest.mock('../SendButton', () => ({
SendButton: ({
onClick,
disabled,
}: {
onClick: () => void;
disabled: boolean;
}) => (
<button onClick={onClick} disabled={disabled} data-testid="send-button">
Send
</button>
),
}));
const defaultProps = {
fileUploadEnabled: true,
webSearchEnabled: true,
isUploadingFiles: false,
isMobile: false,
forceWebSearch: false,
onAttachClick: jest.fn(),
selectedModel: null,
status: null,
inputHasContent: true,
};
describe('InputChatActions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render attach file button', () => {
render(<InputChatActions {...defaultProps} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeInTheDocument();
expect(screen.getByText('Attach file')).toBeInTheDocument();
});
it('should call onAttachClick when attach button is clicked', async () => {
const user = userEvent.setup();
const onAttachClick = jest.fn();
render(
<InputChatActions {...defaultProps} onAttachClick={onAttachClick} />,
);
await user.click(screen.getByRole('button', { name: 'Add attach file' }));
expect(onAttachClick).toHaveBeenCalledTimes(1);
});
it('should disable attach button when fileUploadEnabled is false', () => {
render(<InputChatActions {...defaultProps} fileUploadEnabled={false} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeDisabled();
});
it('should disable attach button when isUploadingFiles is true', () => {
render(<InputChatActions {...defaultProps} isUploadingFiles={true} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeDisabled();
});
it('should not show attach text on mobile', () => {
render(<InputChatActions {...defaultProps} isMobile={true} />);
expect(screen.queryByText('Attach file')).not.toBeInTheDocument();
});
it('should render web search button when onWebSearchToggle is provided', () => {
const onWebSearchToggle = jest.fn();
render(
<InputChatActions
{...defaultProps}
onWebSearchToggle={onWebSearchToggle}
/>,
);
expect(
screen.getByRole('button', { name: 'Research on the web' }),
).toBeInTheDocument();
});
it('should not render web search button when onWebSearchToggle is undefined', () => {
render(
<InputChatActions {...defaultProps} onWebSearchToggle={undefined} />,
);
expect(
screen.queryByRole('button', { name: 'Research on the web' }),
).not.toBeInTheDocument();
});
it('should call onWebSearchToggle when web search button is clicked', async () => {
const user = userEvent.setup();
const onWebSearchToggle = jest.fn();
render(
<InputChatActions
{...defaultProps}
onWebSearchToggle={onWebSearchToggle}
/>,
);
await user.click(
screen.getByRole('button', { name: 'Research on the web' }),
);
expect(onWebSearchToggle).toHaveBeenCalledTimes(1);
});
it('should disable web search button when webSearchEnabled is false', () => {
render(
<InputChatActions
{...defaultProps}
webSearchEnabled={false}
onWebSearchToggle={jest.fn()}
/>,
);
expect(
screen.getByRole('button', { name: 'Research on the web' }),
).toBeDisabled();
});
it('should render model selector when onModelSelect is provided', () => {
const onModelSelect = jest.fn();
render(
<InputChatActions {...defaultProps} onModelSelect={onModelSelect} />,
);
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
});
it('should not render model selector when onModelSelect is undefined', () => {
render(<InputChatActions {...defaultProps} onModelSelect={undefined} />);
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument();
});
it('should render send button', () => {
render(<InputChatActions {...defaultProps} />);
expect(screen.getByTestId('send-button')).toBeInTheDocument();
});
it('should show "Web" text on mobile when forceWebSearch is active', () => {
render(
<InputChatActions
{...defaultProps}
isMobile={true}
forceWebSearch={true}
onWebSearchToggle={jest.fn()}
/>,
);
expect(screen.getByText('Web')).toBeInTheDocument();
});
it('should show "Research on the web" text on desktop when forceWebSearch is active', () => {
render(
<InputChatActions
{...defaultProps}
isMobile={false}
forceWebSearch={true}
onWebSearchToggle={jest.fn()}
/>,
);
expect(screen.getByText('Research on the web')).toBeInTheDocument();
expect(screen.queryByText('Web')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { SuggestionCarousel } from '../SuggestionCarousel';
describe('SuggestionCarousel', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should render all suggestions', () => {
render(<SuggestionCarousel messagesLength={0} />);
// First suggestion appears twice (duplicated for looping animation)
expect(screen.getAllByText('Ask a question')).toHaveLength(2);
expect(
screen.getByText('Turn this list into bullet points'),
).toBeInTheDocument();
expect(
screen.getByText('Write a short product description'),
).toBeInTheDocument();
expect(screen.getByText('Find recent news about...')).toBeInTheDocument();
});
it('should start the interval when messagesLength is 0', () => {
const setIntervalSpy = jest.spyOn(global, 'setInterval');
render(<SuggestionCarousel messagesLength={0} />);
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
setIntervalSpy.mockRestore();
});
it('should not start interval when messagesLength is greater than 0', () => {
const setIntervalSpy = jest.spyOn(global, 'setInterval');
render(<SuggestionCarousel messagesLength={1} />);
expect(setIntervalSpy).not.toHaveBeenCalled();
setIntervalSpy.mockRestore();
});
it('should clear interval on unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
const { unmount } = render(<SuggestionCarousel messagesLength={0} />);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { WelcomeMessage } from '../WelcomeMessage';
describe('WelcomeMessage', () => {
it('should render the welcome message', () => {
render(<WelcomeMessage />);
expect(
screen.getByRole('heading', { level: 2, name: 'What is on your mind?' }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,257 @@
import { act, renderHook } from '@testing-library/react';
import { useFileDragDrop } from '../useFileDragDrop';
const createFile = (name: string, type: string): File => {
return new File(['content'], name, { type });
};
const createDragEvent = (
type: string,
files?: File[],
relatedTarget?: EventTarget | null,
): DragEvent => {
const event = new Event(type, { bubbles: true }) as DragEvent;
const dataTransfer = {
types: files ? ['Files'] : [],
files: files
? {
length: files.length,
item: (i: number) => files[i],
[Symbol.iterator]: function* () {
for (const file of files) {
yield file;
}
},
}
: { length: 0 },
} as unknown as DataTransfer;
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget });
Object.defineProperty(event, 'preventDefault', { value: jest.fn() });
return event;
};
describe('useFileDragDrop', () => {
const defaultProps = {
enabled: true,
isFileAccepted: jest.fn(() => true),
onFilesAccepted: jest.fn(),
onFilesRejected: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize with isDragActive as false', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
expect(result.current.isDragActive).toBe(false);
});
it('should set isDragActive to true on dragenter with files', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
});
it('should not set isDragActive on dragenter without files', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(createDragEvent('dragenter'));
});
expect(result.current.isDragActive).toBe(false);
});
it('should set isDragActive to false on dragleave when leaving window', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(createDragEvent('dragleave', [], null));
});
expect(result.current.isDragActive).toBe(false);
});
it('should not set isDragActive to false on dragleave when moving between elements', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(createDragEvent('dragleave', [], document.body));
});
expect(result.current.isDragActive).toBe(true);
});
it('should set isDragActive to false on drop', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(false);
});
it('should call onFilesAccepted with accepted files on drop', () => {
const onFilesAccepted = jest.fn();
const file = createFile('test.txt', 'text/plain');
renderHook(() =>
useFileDragDrop({
...defaultProps,
onFilesAccepted,
}),
);
act(() => {
window.dispatchEvent(createDragEvent('drop', [file]));
});
expect(onFilesAccepted).toHaveBeenCalledWith([file]);
});
it('should call onFilesRejected with rejected file names', () => {
const onFilesRejected = jest.fn();
const isFileAccepted = jest.fn(() => false);
renderHook(() =>
useFileDragDrop({
...defaultProps,
isFileAccepted,
onFilesRejected,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [createFile('test.exe', 'application/exe')]),
);
});
expect(onFilesRejected).toHaveBeenCalledWith(['test.exe']);
});
it('should separate accepted and rejected files', () => {
const onFilesAccepted = jest.fn();
const onFilesRejected = jest.fn();
const isFileAccepted = jest.fn((file: File) => file.type === 'text/plain');
const acceptedFile = createFile('good.txt', 'text/plain');
const rejectedFile = createFile('bad.exe', 'application/exe');
renderHook(() =>
useFileDragDrop({
...defaultProps,
isFileAccepted,
onFilesAccepted,
onFilesRejected,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [acceptedFile, rejectedFile]),
);
});
expect(onFilesAccepted).toHaveBeenCalledWith([acceptedFile]);
expect(onFilesRejected).toHaveBeenCalledWith(['bad.exe']);
});
it('should ignore drag events when disabled', () => {
const { result } = renderHook(() =>
useFileDragDrop({
...defaultProps,
enabled: false,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(false);
});
it('should reset isDragActive when disabled changes to false', () => {
const { result, rerender } = renderHook(
({ enabled }) => useFileDragDrop({ ...defaultProps, enabled }),
{ initialProps: { enabled: true } },
);
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
rerender({ enabled: false });
expect(result.current.isDragActive).toBe(false);
});
it('should clean up event listeners on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useFileDragDrop(defaultProps));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragenter',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragleave',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragover',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'drop',
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});
});
@@ -0,0 +1,161 @@
import { renderHook } from '@testing-library/react';
import { useFileUrls } from '../useFileUrls';
const createFile = (
name: string,
size: number = 100,
lastModified: number = 1234567890,
): File => {
const file = new File(['x'.repeat(size)], name, { type: 'text/plain' });
Object.defineProperty(file, 'size', { value: size });
Object.defineProperty(file, 'lastModified', { value: lastModified });
return file;
};
const createFileList = (files: File[]): FileList => {
return {
length: files.length,
item: (index: number) => files[index] || null,
[Symbol.iterator]: function* () {
for (const file of files) {
yield file;
}
},
...files.reduce(
(acc, file, index) => {
acc[index] = file;
return acc;
},
{} as Record<number, File>,
),
} as FileList;
};
describe('useFileUrls', () => {
let urlCounter: number;
const mockCreateObjectURL = jest.fn();
const mockRevokeObjectURL = jest.fn();
beforeEach(() => {
urlCounter = 0;
mockCreateObjectURL.mockImplementation(
() => `blob:mock-url-${++urlCounter}`,
);
mockRevokeObjectURL.mockImplementation(() => {});
global.URL.createObjectURL = mockCreateObjectURL;
global.URL.revokeObjectURL = mockRevokeObjectURL;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return an empty Map when files is null', () => {
const { result } = renderHook(() => useFileUrls(null));
expect(result.current).toEqual(new Map());
});
it('should create URLs for files', () => {
const file = createFile('test.txt');
const fileList = createFileList([file]);
const { result } = renderHook(() => useFileUrls(fileList));
expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
expect(result.current.size).toBe(1);
expect(result.current.get('test.txt-100-1234567890')).toBe(
'blob:mock-url-1',
);
});
it('should create URLs for multiple files', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const fileList = createFileList([file1, file2]);
const { result } = renderHook(() => useFileUrls(fileList));
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
expect(result.current.size).toBe(2);
expect(result.current.get('file1.txt-100-1000')).toBe('blob:mock-url-1');
expect(result.current.get('file2.txt-200-2000')).toBe('blob:mock-url-2');
});
it('should reuse URLs for unchanged files', () => {
const file = createFile('test.txt');
const fileList1 = createFileList([file]);
const fileList2 = createFileList([file]);
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: fileList1 },
});
const initialUrl = result.current.get('test.txt-100-1234567890');
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
rerender({ files: fileList2 });
expect(result.current.get('test.txt-100-1234567890')).toBe(initialUrl);
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
});
it('should revoke URLs when files are removed', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1, file2]) },
});
rerender({ files: createFileList([file1]) });
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
});
it('should revoke all URLs when files becomes null', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { rerender } = renderHook<
Map<string, string>,
{ files: FileList | null }
>(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1, file2]) },
});
rerender({ files: null });
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
});
it('should clean up all URLs on unmount', () => {
const file = createFile('test.txt');
const fileList = createFileList([file]);
const { unmount } = renderHook(() => useFileUrls(fileList));
unmount();
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
});
it('should create new URL when adding a file', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1]) },
});
expect(result.current.size).toBe(1);
rerender({ files: createFileList([file1, file2]) });
expect(result.current.size).toBe(2);
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useState } from 'react';
interface UseFileDragDropOptions {
/**
* Whether file upload feature is enabled.
* When false, drag/drop events are ignored.
*/
enabled: boolean;
/**
* Callback to validate if a file type is accepted.
* @param file - File to validate
* @returns true if file should be accepted
*/
isFileAccepted: (file: File) => boolean;
/**
* Callback when files are dropped and validated.
* Receives only the accepted files.
*/
onFilesAccepted: (files: File[]) => void;
/**
* Callback when some files are rejected due to type.
* Useful for showing error toasts.
*/
onFilesRejected?: (fileNames: string[]) => void;
}
interface UseFileDragDropReturn {
/**
* Whether a drag operation is currently active over the window.
* Use this to show a drop overlay.
*/
isDragActive: boolean;
}
/**
* Custom hook to handle file drag and drop functionality.
*
* Purpose:
* - Manages window-level drag/drop events for file uploads
* - Tracks drag state to show/hide drop overlay
* - Validates files against accepted types
* - Separates accepted and rejected files
* - Cleans up event listeners on unmount
*
* Why window-level events:
* - Allows dropping files anywhere on the page
* - Better UX than requiring precise drop zone targeting
* - Handles edge cases like dragging over child elements
*
* @example
* const { isDragActive } = useFileDragDrop({
* enabled: fileUploadEnabled,
* isFileAccepted: (file) => file.type.startsWith('image/'),
* onFilesAccepted: (files) => setFiles(files),
* onFilesRejected: (names) => showError(`Rejected: ${names.join(', ')}`),
* });
*/
export const useFileDragDrop = ({
enabled,
isFileAccepted,
onFilesAccepted,
onFilesRejected,
}: UseFileDragDropOptions): UseFileDragDropReturn => {
const [isDragActive, setIsDragActive] = useState(false);
// Process dropped files: separate accepted from rejected
const processFiles = useCallback(
(fileList: FileList) => {
const accepted: File[] = [];
const rejectedNames: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
accepted.push(file);
} else {
rejectedNames.push(file.name);
}
});
// Notify about rejected files first (for error toasts)
if (rejectedNames.length > 0 && onFilesRejected) {
onFilesRejected(rejectedNames);
}
// Then handle accepted files
if (accepted.length > 0) {
onFilesAccepted(accepted);
}
},
[isFileAccepted, onFilesAccepted, onFilesRejected],
);
// Window-level drag/drop handlers
useEffect(() => {
if (!enabled) {
setIsDragActive(false);
return;
}
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
// Only activate for file drags (not text selections, etc.)
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// Only deactivate when leaving the window entirely
// relatedTarget is null when cursor leaves the document
if (!e.relatedTarget) {
setIsDragActive(false);
}
};
const handleDragOver = (e: DragEvent) => {
// Required to allow drop
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
processFiles(droppedFiles);
}
};
// Attach to window for full-page drag/drop support
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [enabled, processFiles]);
return {
isDragActive,
};
};
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
/**
* Manages object URLs for file previews with proper memory cleanup.
*
* - Reuses URLs for unchanged files across re-renders
* - Revokes URLs when files are removed
* - Cleans up all URLs on unmount
*
* @param files - FileList from input or drag/drop, or null
* @returns Map of file keys (`${name}-${size}-${lastModified}`) to object URLs
*/
export const useFileUrls = (files: FileList | null) => {
const [urlMap, setUrlMap] = useState<Map<string, string>>(new Map());
const urlMapRef = useRef<Map<string, string>>(new Map());
// Keep ref in sync with latest state
useEffect(() => {
urlMapRef.current = urlMap;
});
// Handle URLs when files change
useEffect(() => {
const prevMap = urlMapRef.current;
if (!files) {
prevMap.forEach((url) => URL.revokeObjectURL(url));
setUrlMap(new Map());
return;
}
const newMap = new Map<string, string>();
const currentFileKeys = new Set(
Array.from(files).map((f) => getFileKey(f)),
);
// Reuse or revoke inline
prevMap.forEach((url, key) => {
if (currentFileKeys.has(key)) {
newMap.set(key, url);
} else {
URL.revokeObjectURL(url);
}
});
// Create URLs for new files
Array.from(files).forEach((file) => {
const key = getFileKey(file);
if (!newMap.has(key)) {
newMap.set(key, URL.createObjectURL(file));
}
});
setUrlMap(newMap);
}, [files]);
// Unmount-only cleanup
useEffect(() => {
return () => {
urlMapRef.current.forEach((url) => URL.revokeObjectURL(url));
};
}, []);
return urlMap;
};
/**
* Generates a unique key for a file based on its properties.
* Used to track file identity across renders.
*/
const getFileKey = (file: File): string => {
return `${file.name}-${file.size}-${file.lastModified}`;
};
@@ -1,9 +1,10 @@
import { useModal } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
import { ModalRemoveConversation } from './ModalRemoveConversation';
import { ModalRenameConversation } from './ModalRenameConversation';
@@ -12,77 +13,86 @@ interface ConversationItemActionsProps {
conversation: ChatConversation;
}
const dropdownStyles = css`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: #e1e3e7 !important;
}
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: 2px;
}
`;
const iconStyles = css`
font-size: 1rem;
color: var(--c--theme--colors--primary-text-text);
pointer-events: none;
`;
export const ConversationItemActions = ({
conversation,
}: ConversationItemActionsProps) => {
const { t } = useTranslation();
const deleteModal = useModal();
const renameModal = useModal();
const deleteModal = useOwnModal();
const renameModal = useOwnModal();
const options: DropdownMenuOption[] = [
{
label: t('Rename chat'),
icon: 'edit',
callback: () => renameModal.open(),
callback: renameModal.open,
disabled: false,
testId: `conversation-item-actions-rename-${conversation.id}`,
},
{
label: t('Delete chat'),
icon: 'delete',
callback: () => deleteModal.open(),
callback: deleteModal.open,
disabled: false,
testId: `conversation-item-actions-remove-${conversation.id}`,
},
];
const dropdownLabel = useMemo(
() =>
t('Actions list for conversation {{title}}', {
title: conversation.title || t('Untitled conversation'),
}),
[t, conversation.title],
);
return (
<>
<DropdownMenu
options={options}
label={t('Actions list for conversation {{title}}', {
title: conversation.title || t('Untitled conversation'),
})}
buttonCss={css`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: #e1e3e7 !important;
}
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: 2px;
}
`}
label={dropdownLabel}
buttonCss={dropdownStyles}
>
<Icon
data-testid={`conversation-item-actions-button-${conversation.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
$css={css`
font-size: 1rem;
color: var(--c--theme--colors--primary-text-text);
pointer-events: none;
`}
$css={iconStyles}
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveConversation
onClose={deleteModal.onClose}
onClose={deleteModal.close}
conversation={conversation}
/>
)}
{renameModal.isOpen && (
<ModalRenameConversation
onClose={renameModal.onClose}
onClose={renameModal.close}
conversation={conversation}
/>
)}
@@ -1,3 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -12,61 +13,90 @@ type LeftPanelConversationItemProps = {
isCurrentConversation: boolean;
};
export const LeftPanelConversationItem = ({
conversation,
isCurrentConversation,
}: LeftPanelConversationItemProps) => {
const { isDesktop } = useResponsiveStore();
const { setPanelOpen } = useChatPreferencesStore();
const linkStyles = css`
overflow: auto;
flex-grow: 1;
color: var(--c--theme--colors--greyscale-900);
`;
const handleLinkClick = () => {
if (!isDesktop) {
setPanelOpen(false);
const baseBoxStyles = css`
border-radius: 4px;
width: 100%;
margin-bottom: 1px;
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
&:hover,
&:focus,
&:focus-within {
background-color: #ebedf1;
.pinned-actions {
opacity: 1;
}
};
}
.pinned-actions:focus-within {
opacity: 1;
}
`;
return (
<Box
$direction="row"
$align="center"
$padding={{ horizontal: 'xs', vertical: '4px' }}
$justify="space-between"
$css={css`
border-radius: 4px;
width: 100%;
margin-bottom: 1px;
background-color: ${isCurrentConversation ? '#ebedf1' : ''};
font-weight: ${isCurrentConversation ? '700' : '500'};
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
.pinned-actions {
padding: 2px 0;
opacity: ${isDesktop ? 0 : 1};
background-color: transparent
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
}
&:hover, &:focus, &:focus-within {
background-color: #ebedf1;
.pinned-actions {
opacity: 1;
}
}
.pinned-actions:focus-within {
opacity: 1;
}
`}
className="--docs--left-panel-favorite-item"
>
<StyledLink
href={`/chat/${conversation.id}/`}
$css="overflow: auto; flex-grow: 1; color: var(--c--theme--colors--greyscale-900);"
onClick={handleLinkClick}
const getBoxStyles = (
isCurrentConversation: boolean,
isDesktop: boolean,
) => css`
${baseBoxStyles}
background-color: ${isCurrentConversation ? '#ebedf1' : 'transparent'};
font-weight: ${isCurrentConversation ? '700' : '500'};
.pinned-actions {
padding: 2px 0;
opacity: ${isDesktop ? 0 : 1};
background-color: transparent;
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
}
`;
const containerPadding = { horizontal: 'xs', vertical: '4px' };
export const LeftPanelConversationItem = memo(
function LeftPanelConversationItem({
conversation,
isCurrentConversation,
}: LeftPanelConversationItemProps) {
const isDesktop = useResponsiveStore((state) => state.isDesktop);
const setPanelOpen = useChatPreferencesStore((state) => state.setPanelOpen);
const handleLinkClick = useCallback(() => {
if (!isDesktop) {
setPanelOpen(false);
}
}, [isDesktop, setPanelOpen]);
const boxStyles = useMemo(
() => getBoxStyles(isCurrentConversation, isDesktop),
[isCurrentConversation, isDesktop],
);
return (
<Box
$direction="row"
$align="center"
$padding={containerPadding}
$justify="space-between"
$css={boxStyles}
className="--docs--left-panel-favorite-item"
>
<SimpleConversationItem showAccesses conversation={conversation} />
</StyledLink>
<StyledLink
href={`/chat/${conversation.id}/`}
$css={linkStyles}
onClick={handleLinkClick}
>
<SimpleConversationItem showAccesses conversation={conversation} />
</StyledLink>
<Box className="pinned-actions">
<ConversationItemActions conversation={conversation} />
<Box className="pinned-actions">
<ConversationItemActions conversation={conversation} />
</Box>
</Box>
</Box>
);
};
);
},
);
@@ -1,11 +1,10 @@
// A SUPPRIMER ?
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ChatConversation } from '@/features/chat/types';
import { useResponsiveStore } from '@/stores';
import BubbleIcon from '../assets/bubble-bold.svg';
@@ -18,19 +17,22 @@ const ItemTextCss = css`
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
const bubbleContainerStyles = css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`;
type SimpleConversationItemProps = {
conversation: ChatConversation;
showAccesses?: boolean;
};
export const SimpleConversationItem = ({
export const SimpleConversationItem = memo(function SimpleConversationItem({
conversation,
showAccesses: _showAccesses = false,
}: SimpleConversationItemProps) => {
}: SimpleConversationItemProps) {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop: _isDesktop } = useResponsiveStore();
const title = conversation.title || t('Untitled conversation');
return (
<Box
@@ -42,10 +44,7 @@ export const SimpleConversationItem = ({
<Box
$direction="row"
$align="center"
$css={css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
$css={bubbleContainerStyles}
$padding={`${spacingsTokens['3xs']} 0`}
>
<BubbleIcon
@@ -56,14 +55,14 @@ export const SimpleConversationItem = ({
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={conversation.title || t('Untitled conversation')}
aria-label={title}
$size="sm"
$variation="850"
$css={ItemTextCss}
>
{conversation.title || t('Untitled conversation')}
{title}
</Text>
</Box>
</Box>
);
};
});
@@ -1,29 +1,8 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ToastProvider } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { ConversationItemActions } from '../ConversationItemActions';
const mockPush = jest.fn();
let mockPathname = '/';
jest.mock('next/router', () => ({
useRouter: () => ({
push: mockPush,
pathname: mockPathname,
route: '/',
query: {},
asPath: '/',
}),
}));
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
@@ -42,48 +21,85 @@ jest.mock('i18next', () => ({
t: (key: string) => key,
}));
jest.mock('@/features/chat/api/useRenameConversation', () => ({
useRenameConversation: () => ({
mutate: jest.fn(),
}),
jest.mock('@/components', () => ({
DropdownMenu: ({
children,
options,
label,
}: {
children: React.ReactNode;
options: { label: string; callback: () => void; testId: string }[];
label: string;
}) => (
<div data-testid="dropdown-menu" aria-label={label}>
{children}
<ul>
{options.map((option) => (
<li key={option.testId}>
<button onClick={option.callback} data-testid={option.testId}>
{option.label}
</button>
</li>
))}
</ul>
</div>
),
Icon: ({
iconName,
...props
}: {
iconName: string;
}) => <span data-icon={iconName} {...props} />,
}));
jest.mock('@/features/chat/api/useRemoveConversation', () => ({
useRemoveConversation: () => ({
mutate: jest.fn(),
}),
jest.mock('../ModalRenameConversation', () => ({
ModalRenameConversation: ({
onClose,
conversation,
}: {
onClose: () => void;
conversation: { id: string };
}) => (
<div data-testid="modal-rename-conversation">
<span>Rename conversation {conversation.id}</span>
<button onClick={onClose} data-testid="modal-rename-close-button">
Close
</button>
</div>
),
}));
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<ToastProvider>{ui}</ToastProvider>
</CunninghamProvider>,
);
jest.mock('../ModalRemoveConversation', () => ({
ModalRemoveConversation: ({
onClose,
conversation,
}: {
onClose: () => void;
conversation: { id: string };
}) => (
<div data-testid="modal-remove-conversation">
<span>Remove conversation {conversation.id}</span>
<button onClick={onClose} data-testid="modal-remove-close-button">
Close
</button>
</div>
),
}));
const mockConversation = {
id: 'conv-123',
title: 'Test Conversation',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
messages: [],
};
describe('ConversationItemActions', () => {
const mockConversation: ChatConversation = {
id: 'conv-123',
title: 'Original Title',
messages: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
beforeEach(() => {
jest.clearAllMocks();
mockPathname = '/';
});
const renderComponent = (conversation = mockConversation) => {
return renderWithProviders(
<ConversationItemActions conversation={conversation} />,
);
};
it('renders the actions button', () => {
renderComponent();
render(<ConversationItemActions conversation={mockConversation} />);
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument();
expect(
screen.getByTestId(
`conversation-item-actions-button-${mockConversation.id}`,
@@ -92,164 +108,122 @@ describe('ConversationItemActions', () => {
});
it('renders dropdown menu with correct aria-label', () => {
renderComponent();
render(<ConversationItemActions conversation={mockConversation} />);
expect(
screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
),
).toBeInTheDocument();
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute(
'aria-label',
'Actions list for conversation Test Conversation',
);
});
it('renders dropdown menu with fallback title when conversation has no title', () => {
const untitledConversation = { ...mockConversation, title: '' };
renderComponent(untitledConversation);
render(<ConversationItemActions conversation={untitledConversation} />);
expect(
screen.getByLabelText(
`Actions list for conversation Untitled conversation`,
),
).toBeInTheDocument();
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute(
'aria-label',
'Actions list for conversation Untitled conversation',
);
});
it('opens dropdown menu when clicking the actions button', async () => {
const user = userEvent.setup();
renderComponent();
it('should render delete and rename options', () => {
render(<ConversationItemActions conversation={mockConversation} />);
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
expect(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
).toBeInTheDocument();
expect(
screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
),
).toBeInTheDocument();
});
it('displays rename and delete options in the dropdown', async () => {
const user = userEvent.setup();
renderComponent();
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
expect(screen.getByText('Rename chat')).toBeInTheDocument();
expect(screen.getByText('Delete chat')).toBeInTheDocument();
});
it('opens rename modal when clicking rename option', async () => {
it('opens dropdown menu when clicking the actions button', async () => {
const user = userEvent.setup();
renderComponent();
render(<ConversationItemActions conversation={mockConversation} />);
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
const renameOption = screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
);
await user.click(renameOption);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByRole('textbox')).toHaveValue(mockConversation.title);
expect(screen.getByTestId('rename-chat-form')).toBeInTheDocument();
expect(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
).toBeInTheDocument();
expect(
screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
),
).toBeInTheDocument();
});
it('opens delete modal when clicking delete option', async () => {
const user = userEvent.setup();
renderComponent();
render(<ConversationItemActions conversation={mockConversation} />);
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
expect(
screen.queryByTestId('modal-remove-conversation'),
).not.toBeInTheDocument();
const deleteOption = screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
);
await user.click(deleteOption);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByTestId('delete-chat-confirm')).toBeInTheDocument();
});
it('does not render modals initially', () => {
renderComponent();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-chat-confirm')).not.toBeInTheDocument();
expect(screen.queryByTestId('rename-chat-form')).not.toBeInTheDocument();
});
it('closes rename modal when onClose is called', async () => {
const user = userEvent.setup();
renderComponent();
// Open dropdown and click rename
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
await user.click(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Close the modal
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('closes delete modal when onClose is called', async () => {
const user = userEvent.setup();
renderComponent();
// Open dropdown and click delete
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
await user.click(
screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
),
);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByTestId('modal-remove-conversation')).toBeInTheDocument();
});
// Close the modal
await user.click(screen.getByText('Cancel'));
it('opens rename modal when clicking rename option', async () => {
const user = userEvent.setup();
render(<ConversationItemActions conversation={mockConversation} />);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(
screen.queryByTestId('modal-rename-conversation'),
).not.toBeInTheDocument();
await user.click(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
);
expect(screen.getByTestId('modal-rename-conversation')).toBeInTheDocument();
});
it('closes delete modal when onClose is called', async () => {
const user = userEvent.setup();
render(<ConversationItemActions conversation={mockConversation} />);
await user.click(
screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
),
);
expect(screen.getByTestId('modal-remove-conversation')).toBeInTheDocument();
await user.click(screen.getByTestId('modal-remove-close-button'));
expect(
screen.queryByTestId('modal-remove-conversation'),
).not.toBeInTheDocument();
});
it('closes rename modal when onClose is called', async () => {
const user = userEvent.setup();
render(<ConversationItemActions conversation={mockConversation} />);
await user.click(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
);
expect(screen.getByTestId('modal-rename-conversation')).toBeInTheDocument();
await user.click(screen.getByTestId('modal-rename-close-button'));
expect(
screen.queryByTestId('modal-rename-conversation'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,128 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { LeftPanelConversationItem } from '../LeftPanelConversationItem';
const mockSetPanelOpen = jest.fn();
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
jest.mock('@/stores', () => ({
useResponsiveStore: jest.fn((selector) => selector({ isDesktop: true })),
}));
jest.mock('@/features/chat/stores/useChatPreferencesStore', () => ({
useChatPreferencesStore: jest.fn((selector) =>
selector({ setPanelOpen: mockSetPanelOpen }),
),
}));
jest.mock('../SimpleConversationItem', () => ({
SimpleConversationItem: ({
conversation,
}: {
conversation: { title: string };
}) => <div data-testid="simple-conversation-item">{conversation.title}</div>,
}));
jest.mock('../ConversationItemActions', () => ({
ConversationItemActions: ({
conversation,
}: {
conversation: { id: string };
}) => (
<div data-testid={`conversation-actions-${conversation.id}`}>Actions</div>
),
}));
const mockConversation = {
id: 'conv-123',
title: 'Test Conversation',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
messages: [],
};
describe('LeftPanelConversationItem', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the conversation item with link', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', `/chat/${mockConversation.id}/`);
});
it('should render SimpleConversationItem', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
expect(screen.getByTestId('simple-conversation-item')).toBeInTheDocument();
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
});
it('should render ConversationItemActions', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
expect(
screen.getByTestId(`conversation-actions-${mockConversation.id}`),
).toBeInTheDocument();
});
it('should not close panel on click when on desktop', async () => {
const user = userEvent.setup();
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
await user.click(screen.getByRole('link'));
expect(mockSetPanelOpen).not.toHaveBeenCalled();
});
it('should close panel on click when on mobile', async () => {
const { useResponsiveStore } = jest.requireMock('@/stores');
useResponsiveStore.mockImplementation(
(selector: (state: { isDesktop: boolean }) => boolean) =>
selector({ isDesktop: false }),
);
const user = userEvent.setup();
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
await user.click(screen.getByRole('link'));
expect(mockSetPanelOpen).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { AppWrapper } from '@/tests/utils';
import { SimpleConversationItem } from '../SimpleConversationItem';
jest.mock('../../assets/bubble-bold.svg', () => {
return function BubbleIcon({ color, ...props }: { color: string }) {
return <svg data-testid="bubble-icon" data-color={color} {...props} />;
};
});
const mockConversation = {
id: 'conv-123',
title: 'Test Conversation',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
messages: [],
};
const renderWithWrapper = (ui: React.ReactElement) => {
return render(ui, { wrapper: AppWrapper });
};
describe('SimpleConversationItem', () => {
it('should render conversation title', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
});
it('should render bubble icon', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByTestId('bubble-icon')).toBeInTheDocument();
});
it('should have accessible label for bubble icon', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByLabelText('Simple chat icon')).toBeInTheDocument();
});
it('should display "Untitled conversation" when title is empty', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{ ...mockConversation, title: '' }}
/>,
);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
it('should display "Untitled conversation" when title is undefined', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{
...mockConversation,
title: undefined as unknown as string,
}}
/>,
);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
it('should have aria-label with the title', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByLabelText('Test Conversation')).toBeInTheDocument();
});
it('should have aria-label with "Untitled conversation" when title is empty', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{ ...mockConversation, title: '' }}
/>,
);
expect(screen.getByLabelText('Untitled conversation')).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { act, renderHook } from '@testing-library/react';
import { useOwnModal } from '../useModalHook';
describe('useOwnModal', () => {
it('should initialize with isOpen as false by default', () => {
const { result } = renderHook(() => useOwnModal());
expect(result.current.isOpen).toBe(false);
});
it('should initialize with isOpen as true when initialState is true', () => {
const { result } = renderHook(() => useOwnModal(true));
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to true when open is called', () => {
const { result } = renderHook(() => useOwnModal());
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to false when close is called', () => {
const { result } = renderHook(() => useOwnModal(true));
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
});
it('should maintain stable callback references across rerenders', () => {
const { result, rerender } = renderHook(() => useOwnModal());
const initialOpen = result.current.open;
const initialClose = result.current.close;
rerender();
expect(result.current.open).toBe(initialOpen);
expect(result.current.close).toBe(initialClose);
});
it('should handle multiple open/close cycles', () => {
const { result } = renderHook(() => useOwnModal());
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
});
});
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react';
interface UseModalReturn {
isOpen: boolean;
open: () => void;
close: () => void;
}
/**
* Manages modal open/close state with stable callback references.
*
* @param initialState - Initial open state (default: false)
* @returns `isOpen` state and stable `open`/`close` callbacks safe for dependency arrays
*
*/
export const useOwnModal = (initialState = false): UseModalReturn => {
const [isOpen, setIsOpen] = useState(initialState);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
return { isOpen, open, close };
};
@@ -1,2 +1 @@
export * from './useDate';
export * from './useClipboard';
@@ -1,16 +0,0 @@
import { useEffect } from 'react';
export const useCmdK = (callback: () => void) => {
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
callback();
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [callback]);
};
@@ -1,25 +0,0 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useTranslation } from 'react-i18next';
const formatDefault: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
export const useDate = () => {
const { i18n } = useTranslation();
const formatDate = (
date: string,
format: DateTimeFormatOptions = formatDefault,
): string => {
return DateTime.fromISO(date)
.setLocale(i18n.language)
.toLocaleString(format);
};
return { formatDate };
};
@@ -0,0 +1,28 @@
// hooks/useElementHeight.ts
import { useState, useEffect, useRef, RefObject } from 'react';
export function useElementHeight<T extends HTMLElement>(): [
RefObject<T>,
number,
] {
const ref = useRef<T>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setHeight(entry.contentRect.height);
}
});
observer.observe(element);
setHeight(element.offsetHeight); // initial measurement
return () => observer.disconnect();
}, []);
return [ref, height];
}
@@ -13,9 +13,9 @@
"Add attach file": "Ajouter une pièce jointe",
"Add file": "Ajouter un fichier",
"Allow conversation analysis": "Autoriser l'analyse de conversation",
"An error occurred while renaming the conversation": "Une erreur est survenue lors du renommage de la conversation",
"An error occurred. Please try again.": "Une erreur s'est produite. Veuillez réessayer.",
"Are you sure you want to delete this conversation ?": "Êtes-vous sûr de vouloir supprimer cette conversation ?",
"Ask a question": "Poser une question",
"Assistant IA replied: ": "Assistant IA a répondu : ",
"Assistant is already available, log in to use it now.": "L'Assistant est déjà disponible, connectez-vous pour l'utiliser maintenant.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "L'assistant est en cours de développement : vos commentaires sont importants ! Choisissez comment partager vos avis :",
@@ -31,6 +31,7 @@
"Close the modal": "Fermer la modale",
"Confirm deletion": "Confirmer la suppression",
"Content modal to delete conversation": "Modale pour supprimer la conversation",
"Content modal to rename a conversation": "Modale pour renommer la conversation",
"Conversation analysis disabled": "Analyse de la conversation désactivée",
"Conversation analysis enabled": "Analyse de la conversation activée",
"Copied": "Copié",
@@ -53,7 +54,6 @@
"Feedback Négatif": "Retour négatif",
"Feedback positif": "Retour positif",
"File type not supported": "Type de fichier non pris en charge",
"Find recent news about...": "Trouver les dernières actualités concernant...",
"Get notified about the Public Beta.": "Soyez informé de la Bêta publique.",
"Get notified for the public beta": "Être notifié pour la bêta publique",
"Give a quick opinion": "Donner un avis rapide",
@@ -76,6 +76,7 @@
"Logout": "Se déconnecter",
"New chat": "Nouvelle conversation",
"New feedback": "Nouveaux commentaires",
"New name": "Nouveau nom",
"No code? ": "Pas de code ? ",
"No conversation found": "Aucune conversation trouvée",
"Notify me": "Me notifier",
@@ -87,6 +88,8 @@
"Proconnect Login": "Connexion Proconnect",
"Quick search input": "Saisie de recherche rapide",
"Remove attachment": "Supprimer la pièce jointe",
"Rename": "Renommer",
"Rename chat": "Renommer la conversation",
"Research on the web": "Rechercher sur le web",
"Retry": "Réessayer",
"Search": "Rechercher",
@@ -108,10 +111,10 @@
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "L'Assistant est une IA souveraine conçue pour les fonctionnaires. Il vous permet de gagner du temps sur des tâches quotidiennes telles que la reformulation, le résumé, la traduction ou la recherche d'informations. Vos données ne quittent jamais la France et sont stockées sur des infrastructures sûres et conformes à l'état et ne sont jamais utilisées à des fins commerciales.",
"The Assistant is in Beta": "L'Assistant est en Bêta",
"The conversation has been deleted.": "La conversation a été supprimée.",
"The conversation has been renamed.": "La conversation a été renommée.",
"The summary feature is not supported yet.": "La fonctionnalité de résumé n'est pas encore prise en charge.",
"Thinking...": "Réflexion...",
"To add a file to the conversation, drop it here.": "Pour ajouter un fichier à la conversation, déposez-le ici.",
"Turn this list into bullet points": "Transformer cette liste en liste à puces...",
"Unlock access": "Déverrouiller l'accès",
"Unlocking...": "Déverrouillage en cours...",
"Untitled conversation": "Conversation sans titre",
@@ -121,7 +124,6 @@
"We'll email you when the public beta opens.": "Nous vous enverrons un email quand la bêta publique sera ouverte.",
"Web": "Web",
"What is on your mind?": "Quavez-vous en tête ?",
"Write a short product description": "Écrire une description courte du produit...",
"Write on Tchap": "Écrire sur Tchap",
"You are on the list": "Vous êtes dans la liste",
"You do not have permission to view this page.": "Vous navez pas la permission de voir cette page.",
@@ -146,9 +148,9 @@
"Add attach file": "Voeg een bijlage toe",
"Add file": "Bestand toevoegen",
"Allow conversation analysis": "Gespreksanalyse toestaan",
"An error occurred while renaming the conversation": "Er is een fout opgetreden bij het hernoemen van het gesprek",
"An error occurred. Please try again.": "Er is een fout opgetreden. Probeer het opnieuw.",
"Are you sure you want to delete this conversation ?": "Weet u zeker dat u dit gesprek wilt verwijderen?",
"Ask a question": "Stel een vraag",
"Assistant IA replied: ": "AI Assistent antwoordde: ",
"Assistant is already available, log in to use it now.": "Assistent is al beschikbaar, log in om het nu te gebruiken.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Assistent is in ontwikkeling: uw feedback telt! Kies hoe u uw ideeën wilt delen:",
@@ -164,6 +166,7 @@
"Close the modal": "Sluit het venster",
"Confirm deletion": "Verwijdering bevestigen",
"Content modal to delete conversation": "Inhoudsvenster om conversatie te verwijderen",
"Content modal to rename a conversation": "Content modal om een gesprek te hernoemen",
"Conversation analysis disabled": "Gespreksanalyse uitgeschakeld",
"Conversation analysis enabled": "Gespreksanalyse ingeschakeld",
"Copied": "Gekopieerd",
@@ -186,7 +189,6 @@
"Feedback Négatif": "Negatieve feedback",
"Feedback positif": "Positieve feedback",
"File type not supported": "Bestandstype niet ondersteund",
"Find recent news about...": "Vind het laatste nieuws over...",
"Get notified about the Public Beta.": "Ontvang een melding over de openbare bèta.",
"Get notified for the public beta": "Ontvang een melding voor de openbare bètaversie",
"Give a quick opinion": "Geef snel een mening",
@@ -209,6 +211,7 @@
"Logout": "Uitloggen",
"New chat": "Nieuwe chat",
"New feedback": "Nieuwe feedback",
"New name": "Nieuwe naam",
"No code? ": "Geen code? ",
"No conversation found": "Geen gesprek gevonden",
"Notify me": "Breng mij op de hoogte",
@@ -220,6 +223,8 @@
"Proconnect Login": "Login",
"Quick search input": "Snelle zoekinvoer",
"Remove attachment": "Bijlage verwijderen",
"Rename": "Hernoem",
"Rename chat": "Chat hernoemen",
"Research on the web": "Onderzoek op het internet",
"Retry": "Opnieuw proberen",
"Search": "Zoek",
@@ -241,10 +246,10 @@
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "De Assistent is een soevereine conversationele AI, ontworpen voor ambtenaren. Het helpt je tijd te besparen bij dagelijkse taken zoals het herformuleren, samenvatten, vertalen of zoeken van informatie. Je gegevens verlaten het land nooit en worden opgeslagen op beveiligde, door de overheid goedgekeurde infrastructuren. Ze worden nooit gebruikt voor commerciële doeleinden.",
"The Assistant is in Beta": "De Assistent is in bèta",
"The conversation has been deleted.": "Het gesprek is verwijderd.",
"The conversation has been renamed.": "Het gesprek is verwijderd.",
"The summary feature is not supported yet.": "De samenvattingsfunctie wordt nog niet ondersteund.",
"Thinking...": "Denken...",
"To add a file to the conversation, drop it here.": "Als u een bestand aan het gesprek wilt toevoegen, sleept u het hierheen.",
"Turn this list into bullet points": "Zet deze lijst om in opsommingstekens",
"Unlock access": "Toegang ontgrendelen",
"Unlocking...": "Ontgrendelen...",
"Untitled conversation": "Ongetiteld gesprek",
@@ -254,7 +259,6 @@
"We'll email you when the public beta opens.": "We sturen u een e-mail zodra de openbare bètaversie beschikbaar is.",
"Web": "Internet",
"What is on your mind?": "Waar denk je aan?",
"Write a short product description": "Schrijf een korte productbeschrijving",
"Write on Tchap": "Schrijf op Tchap",
"You are on the list": "Je staat op de lijst",
"You do not have permission to view this page.": "U heeft geen toestemming om deze pagina te bekijken.",
@@ -279,9 +283,9 @@
"Add attach file": "Добавить вложение",
"Add file": "Добавить файл",
"Allow conversation analysis": "Разрешить анализ бесед",
"An error occurred while renaming the conversation": "Ошибка при переименовании беседы",
"An error occurred. Please try again.": "Произошла ошибка. Пожалуйста, повторите попытку.",
"Are you sure you want to delete this conversation ?": "Вы действительно хотите удалить эту беседу?",
"Ask a question": "Задать вопрос",
"Assistant IA replied: ": "Помощник ИИ ответил: ",
"Assistant is already available, log in to use it now.": "Помощник уже доступен, просто войдите в систему.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помощник находится в разработке: ваши отзывы важны! Выберите, как поделиться своими идеями:",
@@ -297,6 +301,7 @@
"Close the modal": "Закрыть это окно",
"Confirm deletion": "Подтвердите удаление",
"Content modal to delete conversation": "Подтверждение удаления беседы",
"Content modal to rename a conversation": "Подтверждение переименования беседы",
"Conversation analysis disabled": "Анализ бесед отключён",
"Conversation analysis enabled": "Анализ бесед включён",
"Copied": "Скопировано",
@@ -319,7 +324,6 @@
"Feedback Négatif": "Отрицательный отзыв",
"Feedback positif": "Положительный отзыв",
"File type not supported": "Тип файла не поддерживается",
"Find recent news about...": "Найти последние новости...",
"Get notified about the Public Beta.": "Получать уведомления о публичной бета-версии.",
"Get notified for the public beta": "Получать уведомления о публичной бета-версии",
"Give a quick opinion": "Оставить быстрый отзыв",
@@ -342,6 +346,7 @@
"Logout": "Выйти",
"New chat": "Новая беседа",
"New feedback": "Новый отзыв",
"New name": "Новое название",
"No code? ": "Нет кода? ",
"No conversation found": "Беседы не найдены",
"Notify me": "Уведомите меня",
@@ -353,6 +358,8 @@
"Proconnect Login": "Войти через Proconnect",
"Quick search input": "Быстрый поиск",
"Remove attachment": "Удалить вложение",
"Rename": "Переименовать",
"Rename chat": "Переименовать чат",
"Research on the web": "Исследование в Интернете",
"Retry": "Повторить",
"Search": "Поиск",
@@ -374,10 +381,10 @@
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "Помощник - собеседник на основе ИИ для государственных служащих. Он поможет вам сэкономить время на ежедневных задачах, таких как перефразирование, обобщение, перевод или поиск информации. Ваши данные никогда не покидают Францию и хранятся в охраняемой государственной инфраструктуре, которая никогда не используется в коммерческих целях.",
"The Assistant is in Beta": "Помощник находится на этапе Бета-версии",
"The conversation has been deleted.": "Беседа была удалена.",
"The conversation has been renamed.": "Беседа была переименована.",
"The summary feature is not supported yet.": "Функция сводки пока не поддерживается.",
"Thinking...": "Размышление...",
"To add a file to the conversation, drop it here.": "Чтобы добавить файл в беседу, поместите его сюда.",
"Turn this list into bullet points": "Преобразовать этот список в маркированный",
"Unlock access": "Разблокировать",
"Unlocking...": "Разблокировка...",
"Untitled conversation": "Беседа без названия",
@@ -387,7 +394,6 @@
"We'll email you when the public beta opens.": "Мы отправим вам письмо, когда станет доступна публичная бета-версия.",
"Web": "Интернет",
"What is on your mind?": "О чём вы думаете?",
"Write a short product description": "Введите краткое описание продукта",
"Write on Tchap": "Написать в Tchap",
"You are on the list": "Вы в списке",
"You do not have permission to view this page.": "У вас недостаточно прав для просмотра этой страницы.",
@@ -412,9 +418,9 @@
"Add attach file": "Додати файл вкладення",
"Add file": "Додати файл",
"Allow conversation analysis": "Дозволити аналіз розмови",
"An error occurred while renaming the conversation": "Сталася помилка під час перейменування розмови",
"An error occurred. Please try again.": "Сталась помилка. Спробуйте ще раз.",
"Are you sure you want to delete this conversation ?": "Ви дійсно бажаєте видалити цю розмову?",
"Ask a question": "Задати питання",
"Assistant IA replied: ": "Відповідь помічника ШІ: ",
"Assistant is already available, log in to use it now.": "Помічник вже доступний, увійдіть щоб почати використання.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помічник в розробці: ваші відгуки мають значення! Оберіть, як поділитися своїми ідеями:",
@@ -430,6 +436,7 @@
"Close the modal": "Закрити це вікно",
"Confirm deletion": "Підтвердження видалення",
"Content modal to delete conversation": "Підтвердження видалення розмови",
"Content modal to rename a conversation": "Підтвердження перейменування розмови",
"Conversation analysis disabled": "Аналіз розмови вимкнено",
"Conversation analysis enabled": "Аналіз розмов увімкнено",
"Copied": "Скопійовано",
@@ -452,7 +459,6 @@
"Feedback Négatif": "Негативний відгук",
"Feedback positif": "Позитивний відгук",
"File type not supported": "Тип файлу не підтримується",
"Find recent news about...": "Знайти останні новини про...",
"Get notified about the Public Beta.": "Отримувати повідомлення про публічну бета-версію.",
"Get notified for the public beta": "Отримувати повідомлення про публічну бета-версію",
"Give a quick opinion": "Дати швидкий відгук",
@@ -475,6 +481,7 @@
"Logout": "Вийти",
"New chat": "Нова розмова",
"New feedback": "Новий відгук",
"New name": "Нова назва",
"No code? ": "Немає коду? ",
"No conversation found": "Розмови не знайдено",
"Notify me": "Нагадати мені",
@@ -486,6 +493,8 @@
"Proconnect Login": "Увійти через Proconnect",
"Quick search input": "Швидкий пошук",
"Remove attachment": "Видалити вкладення",
"Rename": "Перейменувати",
"Rename chat": "Перейменувати чат",
"Research on the web": "Дослідження в Інтернеті",
"Retry": "Повторити",
"Search": "Пошук",
@@ -507,10 +516,10 @@
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "Помічник - це розмовний ШІ, призначений для державних службовців. Він допоможе вам зберегти час в таких щоденних завданнях, як рефразування, узагальнення, переклад або пошукова інформація. Ваші дані ніколи не покидають Францію та зберігаються на захищеній державній інфраструктурі. Вони ніколи не використовуються для комерційних цілей.",
"The Assistant is in Beta": "Помічник у бета-версії",
"The conversation has been deleted.": "Розмова була видалена.",
"The conversation has been renamed.": "Розмова була перейменована.",
"The summary feature is not supported yet.": "Функція узагальнення ще не підтримується.",
"Thinking...": "Мислення...",
"To add a file to the conversation, drop it here.": "Щоб додати файл до розмови, покладіть його сюди.",
"Turn this list into bullet points": "Перетворити цей список на маркований",
"Unlock access": "Розблокувати доступ",
"Unlocking...": "Розблоковування...",
"Untitled conversation": "Розмова без назви",
@@ -520,7 +529,6 @@
"We'll email you when the public beta opens.": "Коли стане доступна публічна бета-версія, ми надішлемо вам листа.",
"Web": "Інтернет",
"What is on your mind?": "Що у вас на думці?",
"Write a short product description": "Напишіть короткий опис продукту",
"Write on Tchap": "Написати у Tchap",
"You are on the list": "Ви є в списку",
"You do not have permission to view this page.": "У вас немає прав для перегляду цієї сторінки.",
@@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
@@ -8,11 +8,37 @@ import { Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import { useElementHeight } from '@/hook/useElementHeight';
import { FeatureFlagState, useConfig } from '@/core';
type MainLayoutProps = {
backgroundColor?: 'white' | 'grey';
};
const Banner = ({
title = '',
content = '',
}: {
title?: string;
content?: string;
}) => {
if (!content) {
return null;
}
return (
<Box
$css={css`
background-color: #ccc;
display: flex;
align-items: center;
`}
>
<strong>{title}</strong>
<p>{content}</p>
</Box>
);
};
export function MainLayout({
children,
backgroundColor: _backgroundColor = 'white',
@@ -21,15 +47,41 @@ export function MainLayout({
const { colorsTokens } = useCunninghamTheme();
const { isPanelOpen } = useChatPreferencesStore();
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
const [banners, setBanners] = useState({});
const { data: conf } = useConfig();
const [bannerRef, bannerHeight] = useElementHeight<HTMLDivElement>();
useEffect(() => {
setBanners(conf?.banners ?? {});
}, [conf]);
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
console.log(banners);
console.log(bannerHeight);
return (
<Box className="--docs--main-layout">
<Box
ref={bannerRef}
$css={css`
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
background-color: #ccc;
`}
>
<Banner content={banners?.environment?.content}></Banner>
<Banner content={banners?.status?.content}></Banner>
</Box>
<Box
$css={css`
z-index: 1000;
transition: left 0.3s ease;
position: fixed;
top: ${bannerHeight}px;
width: 300px;
left: ${isPanelOpen ? '0px' : '-300px'};
`}
@@ -40,6 +92,7 @@ export function MainLayout({
$css={css`
transition: all 0.3s ease;
position: fixed;
top: ${bannerHeight}px;
left: ${isDesktop && isPanelOpen ? '300px' : '0px'};
width: calc(100vw - ${isDesktop && isPanelOpen ? '300px' : '0px'});
`}
@@ -8,6 +8,7 @@ export const CONFIG = {
'document-upload': 'enabled',
'web-search': 'enabled',
},
FILE_UPLOAD_MODE: 'presigned_url',
FRONTEND_CSS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_THEME: null,
@@ -51,6 +52,7 @@ export const overrideConfig = async (
) =>
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
console.log("CONFIG")
if (request.method().includes('GET')) {
await route.fulfill({
json: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "0.0.11",
"version": "0.0.12",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "conversations",
"version": "0.0.11",
"version": "0.0.12",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-conversations",
"version": "0.0.11",
"version": "0.0.12",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "0.0.11",
"version": "0.0.12",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:conversations",
+9 -9
View File
@@ -1971,10 +1971,10 @@
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.9.0"
"@next/env@15.3.8":
version "15.3.8"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.3.8.tgz#02326c38d315d72f2ab8b2bcc9a5c81ec5482873"
integrity sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==
"@next/env@15.3.9":
version "15.3.9"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.3.9.tgz#b9fdfa7e8a4aae151f9c75a8062d3cc2921e1348"
integrity sha512-I7wMCjlHc85EvAebNYJCRBZ+shdrGhcIXBviWmDzGYXwTQ+WrYpfg1LBOnTK1Bn3b+ud5apesNObXKEGqi/C3g==
"@next/eslint-plugin-next@15.3.3":
version "15.3.3"
@@ -10838,12 +10838,12 @@ neo-async@^2.6.2:
resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next@15.3.8:
version "15.3.8"
resolved "https://registry.yarnpkg.com/next/-/next-15.3.8.tgz#c7df2fa890c66fa3042e85437e3c1e8e6bd38b26"
integrity sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==
next@15.3.9:
version "15.3.9"
resolved "https://registry.yarnpkg.com/next/-/next-15.3.9.tgz#1562842cb65a2edb218e7b748d2b4359d9120031"
integrity sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==
dependencies:
"@next/env" "15.3.8"
"@next/env" "15.3.9"
"@swc/counter" "0.1.3"
"@swc/helpers" "0.5.15"
busboy "1.6.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.0.11",
"version": "0.0.12",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -399,7 +399,7 @@ glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
glob@^10.5.0:
glob@^10.3.10, glob@^10.3.3:
version "10.5.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==