Compare commits

..

6 Commits

Author SHA1 Message Date
Eléonore Voisin eb000e5a5a Merge branch 'main' into feature/add-ui-kit 2026-01-16 12:10:38 +01:00
Eléonore Voisin 693beaba86 [WIP] ui-kit 2026-01-16 11:49:28 +01:00
Laurent Paoletti f3680b6905 ⚰️(back) remove dead code and unused files
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-06 10:42:08 +01:00
Laurent Paoletti 5676ce68c0 🐛(back) fix system prompt compatibility with self-hosted models
Pydantic AI allows setting multiple static and dynamic system prompts
to define conversation context and rules. Previously, these were sent
to the model API as separate messages, which caused compatibility
issues with some self-hosted models (e.g., Gemma3/vLLM).

This commit switches from using `system_prompt` to `instruction` as
recommended in the Pydantic AI documentation, thus merging several
instructions into a single message.

Reference: https://ai.pydantic.dev/agents/#system-prompts
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-05 18:43:38 +01:00
Eléonore Voisin 50a395c546 Revert "🐛(front) optimize chat"
This reverts commit 69bf2cab5d.
2025-12-30 13:46:04 +01:00
Eléonore Voisin 69bf2cab5d 🐛(front) optimize chat
Simplified chat rendering
2025-12-19 17:12:53 +01:00
170 changed files with 3536 additions and 17710 deletions
-3
View File
@@ -8,12 +8,9 @@ skip =
**/node_modules/**,
**/e2e/report/**,
*.tsbuildinfo,
**/uv.lock,
./docker/files/etc/mime.types,
check-filenames = true
ignore-words-list =
afterAll,
statics,
exclude-file =
./src/backend/chat/agent_rag/web_search/mocked.py,
+20 -31
View File
@@ -85,24 +85,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
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
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]
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: uv run pylint .
run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
@@ -185,28 +181,25 @@ jobs:
mc version enable conversations/conversations-media-storage"
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
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
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- 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 cp $GITHUB_WORKSPACE/docker/files/etc/mime.types /etc/mime.types
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: uv run python manage.py compilemessages
run: python manage.py compilemessages
- name: Run tests
run: uv run pytest -n 2
run: ~/.local/bin/pytest -n 2
security-trivy-critical:
permissions:
@@ -217,8 +210,6 @@ 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:
@@ -228,5 +219,3 @@ 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
+3 -61
View File
@@ -8,64 +8,11 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(waffle) hide the waffle if not fr theme
- ✨(front) allow pasting an attachment from clipboard
- ✨(array) temporarily adjust array
- ✨(tools) add basic translate tool
### Changed
- ⚡️(front) optimize streaming markdown rendering performance
- ⬆️(back) update pydantic-ai
- ♻️(chat) refactor AIAgentService for readability and maintainability
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
- 🐛(front) fix math formulas and carousel translations
- 🐛(helm) reverse liveness and readiness for backend deployment
## [0.0.13] - 2026-02-09
### Added
- 💄(front) ui fix : update ui-kit
- ✨(front) add persistent darkmode
- ✨(front) add ui kit #240
- 🧱(files) allow to use S3 storage without external access #849
- ✨(backend) add FindRagBackend #209
- ⬆️(back) update dependencies
- ✨(back) use adaptive parsing for pdf documents
### Changed
- 💄(darkmode) change color feedback button
- 🏗️(back) migrate to uv
- ♻️(front) optimize syntax highlighting bundle size
### Fixed
- 🐛(back) cast collection Ids to API expected types
## [0.0.12] - 2026-01-27
### 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
## [0.0.11] - 2026-01-16
### Changed
- 📦️(front) update react
- ✨(chat) generate and edit conversation title
- ✨(front) add ui-kit
- ✨(front) add dark-mode
### Fixed
@@ -73,8 +20,6 @@ and this project adheres to
- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
- ⚰️(back) remove dead code and unused files
- 🐛(back) prevent tool call timeouts
### Removed
@@ -234,10 +179,7 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.13...main
[0.0.13]: https://github.com/suitenumerique/conversations/releases/v0.0.13
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
+23 -33
View File
@@ -3,6 +3,9 @@
# ---- 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 setuptools
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
@@ -10,31 +13,21 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-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/
WORKDIR /builder
# Install Rust and Cargo using Alpine's package manager
RUN apk add --no-cache \
build-base \
libffi-dev \
libxml2-dev \
libxslt-dev \
rust \
cargo
WORKDIR /app
# Copy required python dependencies
COPY ./src/backend /builder
RUN mkdir /install && \
pip install --prefix=/install .
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
@@ -56,16 +49,14 @@ RUN apk add \
pango \
rdfind
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy conversations application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
@@ -88,12 +79,10 @@ RUN apk add \
gettext \
gdk-pixbuf \
libffi-dev \
libxml2 \
libxslt \
pango \
shared-mime-info
COPY ./docker/files/etc/mime.types /etc/mime.types
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -103,17 +92,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 the application from the builder
COPY --from=back-builder /app /app
# Copy conversations application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
@@ -130,9 +119,10 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Uninstall conversations and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y conversations
RUN pip install -e .[dev]
# 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
-12
View File
@@ -71,13 +71,9 @@ 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
@@ -93,9 +89,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:
@@ -184,8 +177,3 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge
File diff suppressed because it is too large Load Diff
-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
@@ -1,159 +0,0 @@
# 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
)
```
-2
View File
@@ -8,5 +8,3 @@ LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.j
# Features
FEATURE_FLAG_WEB_SEARCH=ENABLED
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
AUTO_TITLE_AFTER_USER_MESSAGES=3
@@ -1,280 +0,0 @@
"""Document parsers for RAG backends."""
import base64
import logging
import time
from io import BytesIO
from urllib.parse import urljoin
from django.conf import settings
import requests
from pypdf import PdfReader, PdfWriter
from chat.agent_rag.document_converter.markitdown import DocumentConverter
logger = logging.getLogger(__name__)
class BaseParser:
"""Base class for document parsers."""
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
into a format suitable for storage.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (bytes): The content of the document as a bytes stream.
Returns:
str: The document content in Markdown format.
"""
raise NotImplementedError("Must be implemented in subclass.")
class AlbertParser(BaseParser):
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
"""Parse PDF document using Albert API."""
response = requests.post(
self.endpoint,
headers={
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
},
files={
"file": (name, content, content_type),
"output_format": (None, "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: bytes) -> str:
"""Parse document based on content type."""
if content_type == "application/pdf":
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
return DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
METHOD_TEXT_EXTRACTION = "text_extraction"
METHOD_OCR = "ocr"
def analyze_pdf(pdf_data: bytes) -> dict:
"""
Analyze a PDF to determine if it needs OCR or can use direct text extraction.
"""
reader = PdfReader(BytesIO(pdf_data))
total_pages = len(reader.pages)
if total_pages == 0:
logger.info("No page found in pdf")
return {
"total_pages": 0,
"pages_with_text": 0,
"avg_chars_per_page": 0,
"text_coverage": 0,
"recommended_method": METHOD_TEXT_EXTRACTION,
}
total_chars = 0
pages_with_text = 0
for page in reader.pages:
text = (page.extract_text() or "").strip()
char_count = len(text)
total_chars += char_count
if char_count > 50:
pages_with_text += 1
avg_chars = total_chars / total_pages
text_coverage = pages_with_text / total_pages
# Decision logic
if (
avg_chars > settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
and text_coverage > settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION
):
method = METHOD_TEXT_EXTRACTION
else:
method = METHOD_OCR
return {
"total_pages": total_pages,
"pages_with_text": pages_with_text,
"avg_chars_per_page": round(avg_chars),
"text_coverage": round(text_coverage, 2),
"recommended_method": method,
}
class AdaptiveParserMixin:
"""
Mixin that adds adaptive PDF parsing behavior.
Analyzes PDF content to choose between direct text extraction (fast) and OCR
(for scanned/image PDFs). Subclasses must implement `parse_pdf_document_with_ocr`.
"""
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
"""Analyze PDF and route to text extraction or OCR based on content."""
analysis = analyze_pdf(content)
logger.info(
"Pdf analysis - pages: %s, pages with text: %s, text_coverage: %s, "
"recommended method: %s",
analysis["total_pages"],
analysis["pages_with_text"],
analysis["text_coverage"],
analysis["recommended_method"],
)
method = analysis["recommended_method"]
if method == METHOD_TEXT_EXTRACTION:
return self.extract_text_from_pdf(name=name, content_type=content_type, content=content)
return self.parse_pdf_document_with_ocr(name=name, content=content)
def extract_text_from_pdf(self, name: str, content_type: str, content: bytes) -> str:
"""Extract text directly from PDF without OCR (for text-based PDFs)."""
logger.info("Parsing pdf with text extraction")
return DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
"""Process PDF through OCR. Must be implemented by subclass."""
raise NotImplementedError("Subclass must implement parse_pdf_document_with_ocr")
class AdaptivePdfParser(AdaptiveParserMixin, BaseParser):
"""
PDF parser with adaptive text extraction / OCR routing.
Uses Mistral OCR API for scanned/image PDFs, processed in batches with retry logic.
"""
def __init__(self):
super().__init__()
self.endpoint = urljoin(
settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.base_url, "/v1/ocr"
)
self.max_retries = settings.OCR_MAX_RETRIES
self.retry_delay = settings.OCR_RETRY_DELAY
api_key = settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.api_key
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
def extract_page_batch(self, reader: PdfReader, start_index: int, end_index: int) -> bytes:
"""Extract a range of pages from PDF as a new PDF bytes object."""
writer = PdfWriter()
for i in range(start_index, end_index):
writer.add_page(reader.pages[i])
output = BytesIO()
writer.write(output)
return output.getvalue()
def ocr_page_batch(
self,
name: str,
page_content: bytes,
start_index: int,
end_index: int,
) -> list[str]:
"""Send page batch to Mistral OCR API with static delay retry."""
file_data = base64.standard_b64encode(page_content).decode("utf-8")
payload = {
"document": {
"type": "document_url",
"document_name": f"{name}_pages_{start_index + 1}_to_{end_index}",
"document_url": f"data:application/pdf;base64,{file_data}",
},
"model": settings.OCR_MODEL,
}
last_exception = None
for attempt in range(self.max_retries):
try:
response = requests.post(
self.endpoint,
headers=self.headers,
json=payload,
timeout=settings.OCR_TIMEOUT,
)
response.raise_for_status()
pages = response.json().get("pages", [])
return [page.get("markdown", "") for page in pages]
except (requests.Timeout, requests.RequestException) as e:
last_exception = e
if attempt < self.max_retries - 1:
logger.warning(
"OCR attempt %d/%d failed for pages %d-%d: %s. Retrying in %.1fs...",
attempt + 1,
self.max_retries,
start_index + 1,
end_index,
str(e),
self.retry_delay,
)
time.sleep(self.retry_delay)
logger.error(
"OCR failed for pages %d-%d after %d attempts: %s",
start_index + 1,
end_index,
self.max_retries,
str(last_exception),
)
raise last_exception
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
"""Process PDF through OCR in batches, returning concatenated markdown."""
reader = PdfReader(BytesIO(content))
total_pages = len(reader.pages)
batch_size = settings.OCR_BATCH_PAGES
logger.info("Parsing pdf with OCR (%d pages, batch size %d)", total_pages, batch_size)
results = []
for start_index in range(0, total_pages, batch_size):
end_index = min(start_index + batch_size, total_pages)
batch_content = self.extract_page_batch(reader, start_index, end_index)
try:
batch_results = self.ocr_page_batch(name, batch_content, start_index, end_index)
results.extend(batch_results)
logger.debug(
"Completed OCR for pages %d-%d/%d", start_index + 1, end_index, total_pages
)
except Exception as e: # pylint: disable=broad-except #noqa: BLE001
logger.error("Failed to OCR pages %d-%d: %s", start_index + 1, end_index, str(e))
# Preserve page count with empty placeholders to maintain correct ordering
results.extend([""] * (end_index - start_index))
return "\n\n".join(results)
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
"""Route to PDF parser or DocumentConverter based on content type."""
if content_type == "application/pdf":
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
return DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
@@ -7,13 +7,13 @@ from typing import List, Optional
from urllib.parse import urljoin
from django.conf import settings
from django.utils.module_loading import import_string
import httpx
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.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,15 +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"
parser_class = import_string(settings.RAG_DOCUMENT_PARSER)
self.parser = parser_class()
@staticmethod
def cast_collection_id(collection_id):
"""Albert API expects int Ids."""
return int(collection_id)
self._default_collection_description = "Temporary collection for RAG document search"
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
@@ -93,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
"""
@@ -104,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
"""
@@ -116,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.
@@ -124,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),
@@ -139,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.
@@ -147,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(
@@ -165,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.
@@ -209,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,19 +1,18 @@
"""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
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__(
@@ -39,12 +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()
@staticmethod
def cast_collection_id(collection_id):
"""Dummy method to be overridden when needed."""
return collection_id
def get_all_collection_ids(self) -> List[str]:
"""
@@ -60,17 +53,13 @@ class BaseRagBackend(ABC):
collection_ids = []
if self.collection_id:
collection_ids.append(self.cast_collection_id(self.collection_id))
collection_ids.append(int(self.collection_id))
if self.read_only_collection_id:
collection_ids.extend(
[
self.cast_collection_id(collection_id)
for collection_id in self.read_only_collection_id
]
[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.
@@ -85,7 +74,7 @@ class BaseRagBackend(ABC):
"""
return await sync_to_async(self.create_collection)(name=name, description=description)
def parse_document(self, name: str, content_type: str, content: bytes):
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
@@ -94,15 +83,14 @@ class BaseRagBackend(ABC):
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (bytes): The content of the document as a bytes stream.
content (BytesIO): The content of the document as a BytesIO stream.
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.
@@ -110,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.
@@ -122,66 +109,50 @@ 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: bytes, **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.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (bytes): The content of the document as a bytes stream.
**kwargs: Additional arguments. ex: "user_sub" for access control.
content (BytesIO): The content of the document as a BytesIO stream.
"""
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
@@ -197,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()
@@ -207,4 +176,4 @@ class BaseRagBackend(ABC):
try:
yield backend
finally:
await backend.adelete_collection(**kwargs)
await backend.adelete_collection()
@@ -1,160 +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")
+15 -12
View File
@@ -10,6 +10,7 @@ import httpx
from pydantic_ai import Agent
from pydantic_ai.models import get_user_agent
from pydantic_ai.profiles import ModelProfile
from pydantic_ai.toolsets import FunctionToolset
from chat.tools import get_pydantic_tools_by_name
@@ -173,18 +174,20 @@ class BaseAgent(Agent):
# and pydantic_ai.models.infer_model()
_model_instance = self.configuration.model_name
_system_prompt = self.get_system_prompt()
_system_prompt = self.configuration.system_prompt
_base_toolset = (
[
FunctionToolset(
tools=[
get_pydantic_tools_by_name(tool_name)
for tool_name in self.configuration.tools
]
)
]
if self.configuration.tools
else None
)
_tools = self.get_tools()
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
def get_system_prompt(self) -> str | None:
"""Override this method to customize the system prompt."""
return self.configuration.system_prompt
def get_tools(self) -> list | None:
"""Override this method to customize tools."""
if not self.configuration.tools:
return []
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
-22
View File
@@ -131,25 +131,3 @@ class ConversationAgent(BaseAgent):
if tool.name.startswith("web_search_"):
return tool.name
return None
@dataclasses.dataclass(init=False)
class TitleGenerationAgent(BaseAgent):
"""Agent that generates concise, descriptive titles for conversations."""
def __init__(self, **kwargs):
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=str,
**kwargs,
)
def get_tools(self):
return []
def get_system_prompt(self):
return (
"You are a title generator. Your task is to create concise, descriptive titles "
"that accurately summarize conversation content and help the user quickly identify the "
"conversation.\n\n"
)
@@ -6,103 +6,14 @@ 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,
@@ -110,9 +21,7 @@ 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 appropriate S3 URLs
based on the configured FILE_TO_LLM_MODE.
Replace local image or document URLs in the content list to use presigned S3 URLs.
⚠️Be careful, `media_contents` are replaced in place.
Args:
@@ -122,7 +31,7 @@ def update_local_urls(
mapping of original URLs to updated URLs.
Returns:
Iterable[ImageUrl | DocumentUrl]: Updated iterable of UserContent objects
with appropriate S3 URLs based on the configured mode.
with presigned URLs.
"""
# 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.
@@ -132,9 +41,7 @@ def update_local_urls(
# Filter only ImageUrl contents
media_contents = (c for c in contents if isinstance(c, (ImageUrl, DocumentUrl)))
# Replace URLs with appropriate S3 URLs based on mode
upload_mode = settings.FILE_TO_LLM_MODE
# Replace URLs with presigned URLs
for content in media_contents:
idx = content.url.find(local_media_url_prefix)
@@ -150,7 +57,7 @@ def update_local_urls(
# except if the user tampers with the conversation.
continue
content.url = _get_file_url_for_llm(key, upload_mode)
content.url = generate_retrieve_policy(key)
if updated_url is not None:
updated_url[content.url] = _initial_url
@@ -161,7 +68,7 @@ def update_history_local_urls(
conversation: ChatConversation, messages: list[ModelMessage]
) -> list[ModelMessage]:
"""
Replace local image/documents URLs in the message list to use appropriate S3 URLs.
Replace local image/documents URLs in the message list to use presigned S3 URLs.
⚠️Be careful, `messages` are replaced in place.
@@ -172,7 +79,7 @@ def update_history_local_urls(
Args:
messages (list[ModelMessage]): List of ModelMessage objects.
Returns:
list[ModelMessage]: Updated list of ModelMessage objects with appropriate S3 URLs.
list[ModelMessage]: Updated list of ModelMessage objects with presigned URLs.
"""
# Filter only ModelRequest messages
requests = (msg for msg in messages if isinstance(msg, ModelRequest))
-27
View File
@@ -1,27 +0,0 @@
"""Build the translation agent."""
import dataclasses
import logging
from django.conf import settings
from .base import BaseAgent
logger = logging.getLogger(__name__)
@dataclasses.dataclass(init=False)
class TranslationAgent(BaseAgent):
"""Create a Pydantic AI translation Agent instance with the configured settings"""
def __init__(self, **kwargs):
"""Initialize the agent with the configured model."""
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=str,
**kwargs,
)
def get_tools(self) -> list:
"""Translation does not need any tools."""
return []
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
"""Helpers to prevent proxy timeouts during long-running stream operations.
This module provides utilities to wrap synchronous and asynchronous iterators
with keepalive messages. When a stream pauses for longer than the specified
interval, keepalive messages are injected to prevent proxy/gateway
timeouts while waiting for the stream data.
"""
import asyncio
import logging
import queue
import threading
import time
from typing import AsyncIterator, Iterator
from django.conf import settings
from .vercel_ai_sdk.core.events_v4 import DataPart as V4DataPart
from .vercel_ai_sdk.core.events_v5 import DataPart as V5DataPart
from .vercel_ai_sdk.encoder import (
CURRENT_EVENT_ENCODER_VERSION,
EventEncoder,
EventEncoderVersion,
)
logger = logging.getLogger(__name__)
def get_keepalive_message() -> str:
"""Generate a keepalive message based on encoder/SDK version."""
if CURRENT_EVENT_ENCODER_VERSION == EventEncoderVersion.V4:
event = V4DataPart(data=[{"status": "WAITING"}])
else:
event = V5DataPart(data={"status": "WAITING"})
encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION)
return encoder.encode(event)
async def stream_with_keepalive_async(
stream: AsyncIterator[str],
) -> AsyncIterator[str]:
"""Wrap an async iterator to emit keepalive during long pauses.
Args:
stream: The async iterator to wrap
Yields:
Items from the original stream, plus keepalive messages during pauses
Raises:
Any exception raised by the original stream
"""
q: asyncio.Queue = asyncio.Queue()
finished = asyncio.Event()
keepalive_message = get_keepalive_message()
async def producer():
"""Background task that consumes the original stream into a queue."""
try:
async for stream_item in stream:
await q.put(stream_item)
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
# Pass exceptions through the queue so the consumer can re-raise them.
# This ensures errors aren't silently swallowed.
await q.put(exc)
finally:
finished.set()
await q.put(None) # Sentinel to signal completion
producer_task = asyncio.create_task(producer())
try:
while True:
try:
item = await asyncio.wait_for(q.get(), timeout=settings.KEEPALIVE_INTERVAL)
if item is None:
break
if isinstance(item, Exception):
raise item
yield item
except asyncio.TimeoutError:
# No data received within interval
if finished.is_set():
# Producer is done, queue is empty (else we would not have timed out)
break
logger.debug("Send keepalive")
yield keepalive_message
finally:
# Cleanup
producer_task.cancel()
try:
await producer_task
except asyncio.CancelledError:
pass
def get_current_time() -> float:
"""Get current monotonic time, avoiding freezegun interferences.
Returns time.monotonic() which:
- Is NOT affected by freezegun's @freeze_time decorator (unlike time.time())
- Prevents issues where frozen time in main thread differs from real time in
spawned threads, causing incorrect keepalive interval computation
- Is the best clock for measuring time intervals
Wrapped in a function to ease mocking in tests.
Returns:
float: Monotonic time in seconds since an arbitrary reference point
"""
return time.monotonic()
def stream_with_keepalive_sync(stream: Iterator[str]) -> Iterator[str]:
"""Wraps a synchronous stream with keepalive messages."""
q: queue.Queue = queue.Queue()
stream_done = threading.Event()
keepalive_message = get_keepalive_message()
# Mutable container so threads can read/write shared timestamp
last_yield_time = [get_current_time()]
def consume_stream():
"""Read from source stream and forward chunks to queue."""
try:
for chunk in stream:
if stream_done.is_set():
return # early exit
q.put(chunk, timeout=1) # Arbitrary timeout prevents blocking forever
# pylint: disable=broad-exception-caught
except Exception as e:
logger.exception("Error in stream consumption")
q.put(e)
finally:
stream_done.set()
def send_keepalives():
"""Inject keepalive messages when idle too long.
Uses get_current_time() (time.monotonic) instead of time.time()
to avoid issues with freezegun in tests.
"""
while not stream_done.is_set():
# Sleep before checking to give main loop time to process and update timestamp
time.sleep(0.5) # let main loop process first, empiric value
if get_current_time() - last_yield_time[0] >= settings.KEEPALIVE_INTERVAL:
try:
q.put(keepalive_message, timeout=0.1)
except queue.Full:
pass
for target in (consume_stream, send_keepalives):
threading.Thread(target=target, daemon=True).start()
try:
# Continue while stream is active or queue has still items
while not stream_done.is_set() or not q.empty():
try:
item = q.get(timeout=1) # short timeout, avoid blocking and stay responsive
except queue.Empty:
continue
# Re-raise from consume_stream
if isinstance(item, Exception):
raise item
yield item
last_yield_time[0] = get_current_time()
finally:
# Signal threads to stop
stream_done.set()
@@ -1,21 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-30 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat", "0004_chatconversationattachment_and_more"),
]
operations = [
migrations.AddField(
model_name="chatconversation",
name="title_set_by_user_at",
field=models.DateTimeField(
blank=True,
help_text="Timestamp when the user manually set the title. If set, prevent automatic title generation.",
null=True,
),
),
]
+1 -6
View File
@@ -44,12 +44,7 @@ class ChatConversation(BaseModel):
null=True,
help_text="Title of the chat conversation",
)
title_set_by_user_at = models.DateTimeField(
blank=True,
null=True,
help_text="Timestamp when the user manually set the title. If set, prevent automatic "
"title generation.",
)
ui_messages = models.JSONField(
default=list,
blank=True,
+5 -22
View File
@@ -4,13 +4,12 @@ from typing import Optional
from urllib.parse import quote
from django.conf import settings
from django.utils import timezone
from django_pydantic_field.rest_framework import SchemaField # pylint: disable=no-name-in-module
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.file_upload.enums import AttachmentStatus, FileUploadMode
from core.file_upload.enums import AttachmentStatus
from core.file_upload.utils import generate_upload_policy
from chat import models
@@ -28,12 +27,6 @@ class ChatConversationSerializer(serializers.ModelSerializer):
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
read_only_fields = ["id", "created_at", "updated_at", "messages"]
def update(self, instance, validated_data):
# If title is being changed, mark it as user-set
if "title" in validated_data and validated_data["title"] != instance.title:
instance.title_set_by_user_at = timezone.now()
return super().update(instance, validated_data)
class ChatConversationInputSerializer(serializers.Serializer):
"""
@@ -180,11 +173,7 @@ class ChatConversationAttachmentSerializer(serializers.ModelSerializer):
class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
"""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)
"""
"""Serializer for creating chat conversation attachments."""
policy = serializers.SerializerMethodField()
uploaded_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
@@ -194,15 +183,9 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
model = models.ChatConversationAttachment
fields = ["id", "key", "content_type", "file_name", "size", "policy", "uploaded_by"]
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 get_policy(self, attachment) -> str:
"""Return the policy to use if the item is a file."""
return generate_upload_policy(attachment.key)
def validate_size(self, size: Optional[int]) -> Optional[int]:
"""Validate that the size is not greater than the maximum allowed size."""
@@ -1,161 +0,0 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<<
/Producer (pypdf)
>>
endobj
2 0 obj
<<
/Type /Pages
/Count 10
/Kids [ 4 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R ]
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 5 0 R
/Resources <<
/Font <<
/F1 6 0 R
>>
>>
/Parent 2 0 R
>>
endobj
5 0 obj
<<
/Length 132
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
6 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
7 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 5 0 R
/Resources <<
/Font <<
/F1 6 0 R
>>
>>
/Parent 2 0 R
>>
endobj
8 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
9 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
10 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
11 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
12 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
13 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
14 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
15 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0.0 0.0 612 792 ]
/Parent 2 0 R
>>
endobj
xref
0 16
0000000000 65535 f
0000000015 00000 n
0000000054 00000 n
0000000174 00000 n
0000000223 00000 n
0000000351 00000 n
0000000534 00000 n
0000000631 00000 n
0000000759 00000 n
0000000853 00000 n
0000000947 00000 n
0000001042 00000 n
0000001137 00000 n
0000001232 00000 n
0000001327 00000 n
0000001422 00000 n
trailer
<<
/Size 16
/Root 3 0 R
/Info 1 0 R
>>
startxref
1517
%%EOF
@@ -1,355 +0,0 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<<
/Producer (pypdf)
>>
endobj
2 0 obj
<<
/Type /Pages
/Count 10
/Kids [ 4 0 R 7 0 R 10 0 R 13 0 R 16 0 R 19 0 R 22 0 R 25 0 R 28 0 R 31 0 R ]
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 5 0 R
/Resources <<
/Font <<
/F1 6 0 R
>>
>>
/Parent 2 0 R
>>
endobj
5 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
6 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
7 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 8 0 R
/Resources <<
/Font <<
/F1 9 0 R
>>
>>
/Parent 2 0 R
>>
endobj
8 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
9 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
10 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 11 0 R
/Resources <<
/Font <<
/F1 12 0 R
>>
>>
/Parent 2 0 R
>>
endobj
11 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
12 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
13 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 14 0 R
/Resources <<
/Font <<
/F1 15 0 R
>>
>>
/Parent 2 0 R
>>
endobj
14 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
15 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
16 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 17 0 R
/Resources <<
/Font <<
/F1 18 0 R
>>
>>
/Parent 2 0 R
>>
endobj
17 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
18 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
19 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 20 0 R
/Resources <<
/Font <<
/F1 21 0 R
>>
>>
/Parent 2 0 R
>>
endobj
20 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
21 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
22 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 23 0 R
/Resources <<
/Font <<
/F1 24 0 R
>>
>>
/Parent 2 0 R
>>
endobj
23 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
24 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
25 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 26 0 R
/Resources <<
/Font <<
/F1 27 0 R
>>
>>
/Parent 2 0 R
>>
endobj
26 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
27 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
28 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 29 0 R
/Resources <<
/Font <<
/F1 30 0 R
>>
>>
/Parent 2 0 R
>>
endobj
29 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
30 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
31 0 obj
<<
/Type /Page
/MediaBox [ 0 0 612 792 ]
/Contents 32 0 R
/Resources <<
/Font <<
/F1 33 0 R
>>
>>
/Parent 2 0 R
>>
endobj
32 0 obj
<<
/Length 332
>>
stream
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
endstream
endobj
33 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
/Encoding /WinAnsiEncoding
>>
endobj
xref
0 34
0000000000 65535 f
0000000015 00000 n
0000000054 00000 n
0000000176 00000 n
0000000225 00000 n
0000000353 00000 n
0000000736 00000 n
0000000833 00000 n
0000000961 00000 n
0000001344 00000 n
0000001441 00000 n
0000001572 00000 n
0000001956 00000 n
0000002054 00000 n
0000002185 00000 n
0000002569 00000 n
0000002667 00000 n
0000002798 00000 n
0000003182 00000 n
0000003280 00000 n
0000003411 00000 n
0000003795 00000 n
0000003893 00000 n
0000004024 00000 n
0000004408 00000 n
0000004506 00000 n
0000004637 00000 n
0000005021 00000 n
0000005119 00000 n
0000005250 00000 n
0000005634 00000 n
0000005732 00000 n
0000005863 00000 n
0000006247 00000 n
trailer
<<
/Size 34
/Root 3 0 R
/Info 1 0 R
>>
startxref
6345
%%EOF
@@ -1,310 +0,0 @@
"""Tests for AdaptivePdfParser and AdaptiveParserMixin."""
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
from pypdf import PdfReader
from chat.agent_rag.document_converter.parser import (
METHOD_OCR,
METHOD_TEXT_EXTRACTION,
AdaptivePdfParser,
analyze_pdf,
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
@pytest.fixture(name="text_pdf_1_page")
def provide_text_pdf_1_page():
"""Load a 1 page PDF with extractable text."""
return (FIXTURES_DIR / "text_pdf_1_page.pdf").read_bytes()
@pytest.fixture(name="text_pdf_10_pages")
def provide_text_pdf_10_pages():
"""Load a 10-page PDF with extractable text (~300 chars per page)."""
return (FIXTURES_DIR / "text_10_pages.pdf").read_bytes()
@pytest.fixture(name="mixed_pdf_10_pages")
def provide_mixed_pdf_10_pages():
"""Load a 10-page PDF with 2 pages of text and 8 blank pages."""
return (FIXTURES_DIR / "mixed_10_pages.pdf").read_bytes()
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = 200
OCR_RETRY_DELAY = 1
OCR_MAX_RETRIES = 3
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Mock Django settings for OCR configuration."""
settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = 0.7
settings.OCR_HRID = "test-ocr-hrid"
settings.OCR_MODEL = "test-ocr-model"
settings.OCR_TIMEOUT = 60
settings.OCR_MAX_RETRIES = OCR_MAX_RETRIES
settings.OCR_RETRY_DELAY = OCR_RETRY_DELAY
settings.OCR_BATCH_PAGES = 10
settings.LLM_CONFIGURATIONS = {
"test-ocr-hrid": MagicMock(
provider=MagicMock(
base_url="https://ocr.example.com",
api_key="test-api-key",
)
)
}
return settings
def test_analyze_pdf_returns_correct_structure(text_pdf_10_pages):
"""analyze_pdf should return dict with expected keys."""
result = analyze_pdf(text_pdf_10_pages)
assert "total_pages" in result
assert "pages_with_text" in result
assert "avg_chars_per_page" in result
assert "text_coverage" in result
assert "recommended_method" in result
def test_analyze_pdf_with_text_recommends_extraction(text_pdf_1_page):
"""PDF with sufficient text should recommend text extraction."""
result = analyze_pdf(text_pdf_1_page)
assert result["total_pages"] == 1
assert result["pages_with_text"] == 1
assert result["text_coverage"] == pytest.approx(1.0)
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
def test_analyze_multi_page_pdf_with_text_recommends_extraction(text_pdf_10_pages):
"""PDF with sufficient text should recommend text extraction."""
result = analyze_pdf(text_pdf_10_pages)
assert result["total_pages"] == 10
assert result["pages_with_text"] == 10
assert result["text_coverage"] == pytest.approx(1.0)
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
def test_analyze_pdf_mixed_content_recommends_ocr(mixed_pdf_10_pages):
"""PDF with low text coverage should recommend OCR."""
result = analyze_pdf(mixed_pdf_10_pages)
assert result["total_pages"] == 10
assert result["pages_with_text"] == 2
assert result["text_coverage"] == pytest.approx(0.2)
assert result["recommended_method"] == METHOD_OCR
def test_extract_page_batch_single_page(text_pdf_10_pages):
"""Should extract a single page correctly."""
parser = AdaptivePdfParser()
reader = PdfReader(BytesIO(text_pdf_10_pages))
result = parser.extract_page_batch(reader, 0, 1)
result_reader = PdfReader(BytesIO(result))
assert len(result_reader.pages) == 1
def test_extract_page_batch_multiple_pages(text_pdf_10_pages):
"""Should extract multiple pages correctly."""
parser = AdaptivePdfParser()
reader = PdfReader(BytesIO(text_pdf_10_pages))
result = parser.extract_page_batch(reader, 2, 7)
result_reader = PdfReader(BytesIO(result))
assert len(result_reader.pages) == 5
def test_extract_page_batch_last_batch(text_pdf_10_pages):
"""Should handle last batch with fewer pages."""
parser = AdaptivePdfParser()
reader = PdfReader(BytesIO(text_pdf_10_pages))
result = parser.extract_page_batch(reader, 7, 10)
result_reader = PdfReader(BytesIO(result))
assert len(result_reader.pages) == 3
def test_ocr_page_batch_success(text_pdf_1_page):
"""Should return markdown content on successful OCR."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
mock_post.return_value.json.return_value = {
"pages": [
{"markdown": "# Page 1 content"},
]
}
mock_post.return_value.raise_for_status = MagicMock()
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
assert result == ["# Page 1 content"]
mock_post.assert_called_once()
def test_ocr_page_batch_retry_on_timeout(text_pdf_1_page):
"""Should retry on timeout with static delay."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
with patch("chat.agent_rag.document_converter.parser.time.sleep") as mock_sleep:
mock_post.side_effect = [
requests.Timeout("Connection timed out"),
MagicMock(
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
raise_for_status=MagicMock(),
),
]
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
assert result == ["# Content"]
assert mock_post.call_count == 2
mock_sleep.assert_called_once_with(OCR_RETRY_DELAY)
def test_ocr_page_batch_fails_after_max_retries(text_pdf_1_page):
"""Should raise exception after max retries exceeded."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
mock_post.side_effect = requests.Timeout("Connection timed out")
with pytest.raises(requests.Timeout):
parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
assert mock_post.call_count == OCR_MAX_RETRIES
def test_ocr_page_batch_retry_on_request_exception(text_pdf_1_page):
"""Should retry on general request exceptions."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
mock_post.side_effect = [
requests.RequestException("Network error"),
requests.RequestException("Network error"),
MagicMock(
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
raise_for_status=MagicMock(),
),
]
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
assert result == ["# Content"]
assert mock_post.call_count == 3
def test_parse_pdf_with_ocr_single_batch(text_pdf_10_pages):
"""Should process PDF in single batch when pages <= batch size."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
mock_post.return_value.json.return_value = {
"pages": [{"markdown": f"Page {i}"} for i in range(1, 11)]
}
mock_post.return_value.raise_for_status = MagicMock()
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
assert "Page 1" in result
assert "Page 10" in result
mock_post.assert_called_once()
def test_parse_pdf_with_ocr_multiple_batches(text_pdf_10_pages, settings):
"""Should process PDF in multiple batches when pages > batch size."""
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
mock_post.return_value.json.side_effect = [
{"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]},
{"pages": [{"markdown": f"Page {i}"} for i in range(5, 9)]},
{"pages": [{"markdown": f"Page {i}"} for i in range(9, 11)]},
]
mock_post.return_value.raise_for_status = MagicMock()
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
assert mock_post.call_count == 3
assert "Page 1" in result
assert "Page 10" in result
def test_parse_pdf_with_ocr_partial_failure(text_pdf_10_pages, settings):
"""Should insert empty placeholders for failed batches."""
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
parser = AdaptivePdfParser()
success_response = MagicMock()
success_response.json.return_value = {"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]}
success_response.raise_for_status = MagicMock()
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
# First batch succeeds, then all retries fail for remaining batches
mock_post.side_effect = [
success_response,
requests.Timeout("OCR failed"),
requests.Timeout("OCR failed"),
requests.Timeout("OCR failed"),
requests.Timeout("OCR failed"),
requests.Timeout("OCR failed"),
requests.Timeout("OCR failed"),
]
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
parts = result.split("\n\n")
# First batch succeeded (4 pages), remaining batches failed (6 pages as placeholders)
assert len(parts) == 10
assert parts[0] == "Page 1"
assert parts[3] == "Page 4"
assert parts[4] == "" # Failed batch placeholder
def test_parse_document_pdf_routed_correctly(text_pdf_1_page):
"""Should route PDF content type to PDF parser."""
parser = AdaptivePdfParser()
with patch.object(parser, "parse_pdf_document", return_value="pdf content") as mock_parse:
result = parser.parse_document("test.pdf", "application/pdf", text_pdf_1_page)
assert result == "pdf content"
mock_parse.assert_called_once_with(
name="test.pdf",
content_type="application/pdf",
content=text_pdf_1_page,
)
def test_parse_document_non_pdf_uses_document_converter():
"""Should route non-PDF content to DocumentConverter."""
parser = AdaptivePdfParser()
with patch("chat.agent_rag.document_converter.parser.DocumentConverter") as mock_converter:
mock_converter.return_value.convert_raw.return_value = "docx content"
result = parser.parse_document("test.docx", "application/vnd.openxmlformats", b"content")
assert result == "docx content"
mock_converter.return_value.convert_raw.assert_called_once()
@@ -1,66 +0,0 @@
"""Test cases for the TitleGenerationAgent class."""
# pylint: disable=protected-access
import pytest
from pydantic_ai.models.openai import OpenAIChatModel
from chat.agents.conversation import TitleGenerationAgent
@pytest.fixture(autouse=True)
def base_settings(settings):
"""Set up base settings for the tests."""
settings.AI_BASE_URL = "https://api.llm.com/v1/"
settings.AI_API_KEY = "test-key"
settings.AI_MODEL = "model-XYZ"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
settings.AI_AGENT_TOOLS = []
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
def test_title_generation_agent_uses_default_model_hrid(settings):
"""Test that TitleGenerationAgent uses LLM_DEFAULT_MODEL_HRID from settings."""
settings.AI_MODEL = "custom-llm-model"
settings.AI_BASE_URL = "https://custom.api.com/v1/"
settings.AI_API_KEY = "custom-key"
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
agent = TitleGenerationAgent()
assert isinstance(agent._model, OpenAIChatModel)
assert settings.LLM_CONFIGURATIONS["default-model"].model_name == "custom-llm-model"
assert agent._model.model_name == "custom-llm-model"
def test_title_generation_agent_model_configuration():
"""Test that the agent model is properly configured."""
agent = TitleGenerationAgent()
assert isinstance(agent._model, OpenAIChatModel)
assert agent._model.model_name == "model-XYZ"
assert str(agent._model.client.base_url) == "https://api.llm.com/v1/"
assert agent._model.client.api_key == "test-key"
def test_title_generation_agent_has_no_tools():
"""Test that TitleGenerationAgent has no tools configured."""
agent = TitleGenerationAgent()
assert agent._function_toolset.tools == {}
assert not agent.get_tools()
def test_title_generation_agent_instructions():
"""Test that the agent instructions contain the system prompt."""
agent = TitleGenerationAgent()
# The agent should have the title generation system prompt as instructions
instructions = agent._instructions
assert len(instructions) == 1
expected = (
"You are a title generator. Your task is to create concise, descriptive titles "
"that accurately summarize conversation content and help the user quickly identify the "
"conversation.\n\n"
)
assert instructions[0] == expected
+1 -68
View File
@@ -2,26 +2,19 @@
import logging
from contextlib import ExitStack, contextmanager
from importlib import reload
from unittest.mock import patch
from django.urls import clear_url_caches, set_urlconf
from django.utils import formats, timezone
import pytest
import core.urls
import chat.views
import conversations.urls
from chat.agents.summarize import SummarizationAgent
from chat.agents.translate import TranslationAgent
from chat.clients.pydantic_ai import AIAgentService
logger = logging.getLogger(__name__)
@pytest.fixture(name="today_prompt_date")
@pytest.fixture(name="today_promt_date")
def today_prompt_date_fixture():
"""Fixture to mock date the system prompt when useless to test it."""
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
@@ -92,68 +85,8 @@ def mock_summarization_agent_fixture():
yield _mock_agent
@pytest.fixture(name="mock_translation_agent")
def mock_translation_agent_fixture():
"""Fixture to mock TranslationAgent with a custom model."""
@contextmanager
def _mock_agent(model):
"""Context manager to mock TranslationAgent with a custom model."""
with ExitStack() as stack:
class TranslationAgentMock(TranslationAgent):
"""Mocked TranslationAgent to override the model."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
logger.info("Overriding TranslationAgent model with %s", model)
self._model = model # pylint: disable=protected-access
stack.enter_context(
patch("chat.agents.translate.TranslationAgent", new=TranslationAgentMock)
)
stack.enter_context(
patch("chat.tools.document_translate.TranslationAgent", new=TranslationAgentMock)
)
yield
yield _mock_agent
PIXEL_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
@pytest.fixture(name="oidc_refresh_token_enabled")
def fixture_oidc_refresh_token_enabled(settings):
"""
Fixture to enable OIDC refresh token storage during the test.
This is not a nice fixture, as it forces reloading views and URL configurations.
Maybe there is a better way to do this, because even the conditional_refresh_oidc_token
function is not nice, but I don't want it to be lazy either.
"""
__initial_value = bool(settings.OIDC_STORE_REFRESH_TOKEN)
settings.OIDC_STORE_REFRESH_TOKEN = True
# force view reload
reload(chat.views)
reload(core.urls)
reload(conversations.urls)
clear_url_caches()
set_urlconf(None)
yield settings
settings.OIDC_STORE_REFRESH_TOKEN = __initial_value
# force view reload
reload(chat.views)
reload(core.urls)
reload(conversations.urls)
clear_url_caches()
set_urlconf(None)
@@ -1,6 +1,5 @@
"""Tests for local_media_url_processors."""
from io import BytesIO
from unittest.mock import patch
import pytest
@@ -13,10 +12,7 @@ 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,
)
@@ -125,549 +121,3 @@ 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)
@@ -1,318 +0,0 @@
"""Tests for document_translate functionality."""
import io
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from pydantic_ai import ModelResponse, RunContext, TextPart
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.usage import RunUsage
from chat.llm_configuration import LLModel, LLMProvider
from chat.tools.document_translate import document_translate
@pytest.fixture(autouse=True)
def fixture_translation_agent_config(settings):
"""Fixture to set used settings for agent configuration."""
settings.TRANSLATION_MAX_CHARS = 100_000
settings.LLM_CONFIGURATIONS = {
settings.LLM_DEFAULT_MODEL_HRID: LLModel(
hrid="mistral-model",
model_name="mistral-medium-2508",
human_readable_name="Mistral Medium 2508",
profile=None,
provider=LLMProvider(
hrid="mistral-medium-2508",
kind="mistral",
base_url="https://api.mistral.ai/v1",
api_key="testkey",
),
is_active=True,
system_prompt="direct",
tools=[],
),
}
@pytest.fixture(name="mocked_context")
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = mock.Mock(spec=RunContext)
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
return mock_ctx
def _mock_attachments_queryset(attachment):
"""Create a mock queryset that chains .filter().order_by().afirst() returning the attachment."""
mock_qs = mock.Mock()
mock_qs.order_by.return_value = mock_qs
mock_qs.afirst = mock.AsyncMock(return_value=attachment)
mock_attachments = mock.Mock()
mock_attachments.filter.return_value = mock_qs
return mock_attachments
def mocked_translation(_messages, _info=None):
"""Mocked translation response."""
return ModelResponse(parts=[TextPart(content="Ceci est une traduction du document.")])
@pytest.mark.asyncio
async def test_document_translate_single_document(mocked_context, mock_translation_agent):
"""Test document_translate with a single document."""
mock_attachment = mock.Mock()
mock_attachment.key = "test_doc.txt"
mock_attachment.file_name = "test_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a test document. " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_full(_message, _info=None):
"""Mocked translation for full flow."""
return ModelResponse(parts=[TextPart(content="Ceci est un document de test.")])
with mock_translation_agent(FunctionModel(mocked_translate_full)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "Ceci est un document de test." in result.return_value
assert result.metadata["sources"] == {"test_doc.txt"}
@pytest.mark.asyncio
async def test_document_translate_uses_last_document(mocked_context, mock_translation_agent):
"""Test document_translate uses the last uploaded document."""
mock_attachment = mock.Mock()
mock_attachment.key = "latest_doc.txt"
mock_attachment.file_name = "latest_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Content of the latest document."
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert result.metadata["sources"] == {"latest_doc.txt"}
# Verify order_by was called with -created_at
mock_conversation.attachments.filter.return_value.order_by.assert_called_once_with(
"-created_at"
)
@pytest.mark.asyncio
async def test_document_translate_with_custom_instructions(mocked_context, mock_translation_agent):
"""Test document_translate with custom instructions."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
captured_prompts = []
def mocked_translate_with_instructions(messages, _info=None):
"""Mocked translation that captures prompt."""
messages_text = messages[0].parts[-1].content
captured_prompts.append(messages_text)
return ModelResponse(parts=[TextPart(content="Traduction formelle")])
with mock_translation_agent(FunctionModel(mocked_translate_with_instructions)):
result = await document_translate(
mocked_context, target_language="French", instructions="Use formal tone"
)
assert result.return_value is not None
assert len(captured_prompts) == 1
assert "Use formal tone" in captured_prompts[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("target_language", [None, ""])
async def test_document_translate_no_target_language(
target_language, mocked_context, mock_translation_agent
):
"""Test document_translate asks the user for language when target_language is not specified."""
mocked_context.deps = mock.Mock()
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language=target_language, instructions=None
)
assert "target language is not specified" in result
@pytest.mark.asyncio
async def test_document_translate_no_text_attachments(mocked_context, mock_translation_agent):
"""Test document_translate returns error message when no text documents found."""
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(None)
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "No text documents found in the conversation" in result
@pytest.mark.asyncio
async def test_document_translate_error_reading_document(mocked_context, mock_translation_agent):
"""Test document_translate handles errors when reading documents."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "An unexpected error occurred during document translation" in result
@pytest.mark.asyncio
async def test_document_translate_error_during_translation(mocked_context, mock_translation_agent):
"""Test document_translate handles ModelRetry during translation."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_error(_messages, _info=None):
"""Mocked translation that raises an error."""
raise ValueError("Translation error")
with mock_translation_agent(FunctionModel(mocked_translate_error)):
with pytest.raises(ModelRetry):
await document_translate(
mocked_context, target_language="French", instructions=None
)
@pytest.mark.asyncio
async def test_document_translate_too_large(settings, mocked_context, mock_translation_agent):
"""Test document_translate rejects documents exceeding max chars."""
settings.TRANSLATION_MAX_CHARS = 100 # Very small limit
mock_attachment = mock.Mock()
mock_attachment.key = "large_doc.txt"
mock_attachment.file_name = "large_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a word. " * 100 # Much larger than 100 chars
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "too large to translate" in result
@pytest.mark.asyncio
async def test_document_translate_empty_result(mocked_context, mock_translation_agent):
"""Test document_translate raises ModelRetry when translation produces empty result."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_empty_translation(_messages, _info=None):
"""Mocked translation that returns empty."""
return ModelResponse(parts=[TextPart(content=" ")])
with mock_translation_agent(FunctionModel(mocked_empty_translation)):
with pytest.raises(ModelRetry) as exc_info:
await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "produced an empty result" in str(exc_info.value)
@@ -1,19 +1,14 @@
"""Tests for the Brave web search tool."""
from typing import Sequence
# pylint: disable=too-many-lines
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from urllib.parse import parse_qs
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sessions.backends.cache import SessionStore
import httpx
import pytest
import respx
from pydantic_ai import ModelRetry, RunContext, RunUsage
from pydantic_ai._run_context import RunContextAgentDepsT
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.web_search_brave import (
@@ -43,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
@@ -50,13 +48,6 @@ def brave_settings(settings):
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = Mock(spec=RunContext)
mock_ctx.deps = Mock(spec=RunContextAgentDepsT)
user = Mock(spec=AbstractBaseUser)
user.sub = Mock(spec=Sequence)
mock_ctx.deps.user = user
session = SessionStore()
session["oidc_access_token"] = "mocked-access-token"
mock_ctx.deps.session = session
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
@@ -1020,12 +1011,7 @@ async def test_web_search_brave_with_document_backend_rag_search_params(mocked_c
await web_search_brave_with_document_backend(mocked_context, "test query")
# Verify RAG search was called with correct parameters
mock_document_store.asearch.assert_called_once_with(
query="test query",
results_count=5,
session=mocked_context.deps.session,
user_sub=mocked_context.deps.user.sub,
)
mock_document_store.asearch.assert_called_once_with("test query", results_count=5)
@pytest.mark.asyncio
@@ -9,7 +9,7 @@ from django.test import override_settings
import pytest
from core.file_upload.enums import AttachmentStatus, FileUploadMode
from core.file_upload.enums import AttachmentStatus
from chat import factories, models
from chat.tests.conftest import PIXEL_PNG
@@ -266,40 +266,3 @@ 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
@@ -1,112 +0,0 @@
"""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,6 +1,5 @@
"""Common test fixtures for chat conversation endpoint tests."""
import asyncio
import json
from django.utils import timezone
@@ -11,9 +10,15 @@ import respx
from freezegun import freeze_time
def _create_openai_stream_data():
"""Helper to create OpenAI stream data."""
return (
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
"""
Fixture to mock the OpenAI stream response.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
"""
openai_stream = (
"data: "
+ json.dumps(
{
@@ -54,111 +59,12 @@ def _create_openai_stream_data():
"data: [DONE]\n\n"
)
def _create_mock_openai_route(with_delays: bool = False, delay_seconds: float = 1.0):
"""Create a mock OpenAI stream route with optional delays."""
openai_stream = _create_openai_stream_data()
async def mock_stream():
lines = openai_stream.splitlines(keepends=True)
for i, line in enumerate(lines):
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
if with_delays and i == 1:
# Delay after second line to trigger keepalive during streaming
await asyncio.sleep(delay_seconds)
return respx.post("https://www.external-ai-service.com/chat/completions").mock(
return_value=httpx.Response(200, stream=mock_stream())
)
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
"""
Fixture to mock the OpenAI stream response (no delays).
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
"""
return _create_mock_openai_route(with_delays=False)
@pytest.fixture(name="mock_openai_stream_slow")
def fixture_mock_openai_stream_slow():
"""
Fixture to mock the OpenAI stream response with delays to trigger keepalives.
No @freeze_time decorator because asyncio.sleep() needs real time to work properly.
"""
return _create_mock_openai_route(with_delays=True, delay_seconds=1.0)
@pytest.fixture(name="mock_openai_stream_with_title_generation")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream_with_title_generation():
"""
Fixture to mock the OpenAI stream response.
This fixture handles two different types of API calls made during a single request:
1. **Conversation (streaming)**: The main chat uses `stream=True` to get real-time
token-by-token responses. The API returns chunked data like:
`data: {"choices": [{"delta": {"content": "Hello"}}]}`
2. **Title generation (non-streaming)**: After the conversation, the backend calls
the API again with `stream=False` to generate a title. This returns a standard
JSON response with the complete message.
The `handle_request` function inspects each incoming request's body to determine
which type of response to return:
- `{"stream": true, ...}` → SSE streaming response
- `{"stream": false, ...}` → JSON response with generated title
Each call gets a new generator instance (avoiding generator exhaustion)
"""
def create_stream_response():
"""Create a fresh streaming response for each call."""
openai_stream = _create_openai_stream_data()
async def mock_stream():
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
return httpx.Response(200, stream=mock_stream())
def create_non_stream_response():
"""Create a non-streaming response for title generation."""
return httpx.Response(
200,
json={
"id": "chatcmpl-title",
"object": "chat.completion",
"created": int(timezone.make_naive(timezone.now()).timestamp()),
"model": "test-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "GENERATED TITLE",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 50, "completion_tokens": 5, "total_tokens": 55},
},
)
def handle_request(request):
"""Route to streaming or non-streaming response based on request."""
body = json.loads(request.content)
if body.get("stream", False):
return create_stream_response()
return create_non_stream_response()
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
side_effect=handle_request
return_value=httpx.Response(200, stream=mock_stream())
)
return route
@@ -3,8 +3,6 @@
import json
import logging
import time
from unittest.mock import ANY, patch
from django.utils import timezone
@@ -190,7 +188,6 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -199,171 +196,15 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"details": {},
"input_audio_tokens": 0,
"input_tokens": 0,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
@patch("chat.keepalive.get_current_time")
def test_post_conversation_data_protocol_triggers_keepalives(
mock_time, api_client, mock_openai_stream
):
"""Test streaming response contains keepalive messages"""
chat_conversation = ChatConversationFactory(owner__language="en-us")
mock_time.side_effect = [float(i * 60) for i in range(10)]
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "yuPoOuBkKA4FnKvk",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
api_client.force_login(chat_conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.get("x-vercel-ai-data-stream") == "v1"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
'2:[{"status": "WAITING"}]\n'
)
assert mock_openai_stream.called
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
{
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
"id": "yuPoOuBkKA4FnKvk",
"parts": [{"text": "Hello", "type": "text"}],
"role": "user",
}
]
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
experimental_attachments=None,
role="user",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
experimental_attachments=None,
role="assistant",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
"part_kind": "user-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -468,7 +309,6 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -477,29 +317,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -669,7 +495,6 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -690,29 +515,15 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "I see a cat in the picture.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"parts": [{"content": "I see a cat in the picture.", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -850,7 +661,6 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Weather in Paris?"],
@@ -859,31 +669,23 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "tool_calls"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -903,7 +705,6 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
@@ -915,29 +716,17 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "The current weather in Paris is nice",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1074,7 +863,6 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Weather in Paris?"],
@@ -1083,31 +871,23 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "tool_calls"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1127,7 +907,6 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "Unknown tool name: 'get_current_weather'. No tools available.",
@@ -1138,29 +917,17 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "I cannot give you an answer to that.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
{"content": "I cannot give you an answer to that.", "id": None, "part_kind": "text"}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1406,7 +1173,6 @@ def test_post_conversation_data_protocol_no_stream(
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Why the sky is blue?"],
@@ -1415,12 +1181,10 @@ def test_post_conversation_data_protocol_no_stream(
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
"parts": [
{
@@ -1428,18 +1192,12 @@ def test_post_conversation_data_protocol_no_stream(
"Rayleigh scattering.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-09-22T14:13:49Z",
},
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-92c413bb5a45426299335d0621324654",
"provider_url": "https://www.external-ai-service.com",
"timestamp": "2025-07-25T10:36:35.297675Z",
"timestamp": "2025-09-22T14:13:49Z",
"usage": {
"cache_audio_read_tokens": 0,
"cache_read_tokens": 0,
@@ -1555,7 +1313,6 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -1564,29 +1321,15 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1601,261 +1344,3 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z", tick=True)
@respx.mock
@pytest.mark.asyncio
async def test_post_conversation_async_triggers_keepalive(
api_client, mock_openai_stream_slow, monkeypatch, caplog, settings
):
"""Test posting messages to a conversation using the 'data' protocol."""
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
settings.KEEPALIVE_INTERVAL = 1 # s
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "yuPoOuBkKA4FnKvk",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
await api_client.aforce_login(chat_conversation.owner)
caplog.clear()
caplog.set_level(level=logging.DEBUG, logger="chat.views")
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.get("x-vercel-ai-data-stream") == "v1"
assert response.streaming
assert "Using ASYNC streaming for chat conversation" in caplog.text
# Wait for the streaming content to be fully received => async iterator -> list
# This fails it the streaming is not an async generator
response_content = b"".join([content async for content in response.streaming_content]).decode(
"utf-8"
)
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'2:[{"status": "WAITING"}]\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
assert mock_openai_stream_slow.called
await chat_conversation.arefresh_from_db()
assert chat_conversation.ui_messages == [
{
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
"id": "yuPoOuBkKA4FnKvk",
"parts": [{"text": "Hello", "type": "text"}],
"role": "user",
}
]
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=chat_conversation.messages[0].createdAt, # Mocked timestamp
content="Hello",
reasoning=None,
experimental_attachments=None,
role="user",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=chat_conversation.messages[1].createdAt, # Mocked timestamp
content="Hello there",
reasoning=None,
experimental_attachments=None,
role="assistant",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
# using ANY because time is not frozen in this api mock
assert chat_conversation.pydantic_messages == [
{
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
"part_kind": "user-prompt",
"timestamp": ANY,
},
],
"run_id": _run_id,
"timestamp": ANY,
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": ANY,
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": ANY,
"usage": {
"cache_audio_read_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"details": {},
"input_audio_tokens": 0,
"input_tokens": 0,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
def test_post_conversation_oidc_refresh_enabled_unrefreshed( # pylint: disable=unused-argument
api_client, oidc_refresh_token_enabled
):
"""Test posting messages to a conversation without fresh access token should be forbidden."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "yuPoOuBkKA4FnKvk",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
api_client.force_login(
chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_oidc_refresh_enabled( # pylint: disable=unused-argument
api_client, mock_openai_stream, oidc_refresh_token_enabled
):
"""Test posting messages to a conversation using the 'data' protocol."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "yuPoOuBkKA4FnKvk",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
api_client.force_login(
chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
session = api_client.session
session["oidc_id_token_expiration"] = time.time() + 3600 # valid for 1 hour
session["oidc_token_expiration"] = session["oidc_id_token_expiration"] # ...
session.save()
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.get("x-vercel-ai-data-stream") == "v1"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
{
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
"id": "yuPoOuBkKA4FnKvk",
"parts": [{"text": "Hello", "type": "text"}],
"role": "user",
}
]
assert len(chat_conversation.messages) == 2
assert len(chat_conversation.pydantic_messages) == 2
@@ -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,9 +219,9 @@ 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_prompt_date,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -409,7 +357,7 @@ def test_post_conversation_with_document_upload(
assert chat_conversation.pydantic_messages[0] == {
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -424,7 +372,6 @@ def test_post_conversation_with_document_upload(
"Do not request re-upload of documents; consider them already "
"available via the internal store.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["What does the document say?"],
@@ -433,12 +380,10 @@ def test_post_conversation_with_document_upload(
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
@@ -447,14 +392,11 @@ def test_post_conversation_with_document_upload(
"part_kind": "tool-call",
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -471,7 +413,7 @@ def test_post_conversation_with_document_upload(
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -487,7 +429,6 @@ def test_post_conversation_with_document_upload(
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -505,26 +446,21 @@ def test_post_conversation_with_document_upload(
}
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "From the document, I can see that it says 'Hello PDF'.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -612,9 +548,9 @@ 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_prompt_date,
today_promt_date,
mock_ai_agent_service,
mock_summarization_agent, # pylint: disable=unused-argument
):
@@ -751,7 +687,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert chat_conversation.pydantic_messages[0] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -767,7 +703,6 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Make a summary of this document."],
@@ -776,12 +711,10 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
@@ -790,14 +723,11 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"part_kind": "tool-call",
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -814,7 +744,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -830,7 +760,6 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "The document discusses various topics.",
@@ -842,26 +771,17 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
}
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "The document discusses various topics.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
{"content": "The document discusses various topics.", "id": None, "part_kind": "text"}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -37,20 +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_URL = "https://app-find"
settings.FIND_API_KEY = "find-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
return settings
@@ -73,13 +64,12 @@ def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_prompt_date,
today_promt_date,
mock_ai_agent_service,
):
"""
Test POST to /api/v1/chats/{pk}/conversation/ with a document URL.
"""
responses.post(
"https://albert.api.etalab.gouv.fr/v1/collections",
json={"id": 123, "object": "collection"},
@@ -95,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)
@@ -134,30 +120,30 @@ def test_post_conversation_with_local_pdf_document_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
assert messages == [
ModelRequest(
parts=[
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
UserPromptPart(
content=[
"What is in this document?",
DocumentUrl(
url=presigned_url, # presigned URL for this conversation
media_type="application/pdf",
identifier="sample.pdf",
),
],
timestamp=timezone.now(),
),
],
instructions=(
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from attached "
"documents. Do NOT use it to summarize; for summaries, call the summarize "
"tool instead.\n\nWhen you receive a result from the summarization tool, "
"you MUST return it directly to the user without any modification, "
"paraphrasing, or additional summarization.The tool already produces "
"optimized summaries that should be presented verbatim.You may translate "
"the summary if required, but you MUST preserve all the information from "
"the original summary.You may add a follow-up question after the summary "
"if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already available "
"via the internal store."
),
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is a document about a single pixel."
@@ -200,7 +186,9 @@ def test_post_conversation_with_local_pdf_document_url(
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
experimental_attachments=None, # We should fix this, but for now document appears in source
experimental_attachments=[
Attachment(name="sample.pdf", contentType="application/pdf", url=document_url)
],
role="user",
annotations=None,
toolInvocations=None,
@@ -231,57 +219,42 @@ def test_post_conversation_with_local_pdf_document_url(
assert chat_conversation.pydantic_messages == [
{
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n"
"\n"
"Use document_search_rag ONLY to retrieve specific passages "
"from attached documents. Do NOT use it to summarize; for "
"summaries, call the summarize tool instead.\n"
"\n"
"When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"modification, paraphrasing, or additional summarization.The "
"tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if "
"required, but you MUST preserve all the information from "
"the original summary.You may add a follow-up question after "
"the summary if needed.\n"
"\n"
"[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal store.",
f"{today_promt_date}\n\n"
"Answer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
"What is in this document?",
{
"force_download": False,
"identifier": "sample.pdf",
"kind": "document-url",
"media_type": "application/pdf",
"url": document_url,
"vendor_metadata": None,
},
],
"part_kind": "user-prompt",
"timestamp": timestamp,
},
],
"run_id": _run_id,
"timestamp": timestamp,
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document about a single pixel.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timestamp,
"usage": {
"cache_audio_read_tokens": 0,
@@ -553,7 +526,6 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
@@ -567,7 +539,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
identifier="sample.pdf",
),
],
timestamp=timestamp_now,
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
@@ -579,7 +551,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
parts=[TextPart(content="This is a document about a single pixel.")],
usage=RequestUsage(input_tokens=50, output_tokens=9),
model_name="function::agent_model",
timestamp=timestamp_now,
timestamp=timezone.now(),
run_id=messages[1].run_id,
),
ModelRequest(
@@ -588,10 +560,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
content=[
"Give more details about this document.",
],
timestamp=timestamp_now,
timestamp=timezone.now(),
)
],
timestamp=timestamp_now,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
@@ -748,7 +719,6 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"metadata": None,
"kind": "request",
"parts": [
{
@@ -758,26 +728,21 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
}
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document of square, very small and nice.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -807,7 +772,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
def test_post_conversation_with_local_not_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_prompt_date,
today_promt_date,
mock_ai_agent_service,
file_name,
content_type,
@@ -830,10 +795,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)
@@ -869,8 +830,6 @@ def test_post_conversation_with_local_not_pdf_document_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
@@ -879,13 +838,12 @@ def test_post_conversation_with_local_not_pdf_document_url(
"What is in this document?",
# No presigned URL for non-PDF documents (not supporter by LLM)
],
timestamp=timestamp_now,
timestamp=timezone.now(),
),
],
timestamp=timestamp_now,
instructions=(
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -976,7 +934,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
{
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -993,7 +951,6 @@ def test_post_conversation_with_local_not_pdf_document_url(
"consider them already available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -1004,26 +961,21 @@ def test_post_conversation_with_local_not_pdf_document_url(
},
],
"run_id": _run_id,
"timestamp": timestamp,
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document about you.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timestamp,
"usage": {
"cache_audio_read_tokens": 0,
@@ -2,7 +2,6 @@
# pylint: disable=too-many-lines
import json
from unittest.mock import patch
from django.utils import timezone
@@ -12,7 +11,6 @@ from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
from chat.agents.conversation import TitleGenerationAgent
from chat.ai_sdk_types import (
Attachment,
TextUIPart,
@@ -29,10 +27,6 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
PYAI_CURRENT = "current"
PYAI_V1_17 = "v1.17"
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
@@ -41,13 +35,20 @@ def ai_settings(settings):
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
settings.AUTO_TITLE_AFTER_USER_MESSAGES = None # disable auto title generation
return settings
def build__history_conversation_ui_messages(history_timestamp):
"""Build ui messages list for fixtures."""
return [
@pytest.fixture(name="history_conversation")
def history_conversation_fixture():
"""Create a conversation with existing message history."""
# Create a timestamp for the first message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages
conversation = ChatConversationFactory()
# Add previous user and assistant messages
conversation.messages = [
UIMessage(
id="prev-user-msg-1",
createdAt=history_timestamp,
@@ -116,205 +117,94 @@ def build__history_conversation_ui_messages(history_timestamp):
),
]
@pytest.fixture(name="history_conversation")
def history_conversation_fixture(request):
"""Create a conversation with existing message history according to pydantic ai version."""
# Create a timestamp for the first message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages
conversation = ChatConversationFactory()
pyai_version = getattr(request, "param", PYAI_CURRENT)
# Add previous user and assistant messages
if pyai_version == PYAI_V1_17:
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"vendor_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"vendor_details": None,
"vendor_id": None,
},
]
else:
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "some model",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"provider_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"finish_reason": "stop",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "test-model",
"provider_url": "https://www.external-ai-service.com/",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"provider_details": {
"timestamp": "2025-07-25T10:36:35.297675Z",
"finish_reason": "stop",
},
"provider_name": "test-model",
"provider_response_id": "xyz",
},
]
# Set up the OpenAI message format as well
conversation.messages = build__history_conversation_ui_messages(history_timestamp)
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"vendor_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"vendor_details": None,
"vendor_id": None,
},
]
conversation.save()
return conversation
@pytest.mark.parametrize("history_conversation", [PYAI_CURRENT, PYAI_V1_17], indirect=True)
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol_with_history(
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'data' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
@@ -1135,7 +1025,6 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1177,7 +1066,6 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1211,7 +1099,6 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1258,7 +1145,6 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1495,7 +1381,6 @@ def test_post_conversation_with_existing_tool_history(
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["How about Paris weather?"],
@@ -1504,32 +1389,24 @@ def test_post_conversation_with_existing_tool_history(
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
}
assert history_conversation_with_tool.pydantic_messages[9] == {
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "tool_calls"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1549,7 +1426,6 @@ def test_post_conversation_with_existing_tool_history(
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
@@ -1561,30 +1437,18 @@ def test_post_conversation_with_existing_tool_history(
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
}
assert history_conversation_with_tool.pydantic_messages[11] == {
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"content": "The current weather in Paris is nice",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1709,307 +1573,3 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
toolInvocations=None,
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
)
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
@patch("chat.clients.pydantic_ai.TitleGenerationAgent", wraps=TitleGenerationAgent)
def test_post_conversation_triggers_automatic_title_generation_after_first_message(
mock_title_agent, api_client, mock_openai_stream_with_title_generation, settings
):
"""
Test that posting the first user message triggers automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 1
The conversation is a new one. Posting the first message
should trigger title generation via the TitleGenerationAgent.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 1
conversation = ChatConversationFactory()
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(conversation.owner)
conversation.title = "initial title"
conversation.save()
assert not conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is in the stream
assert '"type": "conversation_metadata"' in response_content
# Refresh and verify title was updated
conversation.refresh_from_db()
assert conversation.title == "GENERATED TITLE"
# title_set_by_user_at should remain None since it was auto-generated
assert not conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 2
# Verify TitleGenerationAgent was called
mock_title_agent.assert_called_once()
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_triggers_automatic_title_generation_at_threshold(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that posting the 3rd user message triggers automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 3
The history_conversation fixture has 2 user messages. Posting a 3rd message
should trigger title generation via the TitleGenerationAgent.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
history_conversation.title = "initial title"
history_conversation.save()
assert not history_conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is in the stream
assert '"type": "conversation_metadata"' in response_content
# Refresh and verify title was updated
history_conversation.refresh_from_db()
assert history_conversation.title == "GENERATED TITLE"
# title_set_by_user_at should remain None since it was auto-generated
assert not history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 2
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_regenerate_title_when_user_set(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that title is NOT regenerated if the user has manually set a title.
"""
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
# Simulate user having set a custom title
history_conversation.title = "My Custom Title"
history_conversation.title_set_by_user_at = timezone.now()
history_conversation.save()
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Consume the stream
response_content = b"".join(response.streaming_content).decode("utf-8")
# conversation_metadata should NOT be in the stream since title wasn't generated
assert "conversation_metadata" not in response_content
# Refresh and verify title was NOT changed
history_conversation.refresh_from_db()
assert history_conversation.title == "My Custom Title"
assert history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 1
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_generate_title_before_threshold(
api_client, mock_openai_stream_with_title_generation, settings
):
"""
Test that title is NOT generated before reaching the message threshold.
"""
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
# Create a conversation with only 1 user message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
conversation = ChatConversationFactory(title="initial title")
conversation.messages = [
UIMessage(
id="prev-user-msg-1",
createdAt=history_timestamp,
content="Hello!",
reasoning=None,
experimental_attachments=None,
role="user",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello!")],
),
UIMessage(
id="prev-assistant-msg-1",
createdAt=history_timestamp.replace(minute=31),
content="Hi there! How can I help you?",
reasoning=None,
experimental_attachments=None,
role="assistant",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hi there! How can I help you?")],
),
]
conversation.save()
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "second-user-msg",
"role": "user",
"parts": [{"text": "What's machine learning?", "type": "text"}],
"content": "What's machine learning?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Consume the stream
response_content = b"".join(response.streaming_content).decode("utf-8")
# conversation_metadata should NOT be in the stream (only 2 user messages)
assert "conversation_metadata" not in response_content
# Refresh and verify title was not updated
conversation.refresh_from_db()
assert conversation.title == "initial title"
assert not conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.call_count == 1
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_generate_title_after_threshold(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that posting the 3rd user message does not trigger automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 2
The history_conversation fixture has 2 user messages. Posting a 3rd message
should not trigger title generation.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 2
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
history_conversation.title = "initial title"
history_conversation.save()
assert not history_conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is not in the stream
assert "conversation_metadata" not in response_content
# Refresh and verify title was NOT updated (past threshold)
history_conversation.refresh_from_db()
# title not updated
assert history_conversation.title == "initial title"
# title_set_by_user_at should remain None since it was auto-generated
assert not history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.call_count == 1
@@ -92,7 +92,6 @@ def test_post_conversation_with_local_image_url(
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
@@ -111,7 +110,6 @@ def test_post_conversation_with_local_image_url(
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is an image of a single pixel."
@@ -183,7 +181,6 @@ def test_post_conversation_with_local_image_url(
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -202,26 +199,17 @@ def test_post_conversation_with_local_image_url(
},
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is an image of a single pixel.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
{"content": "This is an image of a single pixel.", "id": None, "part_kind": "text"}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -241,7 +229,7 @@ def test_post_conversation_with_local_image_url(
@freeze_time()
def test_post_conversation_with_local_image_wrong_url(
api_client,
today_prompt_date,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -287,8 +275,7 @@ def test_post_conversation_with_local_image_wrong_url(
timestamp=timezone.now(),
),
],
timestamp=timezone.now(),
instructions=f"You are a helpful test assistant :)\n\n{today_prompt_date}"
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
@@ -327,7 +314,7 @@ def test_post_conversation_with_local_image_wrong_url(
@freeze_time()
def test_post_conversation_with_remote_image_url(
api_client,
today_prompt_date,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -374,9 +361,8 @@ def test_post_conversation_with_remote_image_url(
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\nAnswer in english.",
f"{today_promt_date}\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is an image of a single pixel."
@@ -446,7 +432,7 @@ def test_post_conversation_with_remote_image_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_image_url_in_history(
api_client,
today_prompt_date,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -489,7 +475,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
@@ -561,8 +547,6 @@ def test_post_conversation_with_local_image_url_in_history(
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
@@ -575,11 +559,11 @@ def test_post_conversation_with_local_image_url_in_history(
identifier="sample.png",
),
],
timestamp=timestamp_now,
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_prompt_date}\n\nAnswer in english.",
f"{today_promt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -593,13 +577,12 @@ def test_post_conversation_with_local_image_url_in_history(
content=[
"Give more details about this image.",
],
timestamp=timestamp_now,
timestamp=timezone.now(),
)
],
run_id=messages[2].run_id,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
timestamp=timestamp_now,
),
]
yield "This is an image of square, very small and nice."
@@ -698,7 +681,7 @@ def test_post_conversation_with_local_image_url_in_history(
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
@@ -745,7 +728,6 @@ def test_post_conversation_with_local_image_url_in_history(
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Give more details about this image."],
@@ -754,26 +736,21 @@ def test_post_conversation_with_local_image_url_in_history(
}
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is an image of square, very small and nice.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -28,7 +28,6 @@ def test_create_conversation(api_client):
conversation = ChatConversation.objects.get(id=response.data["id"])
assert conversation.owner == user
assert conversation.title == "New Conversation"
assert not conversation.title_set_by_user_at
def test_create_conversation_other_owner(api_client):
@@ -2,7 +2,6 @@
import pytest
from rest_framework import status
from rest_framework.exceptions import ErrorDetail
from core.factories import UserFactory
@@ -27,34 +26,6 @@ def test_update_conversation(api_client):
# Verify in database
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
assert conversation.title == "Updated Title"
assert conversation.title_set_by_user_at
def test_update_conversation_limit_title_length(api_client):
"""Test that updating a conversation with a title exceeding 100 characters fails validation."""
chat_conversation = ChatConversationFactory(title="Initial title")
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
# Create a 101-character title to exceed the 100-character maximum limit
new_title = "X" * 101
data = {"title": new_title}
api_client.force_login(chat_conversation.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {
"title": [
ErrorDetail(
string="Ensure this field has no more than 100 characters.", code="max_length"
)
]
}
# Verify in database (title should remain unchanged)
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
assert conversation.title == "Initial title"
assert not conversation.title_set_by_user_at
def test_update_conversation_anonymous(api_client):
@@ -1,55 +0,0 @@
"""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,
+9 -1
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from django.conf import settings
from django.core.files.storage import default_storage
import semchunk
from asgiref.sync import sync_to_async
@@ -13,11 +14,18 @@ from pydantic_ai.messages import ToolReturn
from chat.agents.summarize import SummarizationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
"""Summarize a single chunk of text."""
sum_prompt = (
@@ -1,136 +0,0 @@
"""Translation tool used for uploaded documents."""
import logging
from django.conf import settings
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from chat.agents.translate import TranslationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
logger = logging.getLogger(__name__)
@last_model_retry_soft_fail
async def document_translate(
ctx: RunContext,
*,
target_language: str | None = None,
instructions: str | None = None,
) -> ToolReturn:
"""
Translate the full content of the last uploaded document into the specified target language.
Preserve the original markdown formatting unless the instructions say otherwise.
Return this translation directly to the user WITHOUT any modification
or additional summarization.
The translation is already complete and MUST be presented as-is in the final response.
If target_language isn't specified or unknown, the target language should be asked
to the user.
Instructions are optional but should reflect the user's request.
Examples:
"Translate this doc to English" -> target_language = "English", instructions = ""
"Translate to Spanish, in formal tone" -> target_language = "Spanish",
instructions = "Use formal tone"
"Traduis ce document en français" -> target_language = "French", instructions = ""
"Translate to German, keep technical terms in English" -> target_language = "German",
instructions = "Keep technical terms in English"
"Translate this" -> ask the user: "Which language would you like the document
translated into?"
Args:
target_language (str | None): The language to translate the document into.
If None, ask the user.
instructions (str | None): Optional instructions for the translation style or preferences
"""
try:
if not target_language:
raise ModelCannotRetry(
"The target language is not specified. "
"You must ask the user which language they want the document translated into."
)
instructions_hint = (
f"Follow these instructions: {instructions.strip()}" if instructions else ""
)
translation_agent = TranslationAgent()
# Get the last uploaded text document
last_attachment = await (
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
.order_by("-created_at")
.afirst()
)
if not last_attachment:
raise ModelCannotRetry(
"No text documents found in the conversation. "
"You must explain this to the user and ask them to provide documents."
)
doc_name, content = await read_document_content(last_attachment)
max_chars = settings.TRANSLATION_MAX_CHARS
if len(content) > max_chars:
raise ModelCannotRetry(
f"The document is too large to translate ({len(content):,} characters, "
f"limit is {max_chars:,}). "
"You must explain this to the user, without providing numerical details. "
"Suggest them to reduce the document size by summarizing it or "
"by splitting it into smaller parts. "
"Also offer them to summarize the document in the target language instead, "
"which can be a good alternative to translation for large documents."
)
logger.info(
"[translate] translating '%s', %s chars, target_language='%s', instructions='%s'",
doc_name,
len(content),
target_language,
instructions_hint,
)
# Translate the document directly
translate_prompt = (
f"You are an agent specializing in text translation. "
f"Translate the following document to {target_language}. "
f"Preserve all markdown formatting exactly as-is. "
f"{instructions_hint}\n\n"
f"'''\n{content}\n'''\n\n"
f"Respond directly with the translated text only, no commentary."
)
logger.debug("[translate] prompt for '%s'=> %s", doc_name, translate_prompt[:100] + "...")
try:
resp = await translation_agent.run(translate_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during translation of '%s': %s", doc_name, exc, exc_info=True)
raise ModelRetry(f"An error occurred while translating document '{doc_name}'.") from exc
translated_text = (resp.output or "").strip()
if not translated_text:
raise ModelRetry(f"The translation of '{doc_name}' produced an empty result.")
logger.debug("[translate] final translation length: %s chars", len(translated_text))
return ToolReturn(
return_value=translated_text,
metadata={"sources": {doc_name}},
)
except (ModelCannotRetry, ModelRetry):
raise
except Exception as exc:
logger.exception("Unexpected error in document_translate: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during document translation: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
-10
View File
@@ -4,9 +4,6 @@ import functools
import logging
from typing import Any, Callable
from django.core.files.storage import default_storage
from asgiref.sync import sync_to_async
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
@@ -51,10 +48,3 @@ def last_model_retry_soft_fail(
raise # Re-raise to allow retrying
return wrapper
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
+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)
@@ -2,6 +2,6 @@
This module contains the EventEncoder class.
"""
from .encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder, EventEncoderVersion
from .encoder import EventEncoder
__all__ = ["EventEncoder", "CURRENT_EVENT_ENCODER_VERSION", "EventEncoderVersion"]
__all__ = ["EventEncoder"]
@@ -1,7 +1,6 @@
"""Event Encoder for Vercel AI SDK"""
from enum import Enum
from typing import Union
from typing import Literal, Union
from ..core.events_v4 import BaseEvent as V4BaseEvent
from ..core.events_v4 import TextPart
@@ -9,26 +8,16 @@ from ..core.events_v5 import BaseEvent as V5BaseEvent
from ..core.events_v5 import TextDeltaEvent
class EventEncoderVersion(str, Enum):
"""Enumeration of supported event encoder versions."""
V4 = "v4"
V5 = "v5"
CURRENT_EVENT_ENCODER_VERSION = EventEncoderVersion.V4 # used encoder version
class EventEncoder:
"""
Encodes events for the Vercel AI SDK based on the specified version.
"""
def __init__(self, version: EventEncoderVersion):
def __init__(self, version: Literal["v4", "v5"] = None):
"""
Initializes the EventEncoder with the specified version.
"""
if version not in [EventEncoderVersion.V4, EventEncoderVersion.V5]:
if version not in ["v4", "v5"]:
raise ValueError("Unsupported version. Supported versions are 'v4' and 'v5'.")
self.version = version
@@ -39,7 +28,7 @@ class EventEncoder:
"""
return "text/event-stream"
def encode(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
def encode(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
"""
Encodes an event based on the version.
@@ -49,15 +38,15 @@ class EventEncoder:
str | None: The encoded event as a string,
or None if the event type is not adapted to the SDK version.
"""
if self.version == EventEncoderVersion.V4 and isinstance(event, V4BaseEvent):
if self.version == "v4" and isinstance(event, V4BaseEvent):
return self._encode_v4_streaming(event)
if self.version == EventEncoderVersion.V5 and isinstance(event, V5BaseEvent):
if self.version == "v5" and isinstance(event, V5BaseEvent):
return self._encode_sse(event)
return None
def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
def encode_text(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
"""
Encodes an event based on the version.
@@ -67,10 +56,10 @@ class EventEncoder:
str | None: The encoded event as a string,
or None if the event type is not adapted to the SDK version.
"""
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
if self.version == "v4" and isinstance(event, TextPart):
return event.text
if self.version == EventEncoderVersion.V5 and isinstance(event, TextDeltaEvent):
if self.version == "v5" and isinstance(event, TextDeltaEvent):
return event.delta
return None
@@ -81,7 +70,7 @@ class EventEncoder:
"""
return f"{event.type}:{event.model_dump_json(by_alias=True, exclude={'type'})}\n"
def _encode_sse(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str:
def _encode_sse(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str:
"""
Encodes an event into an SSE string.
"""
+9 -198
View File
@@ -5,51 +5,32 @@ 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
from chat import models, serializers
from chat.clients.pydantic_ai import AIAgentService
from chat.keepalive import stream_with_keepalive_async, stream_with_keepalive_sync
from chat.serializers import ChatConversationRequestSerializer
logger = logging.getLogger(__name__)
def conditional_refresh_oidc_token(func):
"""
Conditionally apply refresh_oidc_access_token decorator.
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
we can actually refresh something. Broader settings checks are done in settings.py.
"""
if settings.OIDC_STORE_REFRESH_TOKEN:
return method_decorator(refresh_oidc_access_token)(func)
return func
class ChatConversationFilter(filters.BaseFilterBackend):
"""Filter conversation."""
@@ -141,7 +122,6 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
self.permission_classes = []
return super().get_permissions()
@conditional_refresh_oidc_token
@decorators.action(
methods=["post"],
detail=True,
@@ -193,7 +173,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
@@ -209,28 +188,29 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
if is_async_mode:
logger.debug("Using ASYNC streaming for chat conversation.")
if protocol == "data":
base_stream = ai_service.stream_data_async(
streaming_content = ai_service.stream_data_async(
messages, force_web_search=force_web_search
)
else: # Default to 'text' protocol
base_stream = ai_service.stream_text_async(
streaming_content = ai_service.stream_text_async(
messages, force_web_search=force_web_search
)
streaming_content = stream_with_keepalive_async(base_stream)
else:
logger.debug("Using SYNC streaming for chat conversation.")
if protocol == "data":
base_stream = ai_service.stream_data(messages, force_web_search=force_web_search)
streaming_content = ai_service.stream_data(
messages, force_web_search=force_web_search
)
else: # Default to 'text' protocol
base_stream = ai_service.stream_text(messages, force_web_search=force_web_search)
streaming_content = ai_service.stream_text(
messages, force_web_search=force_web_search
)
streaming_content = stream_with_keepalive_sync(base_stream)
response = StreamingHttpResponse(
streaming_content,
content_type="text/event-stream",
headers={
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
"X-Accel-Buffering": "no", # Prevent nginx buffering
},
)
return response
@@ -391,6 +371,7 @@ class ChatConversationAttachmentViewSet(
owner=self.request.user,
).exists():
raise Http404
file_name = serializer.validated_data["file_name"]
extension = file_name.rpartition(".")[-1] if "." in file_name else None
@@ -454,173 +435,3 @@ 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):
+1 -147
View File
@@ -230,27 +230,6 @@ 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",
@@ -416,11 +395,6 @@ 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,
),
},
}
@@ -743,11 +717,6 @@ class Base(BraveSettings, Configuration):
environ_name="RAG_DOCUMENT_SEARCH_BACKEND",
environ_prefix=None,
)
RAG_DOCUMENT_PARSER = values.Value(
"chat.agent_rag.document_converter.parser.AlbertParser",
environ_name="RAG_DOCUMENT_PARSER",
environ_prefix=None,
)
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = values.DictValue(
default={},
environ_name="SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS",
@@ -813,51 +782,6 @@ USER QUESTION:
environ_prefix=None,
)
# OCR settings for AdaptivePdfParser
OCR_HRID = values.Value(
default="etalab-plateform-mistral-medium-2508",
environ_name="OCR_HRID",
environ_prefix=None,
)
# Specific Mistral OCR model - Designates which Mistral vision model to use for OCR
OCR_MODEL = values.Value(
default="mistral-ocr-2512",
environ_name="OCR_MODEL",
environ_prefix=None,
)
OCR_TIMEOUT = values.PositiveIntegerValue(
default=240,
environ_name="OCR_TIMEOUT",
environ_prefix=None,
)
OCR_MAX_RETRIES = values.PositiveIntegerValue(
default=3,
environ_name="OCR_MAX_RETRIES",
environ_prefix=None,
)
OCR_RETRY_DELAY = values.PositiveIntegerValue(
default=5,
environ_name="OCR_RETRY_DELAY",
environ_prefix=None,
)
OCR_BATCH_PAGES = values.PositiveIntegerValue(
default=10,
environ_name="OCR_BATCH_PAGES",
environ_prefix=None,
)
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = values.PositiveIntegerValue(
default=200,
environ_name="MIN_AVG_CHARS_FOR_TEXT_EXTRACTION",
environ_prefix=None,
)
MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = values.FloatValue(
default=0.7,
environ_name="MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION",
environ_prefix=None,
)
# Summarization
SUMMARIZATION_SYSTEM_PROMPT = values.Value(
(
@@ -883,13 +807,6 @@ USER QUESTION:
environ_prefix=None,
)
# Translation
TRANSLATION_MAX_CHARS = values.PositiveIntegerValue(
default=100_000, # ~100k characters, roughly half a 128k context window
environ_name="TRANSLATION_MAX_CHARS",
environ_prefix=None,
)
# Tavily API
TAVILY_API_KEY = values.Value(
None, # Tavily API key is not set by default
@@ -924,23 +841,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.
@@ -1011,9 +911,7 @@ USER QUESTION:
LANGFUSE_MEDIA_UPLOAD_ENABLED = values.BooleanValue(
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
)
AUTO_TITLE_AFTER_USER_MESSAGES = values.PositiveIntegerValue(
default=None, environ_name="AUTO_TITLE_AFTER_USER_MESSAGES", environ_prefix=None
)
# WARNING: Testing purpose only. Do not use in production.
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
default=False,
@@ -1021,12 +919,6 @@ USER QUESTION:
environ_prefix=None,
)
# Default keepalive interval: 55s (safely below typical 60s proxy timeouts)
# Prevents connection drops during long stream pauses while providing 5s safety margin.
KEEPALIVE_INTERVAL = values.PositiveIntegerValue(
default=55, environ_name="KEEPALIVE_INTERVAL", environ_prefix=None
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -1146,42 +1038,6 @@ 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."
)
# Find configuration
if (
cls.RAG_DOCUMENT_SEARCH_BACKEND
== "chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend"
and not all(
(
cls.FIND_API_KEY,
cls.FIND_API_URL,
cls.OIDC_STORE_ACCESS_TOKEN,
cls.OIDC_STORE_REFRESH_TOKEN,
)
)
):
raise ValueError(
f"{cls.RAG_DOCUMENT_SEARCH_BACKEND} requires FIND_API_KEY, FIND_API_URL, "
"OIDC_STORE_ACCESS_TOKEN and OIDC_STORE_REFRESH_TOKEN to be set."
)
# OCR configuration validation
# Note: we call load_llm_configuration directly because LLM_CONFIGURATIONS is a
# @property returning a lazy object, which cannot be accessed via cls in a classmethod.
if cls.RAG_DOCUMENT_PARSER == "chat.agent_rag.document_converter.parser.AdaptivePdfParser":
llm_configs = load_llm_configuration(cls._llm_configuration_file_path)
if cls.OCR_HRID not in llm_configs:
raise ValueError(
f"OCR_HRID '{cls.OCR_HRID}' not found in LLM_CONFIGURATIONS. "
"Please add a matching provider entry or set OCR_HRID to an existing key."
)
# Langfuse initialization
if cls.LANGFUSE_ENABLED:
if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED:
@@ -1275,8 +1131,6 @@ class Test(Base):
POSTHOG_KEY = None
AUTO_TITLE_AFTER_USER_MESSAGES = None
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
-1
View File
@@ -211,7 +211,6 @@ class ConfigView(drf.views.APIView):
"LANGUAGE_CODE",
"SENTRY_DSN",
"FEATURE_FLAGS",
"FILE_UPLOAD_MODE",
]
dict_settings = {}
for setting in array_settings:
-25
View File
@@ -20,28 +20,3 @@ 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]
@@ -1,110 +0,0 @@
"""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,7 +47,6 @@ 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",
@@ -190,7 +189,6 @@ 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",
+2 -23
View File
@@ -1,21 +1,15 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path
from django.urls import include, 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,
FileStreamView,
LLMConfigurationView,
)
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
# - Main endpoints
router = DefaultRouter()
@@ -43,21 +37,6 @@ 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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\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-02-09 13:05\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
+6 -21
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.13"
version = "0.0.10"
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.13.0"
requires-python = ">=3.12"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
@@ -39,14 +39,13 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.11",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jaraco.context>=6.1.0",
"jsonschema==4.25.1",
"langfuse==3.10.0",
"lxml==5.4.0",
@@ -56,7 +55,7 @@ dependencies = [
"nested-multipart-parser==1.6.0",
"posthog==7.0.0",
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.56.0",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.17.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
@@ -67,7 +66,6 @@ dependencies = [
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"pypdf>=6.6.2",
]
[project.urls]
@@ -108,19 +106,6 @@ zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv]
override-dependencies = [
"cryptography>=46.0.5", # CVE-2026-26007
]
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [
".git",
@@ -152,8 +137,8 @@ select = [
]
[tool.ruff.lint.isort]
section-order = ["future", "standard-library", "django", "third-party", "conversations", "first-party", "local-folder"]
sections = { conversations = ["core"], django = ["django"] }
section-order = ["future","standard-library","django","third-party","conversations","first-party","local-folder"]
sections = { conversations=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
+7
View File
@@ -0,0 +1,7 @@
#!/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
-3145
View File
File diff suppressed because it is too large Load Diff
+7 -27
View File
@@ -69,16 +69,6 @@ export const commonTokenOverrides = {
};
export const commonGlobals = {
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
},
font: {
sizes: {
xs: '0.75rem',
@@ -145,8 +135,10 @@ export const commonGlobals = {
export const whiteLabelGlobals = {
colors: {
'logo-1': '#4844AD',
'logo-2': '#4844AD',
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -488,18 +480,6 @@ export const whiteLabelGlobals = {
};
export const dsfrGlobals = {
...commonGlobals,
components: {
...commonGlobals.components,
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
colors: {
'logo-1': '#2845C1',
'logo-2': '#C83F49',
@@ -830,6 +810,7 @@ export const dsfrGlobals = {
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
...commonGlobals,
};
const whiteLabelThemes = getThemesFromGlobals(whiteLabelGlobals, {
@@ -839,9 +820,8 @@ const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
overrides: commonTokenOverrides,
});
if (dsfrThemes.dark) {
dsfrThemes.dark.globals.components.logo.src =
'/assets/logo-gouv-darkmode.svg';
// Apply logo colors only to dsfr-dark theme
if (dsfrThemes.dark?.globals?.colors) {
dsfrThemes.dark.globals.colors['logo-1'] = '#95ABFF';
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.13",
"version": "0.0.10",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,13 +9,13 @@
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
"start": "npx -y serve@latest out",
"lint": "tsc --noEmit && next lint",
"lint:fix": "tsc --noEmit && next lint --fix",
"prettier": "prettier --write .",
"stylelint": "stylelint \"**/*.css\"",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/react": "1.2.12",
"@ai-sdk/ui-utils": "1.2.11",
"@emoji-mart/data": "1.2.1",
@@ -23,10 +23,10 @@
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.7",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.26.0",
"@shikijs/rehype": "^3.21.0",
"@tanstack/react-query": "5.80.5",
"canvg": "4.0.3",
"clsx": "2.1.1",
@@ -36,11 +36,11 @@
"i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.23",
"lodash": "4.17.21",
"lottie-react": "^2.4.1",
"luxon": "3.6.1",
"micromark-extension-llm-math": "3.1.1-20250610",
"next": "15.3.9",
"next": "15.3.8",
"posthog-js": "1.249.3",
"react": "19.2.1",
"react-aria-components": "1.9.0",
@@ -48,10 +48,12 @@
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-markdown": "10.1.0",
"react-select": "5.10.1",
"rehype-katex": "7.0.1",
"rehype-pretty-code": "^0.14.1",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"shiki": "^3.21.0",
"shiki": "^3.13.0",
"styled-components": "6.1.18",
"use-debounce": "10.0.4",
"zod": "^3.25.67",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

@@ -1,8 +1,17 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
export const Card = ({
children,
$css,
...props
}: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
className={`--docs--card ${props.className || ''}`}
@@ -33,7 +33,7 @@ export const DropdownMenu = ({
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens } = useCunninghamTheme();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
const blockButtonRef = useRef<HTMLDivElement>(null);
@@ -1,61 +1,16 @@
import { useRouter } from 'next/navigation';
import { memo, useCallback, useEffect, useRef } from 'react';
import Link from 'next/link';
import styled, { RuleSet } from 'styled-components';
interface StyledLinkProps {
export interface LinkProps {
$css?: string | RuleSet<object>;
}
const Anchor = styled.a<StyledLinkProps>`
export const StyledLink = styled(Link)<LinkProps>`
text-decoration: none;
color: #ffffff;
&[aria-current='page'] {
color: #ffffff;
}
display: flex;
cursor: pointer;
${({ $css }) => $css && (typeof $css === 'string' ? `${$css}` : $css)}
${({ $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} />;
});
@@ -7,7 +7,7 @@ import { Box, Text, TextType } from '@/components';
const AlertStyled = styled(Alert)`
& .c__button--tertiary:hover {
background-color: var(--c--theme--colors--gray-200);
background-color: var(--c--theme--colors--greyscale-200);
}
`;
@@ -1,101 +0,0 @@
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();
});
});
@@ -64,8 +64,8 @@ export const QuickSearch = ({
bottom: -20px;
background: linear-gradient(
to bottom,
rgba(var(--c--contextuals--background--surface--tertiary), 1),
rgba(var(--c--contextuals--background--surface--tertiary), 0)
var(--c--contextuals--background--surface--tertiary) 0%,
transparent 100%
);
}
${
@@ -101,9 +101,6 @@ export const QuickSearchInput = ({
border: none;
outline: none;
}
&::placeholder {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
`}
>
@@ -13,7 +13,7 @@ export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const { spacingsTokens } = useCunninghamTheme();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
return (
<Box
$css={css`
@@ -34,7 +34,6 @@ export interface ConfigResponse {
MEDIA_BASE_URL?: string;
POSTHOG_KEY?: PostHogConf;
SENTRY_DSN?: string;
FILE_UPLOAD_MODE?: string;
theme_customization?: ThemeCustomization;
chat_upload_accept?: string;
}
@@ -1,20 +1,16 @@
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the favicon correctly set', () => {
const favicon1 = (
useCunninghamTheme.getState().componentTokens as Record<string, unknown>
).favicon as { 'png-light': string; 'png-dark': string } | undefined;
expect(favicon1?.['png-light']).toBe('/assets/favicon-light.png');
it('has the logo correctly set', () => {
expect(useCunninghamTheme.getState().componentTokens.logo?.src).toBe('');
// Change theme
useCunninghamTheme.getState().setTheme('dsfr');
const { componentTokens } = useCunninghamTheme.getState();
const favicon = (componentTokens as Record<string, unknown>).favicon as
| { 'png-light': string; 'png-dark': string }
| undefined;
expect(favicon?.['png-light']).toBe('/assets/favicon-light.png');
expect(favicon?.['png-dark']).toBe('/assets/favicon-dark.png');
const logo = componentTokens.logo;
expect(logo?.src).toBe('/assets/logo-gouv.svg');
expect(logo?.widthHeader).toBe('110px');
expect(logo?.widthFooter).toBe('220px');
});
});
@@ -1,5 +1,4 @@
@import url('@gouvfr-lasuite/ui-kit/style');
@import url('@gouvfr-lasuite/ui-kit/fonts/Marianne');
@import url('./cunningham-tokens.css');
:root {
@@ -1,8 +1,8 @@
:root {
--c--globals--colors--logo-1-light: #377fdb;
--c--globals--colors--logo-2-light: #377fdb;
--c--globals--colors--logo-1-dark: #c1d6f2;
--c--globals--colors--logo-2-dark: #c1d6f2;
--c--globals--colors--logo-1-light: #4844ad;
--c--globals--colors--logo-2-light: #4844ad;
--c--globals--colors--logo-1-dark: #bec5f0;
--c--globals--colors--logo-2-dark: #bec5f0;
--c--globals--colors--brand-050: #eef1fa;
--c--globals--colors--brand-100: #dde2f5;
--c--globals--colors--brand-150: #ced3f1;
@@ -332,8 +332,6 @@
--c--globals--colors--white-900: #f8f8f9e5;
--c--globals--colors--white-950: #f8f8f9f2;
--c--globals--colors--white-975: #f8f8f9f9;
--c--globals--colors--logo-1: #4844ad;
--c--globals--colors--logo-2: #4844ad;
--c--globals--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--globals--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--globals--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -407,12 +405,6 @@
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--globals--components--la-gaufre: false;
--c--globals--components--home-proconnect: false;
--c--globals--components--logo--src: ;
--c--globals--components--logo--alt: ;
--c--globals--components--logo--widthheader: ;
--c--globals--components--logo--widthfooter: ;
--c--contextuals--background--surface--primary: var(
--c--globals--colors--gray-000
);
@@ -895,10 +887,10 @@
}
.cunningham-theme--dark {
--c--globals--colors--logo-1-light: #377fdb;
--c--globals--colors--logo-2-light: #377fdb;
--c--globals--colors--logo-1-dark: #c1d6f2;
--c--globals--colors--logo-2-dark: #c1d6f2;
--c--globals--colors--logo-1-light: #4844ad;
--c--globals--colors--logo-2-light: #4844ad;
--c--globals--colors--logo-1-dark: #bec5f0;
--c--globals--colors--logo-2-dark: #bec5f0;
--c--globals--colors--brand-050: #eef1fa;
--c--globals--colors--brand-100: #dde2f5;
--c--globals--colors--brand-150: #ced3f1;
@@ -1228,8 +1220,6 @@
--c--globals--colors--white-900: #f8f8f9e5;
--c--globals--colors--white-950: #f8f8f9f2;
--c--globals--colors--white-975: #f8f8f9f9;
--c--globals--colors--logo-1: #4844ad;
--c--globals--colors--logo-2: #4844ad;
--c--globals--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--globals--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--globals--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -1303,12 +1293,6 @@
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--globals--components--la-gaufre: false;
--c--globals--components--home-proconnect: false;
--c--globals--components--logo--src: ;
--c--globals--components--logo--alt: ;
--c--globals--components--logo--widthHeader: ;
--c--globals--components--logo--widthFooter: ;
--c--contextuals--background--surface--primary: var(
--c--globals--colors--gray-800
);
@@ -1791,64 +1775,6 @@
}
.cunningham-theme--dsfr {
--c--globals--components--la-gaufre: true;
--c--globals--components--home-proconnect: true;
--c--globals--components--logo--src: /assets/logo-gouv.svg;
--c--globals--components--logo--widthHeader: 110px;
--c--globals--components--logo--widthFooter: 220px;
--c--globals--components--logo--alt: gouvernement logo;
--c--globals--font--sizes--xs: 0.75rem;
--c--globals--font--sizes--sm: 0.875rem;
--c--globals--font--sizes--md: 1rem;
--c--globals--font--sizes--lg: 1.125rem;
--c--globals--font--sizes--ml: 0.938rem;
--c--globals--font--sizes--xl: 1.25rem;
--c--globals--font--sizes--t: 0.6875rem;
--c--globals--font--sizes--s: 0.75rem;
--c--globals--font--sizes--h1: 2rem;
--c--globals--font--sizes--h2: 1.75rem;
--c--globals--font--sizes--h3: 1.5rem;
--c--globals--font--sizes--h4: 1.375rem;
--c--globals--font--sizes--h5: 1.25rem;
--c--globals--font--sizes--h6: 1.125rem;
--c--globals--font--sizes--xl-alt: 5rem;
--c--globals--font--sizes--lg-alt: 4.5rem;
--c--globals--font--sizes--md-alt: 4rem;
--c--globals--font--sizes--sm-alt: 3.5rem;
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--font--families--base:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--spacings--0: 0;
--c--globals--spacings--none: 0;
--c--globals--spacings--auto: auto;
--c--globals--spacings--bx: 2.2rem;
--c--globals--spacings--full: 100%;
--c--globals--spacings--4xs: 0.125rem;
--c--globals--spacings--3xs: 0.25rem;
--c--globals--spacings--2xs: 0.375rem;
--c--globals--spacings--xs: 0.5rem;
--c--globals--spacings--sm: 0.75rem;
--c--globals--spacings--base: 1rem;
--c--globals--spacings--md: 1.5rem;
--c--globals--spacings--lg: 2rem;
--c--globals--spacings--xl: 2.5rem;
--c--globals--spacings--xxl: 3rem;
--c--globals--spacings--2xl: 3rem;
--c--globals--spacings--xxxl: 3.5rem;
--c--globals--spacings--3xl: 3.5rem;
--c--globals--spacings--4xl: 4rem;
--c--globals--spacings--5xl: 4.5rem;
--c--globals--spacings--6xl: 6rem;
--c--globals--spacings--7xl: 7.5rem;
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--xs: 480px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--globals--colors--logo-1: #2845c1;
--c--globals--colors--logo-2: #c83f49;
--c--globals--colors--brand-050: #edf0ff;
@@ -2177,6 +2103,58 @@
--c--globals--colors--white-900: #f6f8f9e5;
--c--globals--colors--white-950: #f6f8f9f2;
--c--globals--colors--white-975: #f6f8f9f9;
--c--globals--font--sizes--xs: 0.75rem;
--c--globals--font--sizes--sm: 0.875rem;
--c--globals--font--sizes--md: 1rem;
--c--globals--font--sizes--lg: 1.125rem;
--c--globals--font--sizes--ml: 0.938rem;
--c--globals--font--sizes--xl: 1.25rem;
--c--globals--font--sizes--t: 0.6875rem;
--c--globals--font--sizes--s: 0.75rem;
--c--globals--font--sizes--h1: 2rem;
--c--globals--font--sizes--h2: 1.75rem;
--c--globals--font--sizes--h3: 1.5rem;
--c--globals--font--sizes--h4: 1.375rem;
--c--globals--font--sizes--h5: 1.25rem;
--c--globals--font--sizes--h6: 1.125rem;
--c--globals--font--sizes--xl-alt: 5rem;
--c--globals--font--sizes--lg-alt: 4.5rem;
--c--globals--font--sizes--md-alt: 4rem;
--c--globals--font--sizes--sm-alt: 3.5rem;
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--font--families--base:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--spacings--0: 0;
--c--globals--spacings--none: 0;
--c--globals--spacings--auto: auto;
--c--globals--spacings--bx: 2.2rem;
--c--globals--spacings--full: 100%;
--c--globals--spacings--4xs: 0.125rem;
--c--globals--spacings--3xs: 0.25rem;
--c--globals--spacings--2xs: 0.375rem;
--c--globals--spacings--xs: 0.5rem;
--c--globals--spacings--sm: 0.75rem;
--c--globals--spacings--base: 1rem;
--c--globals--spacings--md: 1.5rem;
--c--globals--spacings--lg: 2rem;
--c--globals--spacings--xl: 2.5rem;
--c--globals--spacings--xxl: 3rem;
--c--globals--spacings--2xl: 3rem;
--c--globals--spacings--xxxl: 3.5rem;
--c--globals--spacings--3xl: 3.5rem;
--c--globals--spacings--4xl: 4rem;
--c--globals--spacings--5xl: 4.5rem;
--c--globals--spacings--6xl: 6rem;
--c--globals--spacings--7xl: 7.5rem;
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--xs: 480px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--contextuals--background--surface--primary: var(
--c--globals--colors--gray-000
);
@@ -2659,64 +2637,6 @@
}
.cunningham-theme--dsfr-dark {
--c--globals--components--la-gaufre: true;
--c--globals--components--home-proconnect: true;
--c--globals--components--logo--src: /assets/logo-gouv-darkmode.svg;
--c--globals--components--logo--widthHeader: 110px;
--c--globals--components--logo--widthFooter: 220px;
--c--globals--components--logo--alt: gouvernement logo;
--c--globals--font--sizes--xs: 0.75rem;
--c--globals--font--sizes--sm: 0.875rem;
--c--globals--font--sizes--md: 1rem;
--c--globals--font--sizes--lg: 1.125rem;
--c--globals--font--sizes--ml: 0.938rem;
--c--globals--font--sizes--xl: 1.25rem;
--c--globals--font--sizes--t: 0.6875rem;
--c--globals--font--sizes--s: 0.75rem;
--c--globals--font--sizes--h1: 2rem;
--c--globals--font--sizes--h2: 1.75rem;
--c--globals--font--sizes--h3: 1.5rem;
--c--globals--font--sizes--h4: 1.375rem;
--c--globals--font--sizes--h5: 1.25rem;
--c--globals--font--sizes--h6: 1.125rem;
--c--globals--font--sizes--xl-alt: 5rem;
--c--globals--font--sizes--lg-alt: 4.5rem;
--c--globals--font--sizes--md-alt: 4rem;
--c--globals--font--sizes--sm-alt: 3.5rem;
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--font--families--base:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--spacings--0: 0;
--c--globals--spacings--none: 0;
--c--globals--spacings--auto: auto;
--c--globals--spacings--bx: 2.2rem;
--c--globals--spacings--full: 100%;
--c--globals--spacings--4xs: 0.125rem;
--c--globals--spacings--3xs: 0.25rem;
--c--globals--spacings--2xs: 0.375rem;
--c--globals--spacings--xs: 0.5rem;
--c--globals--spacings--sm: 0.75rem;
--c--globals--spacings--base: 1rem;
--c--globals--spacings--md: 1.5rem;
--c--globals--spacings--lg: 2rem;
--c--globals--spacings--xl: 2.5rem;
--c--globals--spacings--xxl: 3rem;
--c--globals--spacings--2xl: 3rem;
--c--globals--spacings--xxxl: 3.5rem;
--c--globals--spacings--3xl: 3.5rem;
--c--globals--spacings--4xl: 4rem;
--c--globals--spacings--5xl: 4.5rem;
--c--globals--spacings--6xl: 6rem;
--c--globals--spacings--7xl: 7.5rem;
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--xs: 480px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--globals--colors--logo-1: #95abff;
--c--globals--colors--logo-2: #e78087;
--c--globals--colors--brand-050: #edf0ff;
@@ -3045,6 +2965,58 @@
--c--globals--colors--white-900: #f6f8f9e5;
--c--globals--colors--white-950: #f6f8f9f2;
--c--globals--colors--white-975: #f6f8f9f9;
--c--globals--font--sizes--xs: 0.75rem;
--c--globals--font--sizes--sm: 0.875rem;
--c--globals--font--sizes--md: 1rem;
--c--globals--font--sizes--lg: 1.125rem;
--c--globals--font--sizes--ml: 0.938rem;
--c--globals--font--sizes--xl: 1.25rem;
--c--globals--font--sizes--t: 0.6875rem;
--c--globals--font--sizes--s: 0.75rem;
--c--globals--font--sizes--h1: 2rem;
--c--globals--font--sizes--h2: 1.75rem;
--c--globals--font--sizes--h3: 1.5rem;
--c--globals--font--sizes--h4: 1.375rem;
--c--globals--font--sizes--h5: 1.25rem;
--c--globals--font--sizes--h6: 1.125rem;
--c--globals--font--sizes--xl-alt: 5rem;
--c--globals--font--sizes--lg-alt: 4.5rem;
--c--globals--font--sizes--md-alt: 4rem;
--c--globals--font--sizes--sm-alt: 3.5rem;
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--font--families--base:
marianne, inter, roboto flex variable, sans-serif;
--c--globals--spacings--0: 0;
--c--globals--spacings--none: 0;
--c--globals--spacings--auto: auto;
--c--globals--spacings--bx: 2.2rem;
--c--globals--spacings--full: 100%;
--c--globals--spacings--4xs: 0.125rem;
--c--globals--spacings--3xs: 0.25rem;
--c--globals--spacings--2xs: 0.375rem;
--c--globals--spacings--xs: 0.5rem;
--c--globals--spacings--sm: 0.75rem;
--c--globals--spacings--base: 1rem;
--c--globals--spacings--md: 1.5rem;
--c--globals--spacings--lg: 2rem;
--c--globals--spacings--xl: 2.5rem;
--c--globals--spacings--xxl: 3rem;
--c--globals--spacings--2xl: 3rem;
--c--globals--spacings--xxxl: 3.5rem;
--c--globals--spacings--3xl: 3.5rem;
--c--globals--spacings--4xl: 4rem;
--c--globals--spacings--5xl: 4.5rem;
--c--globals--spacings--6xl: 6rem;
--c--globals--spacings--7xl: 7.5rem;
--c--globals--breakpoints--xxs: 320px;
--c--globals--breakpoints--xs: 480px;
--c--globals--breakpoints--mobile: 768px;
--c--globals--breakpoints--tablet: 1024px;
--c--contextuals--background--surface--primary: var(
--c--globals--colors--gray-800
);
@@ -4858,14 +4830,6 @@
color: var(--c--globals--colors--white-975);
}
.clr-logo-1 {
color: var(--c--globals--colors--logo-1);
}
.clr-logo-2 {
color: var(--c--globals--colors--logo-2);
}
.bg-logo-1-light {
background-color: var(--c--globals--colors--logo-1-light);
}
@@ -6198,14 +6162,6 @@
background-color: var(--c--globals--colors--white-975);
}
.bg-logo-1 {
background-color: var(--c--globals--colors--logo-1);
}
.bg-logo-2 {
background-color: var(--c--globals--colors--logo-2);
}
.bg-surface-primary {
background-color: var(--c--contextuals--background--surface--primary);
}
@@ -3,10 +3,10 @@ export const tokens = {
default: {
globals: {
colors: {
'logo-1-light': '#377FDB',
'logo-2-light': '#377FDB',
'logo-1-dark': '#C1D6F2',
'logo-2-dark': '#C1D6F2',
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -336,8 +336,6 @@ export const tokens = {
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
'logo-1': '#4844AD',
'logo-2': '#4844AD',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -424,11 +422,6 @@ export const tokens = {
mobile: '768px',
tablet: '1024px',
},
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: { src: '', alt: '', widthHeader: '', widthFooter: '' },
},
},
contextuals: {
background: {
@@ -549,8 +542,8 @@ export const tokens = {
},
},
content: {
logo1: '#377FDB',
logo2: '#377FDB',
logo1: '#4844AD',
logo2: '#4844AD',
semantic: {
contextual: { primary: '#F8F8F9F2' },
overlay: { primary: '#F8F8F9F2' },
@@ -683,10 +676,10 @@ export const tokens = {
dark: {
globals: {
colors: {
'logo-1-light': '#377FDB',
'logo-2-light': '#377FDB',
'logo-1-dark': '#C1D6F2',
'logo-2-dark': '#C1D6F2',
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -1016,8 +1009,6 @@ export const tokens = {
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
'logo-1': '#4844AD',
'logo-2': '#4844AD',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -1104,11 +1095,6 @@ export const tokens = {
mobile: '768px',
tablet: '1024px',
},
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: { src: '', alt: '', widthHeader: '', widthFooter: '' },
},
},
contextuals: {
background: {
@@ -1229,8 +1215,8 @@ export const tokens = {
},
},
content: {
logo1: '#C1D6F2',
logo2: '#C1D6F2',
logo1: '#BEC5F0',
logo2: '#BEC5F0',
semantic: {
contextual: { primary: '#1B1B23D9' },
overlay: { primary: '#1B1B23D9' },
@@ -1362,74 +1348,6 @@ export const tokens = {
},
dsfr: {
globals: {
components: {
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800, black: 900 },
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
colors: {
'logo-1': '#2845C1',
'logo-2': '#C83F49',
@@ -1760,6 +1678,64 @@ export const tokens = {
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800, black: 900 },
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
},
contextuals: {
background: {
@@ -1880,8 +1856,8 @@ export const tokens = {
},
},
content: {
logo1: '#377FDB',
logo2: '#377FDB',
logo1: '#4844AD',
logo2: '#4844AD',
semantic: {
contextual: { primary: '#F8F8F9F2' },
overlay: { primary: '#F8F8F9F2' },
@@ -2013,74 +1989,6 @@ export const tokens = {
},
'dsfr-dark': {
globals: {
components: {
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv-darkmode.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800, black: 900 },
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
colors: {
'logo-1': '#95ABFF',
'logo-2': '#E78087',
@@ -2411,6 +2319,64 @@ export const tokens = {
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800, black: 900 },
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
},
contextuals: {
background: {
@@ -2531,8 +2497,8 @@ export const tokens = {
},
},
content: {
logo1: '#C1D6F2',
logo2: '#C1D6F2',
logo1: '#BEC5F0',
logo2: '#BEC5F0',
semantic: {
contextual: { primary: '#1B1B23D9' },
overlay: { primary: '#1B1B23D9' },
@@ -2,9 +2,6 @@ import merge from 'lodash/merge';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { safeLocalStorage } from '@/utils/storages';
import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default &
@@ -12,11 +9,7 @@ type Tokens = typeof tokens.themes.default &
type ColorsTokens = Tokens['globals']['colors'];
type FontSizesTokens = Tokens['globals']['font']['sizes'];
type SpacingsTokens = Tokens['globals']['spacings'];
type ComponentTokens = Partial<
| (Tokens['components'] & Tokens['globals']['components'])
| Record<string, unknown>
> &
Record<string, unknown>;
type ComponentTokens = Tokens['components'];
type ContextualTokens = Tokens['contextuals'];
export type Theme = keyof typeof tokens.themes;
@@ -29,7 +22,6 @@ interface ThemeStore {
setTheme: (theme: Theme) => void;
spacingsTokens: Partial<SpacingsTokens>;
theme: Theme;
baseTheme: Theme; // 'default' or 'dsfr' (not persisted)
themeTokens: Partial<Tokens['globals']>;
isDarkMode: boolean;
toggleDarkMode: () => void;
@@ -39,41 +31,20 @@ const getMergedTokens = (theme: Theme) => {
return merge({}, tokens.themes['default'], tokens.themes[theme]);
};
const getComponentTokens = (
mergedTokens: ReturnType<typeof getMergedTokens>,
) => {
// Merge components from root level (favicon, etc.) and globals.components (logo, etc.)
return merge(
{},
mergedTokens.components || {},
mergedTokens.globals?.components || {},
);
};
const DEFAULT_THEME: Theme = 'default';
const defaultTokens = getMergedTokens(DEFAULT_THEME);
// Helper to get isDarkMode from useChatPreferencesStore
const getIsDarkModeFromPreferences = (): boolean => {
try {
return useChatPreferencesStore.getState().isDarkModePreference ?? false;
} catch {
return false;
}
};
const initialState: ThemeStore = {
colorsTokens: defaultTokens.globals.colors,
componentTokens: getComponentTokens(defaultTokens),
componentTokens: defaultTokens.components,
contextualTokens: defaultTokens.contextuals,
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
fontSizesTokens: defaultTokens.globals.font.sizes,
setTheme: () => {},
spacingsTokens: defaultTokens.globals.spacings,
theme: DEFAULT_THEME,
baseTheme: DEFAULT_THEME,
themeTokens: defaultTokens.globals,
isDarkMode: getIsDarkModeFromPreferences(),
isDarkMode: false,
toggleDarkMode: () => {},
};
@@ -82,82 +53,49 @@ export const useCunninghamTheme = create<ThemeStore>()(
(set) => ({
...initialState,
setTheme: (theme: Theme) => {
// Extract base theme (default or dsfr)
const baseTheme: Theme =
theme === 'dark' || theme === 'dsfr-dark'
? theme === 'dark'
? 'default'
: 'dsfr'
: theme;
const isDarkMode =
getIsDarkModeFromPreferences() ??
(theme === 'dark' || theme === 'dsfr-dark');
// Apply dark mode based on stored preference or theme
const finalTheme: Theme = isDarkMode
? baseTheme === 'dsfr'
? 'dsfr-dark'
: 'dark'
: baseTheme;
const newTokens = getMergedTokens(finalTheme);
const newTokens = getMergedTokens(theme);
set({
colorsTokens: newTokens.globals.colors,
componentTokens: getComponentTokens(newTokens),
componentTokens: newTokens.components,
contextualTokens: newTokens.contextuals,
currentTokens: tokens.themes[finalTheme] as Partial<Tokens>,
currentTokens: tokens.themes[theme] as Partial<Tokens>,
fontSizesTokens: newTokens.globals.font.sizes,
spacingsTokens: newTokens.globals.spacings,
theme: finalTheme,
baseTheme,
theme,
themeTokens: newTokens.globals,
isDarkMode,
isDarkMode: theme === 'dark' || theme === 'dsfr-dark',
});
},
toggleDarkMode: () => {
useChatPreferencesStore.getState().toggleDarkModePreferences();
set((state) => {
const newIsDarkMode = getIsDarkModeFromPreferences();
const newTheme: Theme = newIsDarkMode
? state.baseTheme === 'dsfr'
? 'dsfr-dark'
: 'dark'
: state.baseTheme;
const newTheme =
state.theme === 'default'
? 'dark'
: state.theme === 'dark'
? 'default'
: state.theme === 'dsfr'
? 'dsfr-dark'
: 'dsfr';
const newTokens = getMergedTokens(newTheme);
return {
colorsTokens: newTokens.globals.colors,
componentTokens: getComponentTokens(newTokens),
componentTokens: newTokens.components,
contextualTokens: newTokens.contextuals,
currentTokens: tokens.themes[newTheme] as Partial<Tokens>,
fontSizesTokens: newTokens.globals.font.sizes,
spacingsTokens: newTokens.globals.spacings,
theme: newTheme,
baseTheme: state.baseTheme,
themeTokens: newTokens.globals,
isDarkMode: newIsDarkMode,
isDarkMode: newTheme === 'dark' || newTheme === 'dsfr-dark',
};
});
},
}),
{
name: 'cunningham-theme',
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
storage: safeLocalStorage as any,
partialize: (state) => ({ isDarkMode: state.isDarkMode }),
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('[useCunninghamTheme] Rehydration error:', error);
return;
}
if (state) {
state.isDarkMode = getIsDarkModeFromPreferences();
}
},
},
),
);
@@ -1,19 +1,14 @@
import { useCallback } from 'react';
import { baseApiUrl, fetchAPI, getCSRFToken } from '@/api';
import { useConfig } from '@/core';
import { fetchAPI } from '@/api';
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,
@@ -52,71 +47,6 @@ 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,
@@ -124,25 +54,8 @@ 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,
@@ -170,7 +83,7 @@ export const useUploadFile = (conversationId: string) => {
return `/media-key/${attachment.key}`;
},
[createConversationAttachment, conversationId, conf],
[createConversationAttachment, conversationId],
);
return {
@@ -232,21 +232,13 @@ export const ActivationPage = () => {
width: 100%;
height: 40px;
padding: 6px 8px;
border: 1px solid ${
error
? 'var(--c--contextuals--border--semantic--error--primary)'
: 'var(--c--contextuals--border--semantic--neutral--tertiary)'
};
border: 1px solid ${error ? 'var(--c--theme--colors--danger-600)' : 'var(--c--theme--colors--greyscale-150)'};
border-radius: 4px;
font-size: 14px;
fontSize: 14px;
outline: none;
&:focus {
border: 1px solid ${
error
? 'var(--c--contextuals--border--semantic--error--primary)'
: 'var(--c--contextuals--border--semantic--neutral--tertiary)'
};
border: 1px solid ${error ? 'var(--c--theme--colors--danger-600)' : 'var(--c--theme--colors--greyscale-150)'};
box-shadow: none;
}
}
@@ -266,8 +258,8 @@ export const ActivationPage = () => {
{error && (
<Text
$size="xs"
$theme="error"
$variation="tertiary"
$theme="danger"
$variation="600"
$margin={{ top: '4px' }}
>
{error}
@@ -1,9 +1,6 @@
import { UseChatOptions, useChat as useAiSdkChat } from '@ai-sdk/react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { fetchAPI } from '@/api';
import { KEY_LIST_CONVERSATION } from '@/features/chat/api/useConversations';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
@@ -39,46 +36,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
return fetchAPI(url, init);
};
interface ConversationMetadataEvent {
type: 'conversation_metadata';
conversationId: string;
title: string;
}
// Type guard to check if an item is a ConversationMetadataEvent
function isConversationMetadataEvent(
item: unknown,
): item is ConversationMetadataEvent {
return (
typeof item === 'object' &&
item !== null &&
'type' in item &&
item.type === 'conversation_metadata' &&
'conversationId' in item &&
typeof item.conversationId === 'string' &&
'title' in item &&
typeof item.title === 'string'
);
}
export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
const queryClient = useQueryClient();
const result = useAiSdkChat({
return useAiSdkChat({
...options,
maxSteps: 3,
fetch: fetchAPIAdapter,
});
useEffect(() => {
if (result.data && Array.isArray(result.data)) {
for (const item of result.data) {
if (isConversationMetadataEvent(item)) {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
}
}
}
}, [result.data, queryClient]);
return result;
}
@@ -1,62 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_CONVERSATION } from './useConversations';
interface RenameConversationProps {
conversationId: string;
title: string;
}
export const renameConversation = async ({
conversationId,
title,
}: RenameConversationProps): Promise<void> => {
const response = await fetchAPI(`chats/${conversationId}/`, {
method: 'PUT',
body: JSON.stringify({
title,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to rename the conversation',
await errorCauses(response),
);
}
};
type UseRenameConversationOptions = UseMutationOptions<
void,
APIError,
RenameConversationProps
>;
export const useRenameConversation = (
options?: UseRenameConversationOptions,
) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, RenameConversationProps>({
mutationFn: renameConversation,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};
@@ -1,5 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
@@ -54,7 +54,7 @@ export const AttachmentList = ({
$align={isReadOnly ? 'left' : 'center'}
>
<Box
$background="var(--c--contextuals--background--semantic--neutral--tertiary)"
$background="var(--c--theme--colors--greyscale-050)"
$width="200px"
$direction="row"
$gap="8px"
@@ -66,7 +66,7 @@ export const AttachmentList = ({
>
{/* Extension du fichier */}
<Box
$background="var(--c--contextuals--background--palette--gray--primary)"
$background="var(--c--theme--colors--greyscale-200)"
$width="22px"
$height="22px"
$direction="row"
@@ -74,11 +74,11 @@ export const AttachmentList = ({
$justify="center"
$css={`
flex-shrink: 0;
border-radius: 8px;
border-radius: 4px;
`}
>
<Text
$color="var(--c--contextuals--content--semantic--overlay--primary)"
$color="var(--c--globals--colors--gray-700)"
$weight="500"
$css={`
font-size: 7px;
@@ -90,7 +90,7 @@ export const AttachmentList = ({
<Text
$size="xs"
$variation="500"
$color="var(--c--contextuals--content--semantic--neutral--primary)"
$color="var(--c--globals--colors--gray-850)"
$css={`
overflow: hidden;
text-overflow: ellipsis;
@@ -105,11 +105,8 @@ export const AttachmentList = ({
{name}
</Text>
{!isReadOnly && onRemove && (
<Button
color="neutral"
variant="tertiary"
size="small"
className="c__button--without-padding"
<Box
role="button"
tabIndex={0}
aria-label={t('Remove attachment')}
onClick={removeAttachment}
@@ -119,9 +116,22 @@ export const AttachmentList = ({
removeAttachment();
}
}}
$css={css`
display: block;
padding: 4px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #e1e3e7 !important;
}
&:focus-visible {
outline: 2px solid var(--c--globals--colors--brand-550);
outline-offset: 2px;
}
`}
>
<Icon iconName="close" $size="18px" />
</Button>
<Icon iconName="close" $theme="greyscale" $size="18px" />
</Box>
)}
</Box>
</Box>
@@ -1,13 +1,23 @@
import { Message, SourceUIPart } from '@ai-sdk/ui-utils';
import { Modal, ModalSize } from '@openfun/cunningham-react';
import {
Message,
ReasoningUIPart,
SourceUIPart,
ToolInvocationUIPart,
} from '@ai-sdk/ui-utils';
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import rehypePrettyCode from 'rehype-pretty-code';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Box, Loader, Text } from '@/components';
import { Box, Icon, Loader, Text } from '@/components';
import { useUploadFile } from '@/features/attachments/hooks/useUploadFile';
import { useChat } from '@/features/chat/api/useChat';
import { getConversation } from '@/features/chat/api/useConversation';
@@ -16,9 +26,13 @@ import {
LLMModel,
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { ChatError } from '@/features/chat/components/ChatError';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import { InputChat } from '@/features/chat/components/InputChat';
import { MessageItem } from '@/features/chat/components/MessageItem';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem';
import { useClipboard } from '@/hook';
import { useResponsiveStore } from '@/stores';
@@ -280,21 +294,21 @@ export const Chat = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages]);
const openSources = useCallback((messageId: string) => {
// Source-parts guard is handled at the call site (MessageItem only shows the button when sourceParts.length > 0),
// so we just toggle it here.
setIsSourceOpen((prev) => (prev === messageId ? null : messageId));
}, []);
// Memoize the last assistant message index to avoid recalculating in render
const lastAssistantMessageIndex = useMemo(() => {
return messages.findLastIndex((msg) => msg.role === 'assistant');
}, [messages]);
// Memoize whether this is the first conversation (2 or fewer messages)
const isFirstConversationMessage = useMemo(() => {
return messages.length <= 2;
}, [messages.length]);
const openSources = (messageId: string) => {
if (isSourceOpen === messageId) {
setIsSourceOpen(null);
return;
}
const message = messages.find((msg) => msg.id === messageId);
if (message?.parts) {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
if (sourceParts.length > 0) {
setIsSourceOpen(messageId);
}
}
};
// Calculer la hauteur pour le message de streaming
const calculateStreamingHeight = useCallback(() => {
@@ -633,26 +647,317 @@ export const Chat = ({
>
{messages.length > 0 && (
<Box>
{messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
isLastMessage={index === messages.length - 1}
isLastAssistantMessage={
message.role === 'assistant' &&
index === lastAssistantMessageIndex
}
isFirstConversationMessage={isFirstConversationMessage}
streamingMessageHeight={streamingMessageHeight}
status={status}
conversationId={conversationId}
isSourceOpen={isSourceOpen}
isMobile={isMobile}
onCopyToClipboard={copyToClipboard}
onOpenSources={openSources}
getMetadata={getMetadata}
/>
))}
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastAssistantMessageInConversation =
message.role === 'assistant' &&
index ===
messages.findLastIndex((msg) => msg.role === 'assistant');
const isFirstConversationMessage = messages.length <= 2;
const shouldApplyStreamingHeight =
isLastAssistantMessageInConversation &&
isLastMessage &&
streamingMessageHeight &&
!isFirstConversationMessage;
const isCurrentlyStreaming =
isLastAssistantMessageInConversation &&
(status === 'streaming' || status === 'submitted');
return (
<Box
key={message.id}
data-message-id={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessageInConversation ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
className={`chatMessage ${message.role === 'user' ? 'chatMessage--user' : 'chatMessage--assistant'}`}
style={
shouldApplyStreamingHeight
? { minHeight: `${streamingMessageHeight}px` }
: undefined
}
>
{/* Message content */}
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
[
rehypePrettyCode,
{
theme: 'github-dark-dimmed',
},
],
rehypeKatex,
]}
components={{
// Custom components for Markdown rendering
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
</MarkdownHooks>
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessageInConversation &&
status === 'streaming' &&
message.parts?.some(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !==
'document_parsing',
) && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{(() => {
const toolInvocation = message.parts?.find(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !==
'document_parsing',
);
if (
toolInvocation?.type ===
'tool-invocation' &&
toolInvocation.toolInvocation.toolName ===
'summarize'
) {
return t('Summarizing...');
}
return t('Search...');
})()}
</Text>
</Box>
)}
{message.parts
?.filter((part) => part.type === 'tool-invocation')
.map(
(part: ToolInvocationUIPart, partIndex: number) =>
part.type === 'tool-invocation' &&
isCurrentlyStreaming &&
isLastAssistantMessageInConversation ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(
isLastAssistantMessageInConversation &&
status === 'streaming'
) && (
<Box
$css="font-size: 12px;"
$direction="row"
$align="center"
className="clr-content-semantic-neutral-secondary"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$theme="neutral"
$variation="secondary"
$direction="row"
$align="center"
$gap="4px"
className="c__button c__button--brand c__button--brand--tertiary c__button--nano clr-content-semantic-neutral-secondary"
onClick={() => copyToClipboard(message.content)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyToClipboard(message.content);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$variation="550"
$size="16px"
/>
{!isMobile && (
<Text $theme="neutral" $variation="secondary">
{t('Copy')}
</Text>
)}
</Box>
{message.parts?.some(
(part) => part.type === 'source',
) &&
(() => {
const sourceCount =
message.parts?.filter(
(part) => part.type === 'source',
).length || 0;
return (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button c__button--brand c__button--brand--tertiary c__button--nano t ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => openSources(message.id)}
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
openSources(message.id);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceCount}{' '}
{sourceCount !== 1
? t('sources')
: t('source')}
</Text>
</Box>
);
})()}
</Box>
<Box $direction="row" $gap="4px">
{/* We should display the button, but disabled if no trace linked */}
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{message.parts &&
isSourceOpen === message.id &&
(() => {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart =>
part.type === 'source',
);
return (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList
parts={sourceParts}
getMetadata={getMetadata}
/>
</Box>
);
})()}
</Box>
</Box>
</Box>
);
})}
</Box>
)}
{(status !== 'ready' && status !== 'streaming' && status !== 'error') ||
@@ -71,12 +71,10 @@ export const CodeBlock = ({ children, ...props }: CodeBlockProps) => {
return (
<>
<figure data-rehype-pretty-code-figure="">
<CopyCodeButton onCopy={handleCopy} />
<Box ref={preRef} $position="relative" as="pre" {...props}>
{children}
</Box>
</figure>
<CopyCodeButton onCopy={handleCopy} />
<Box ref={preRef} $position="relative" as="pre" {...props}>
{children}
</Box>
</>
);
};
@@ -22,7 +22,7 @@ const ThumbUp = () => (
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.48 3.31533C11.4515 1.69679 13.739 1.54257 14.9186 3.01635C15.4744 3.71116 15.654 4.63525 15.3984 5.4876L14.4273 8.723H18.8941C20.9405 8.72323 22.427 10.6688 21.8891 12.6431L19.8454 20.1363C19.4771 21.4869 18.2502 22.4245 16.8505 22.4246H6.73028C6.72367 22.4247 6.71705 22.4256 6.71041 22.4256C6.70377 22.4256 6.69716 22.4247 6.69055 22.4246H3.60568C2.71921 22.4244 2.00016 21.7055 2 20.8189V10.3287C2.00022 9.44222 2.71921 8.72323 3.60568 8.723H6.56824C6.9817 8.723 7.36522 8.5056 7.57807 8.15119L10.48 3.31533ZM13.4143 4.22061C13.0737 3.79509 12.4124 3.83894 12.1317 4.30633L9.23079 9.14219C8.86738 9.74788 8.31458 10.2004 7.67424 10.4447V20.498H16.8505C17.3813 20.4979 17.846 20.1415 17.9857 19.6293L20.0294 12.1361C20.2331 11.3875 19.6701 10.6498 18.8941 10.6496H13.9966C12.9211 10.6496 12.1498 9.61217 12.4589 8.58188L13.5523 4.9346C13.6259 4.68866 13.5747 4.42117 13.4143 4.22061ZM3.92765 10.6496V20.498H5.74659V10.6496H3.92765Z"
fill="var(--c--contextuals--content--semantic--neutral--secondary)"
fill="#1C1B1F"
/>
</svg>
);
@@ -39,7 +39,7 @@ const ThumbDown = () => (
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.8504 3C18.2501 3.00011 19.477 3.8724 19.8453 5.12866L21.889 12.0991C22.4272 13.9358 20.9405 15.7465 18.894 15.7467H14.4283L15.3984 18.7554C15.6541 19.5485 15.4745 20.4078 14.9185 21.0543C13.7389 22.4257 11.4514 22.2821 10.4799 20.7761L7.57803 16.2777C7.36518 15.948 6.98164 15.7467 6.56822 15.7467H3.60567C2.71925 15.7465 2.00023 15.0777 2 14.253V4.49366C2.00023 3.66904 2.71922 3.00021 3.60567 3H16.8504ZM7.6742 14.1432C8.31475 14.3704 8.86725 14.7922 9.23074 15.3558L12.1316 19.8543C12.4123 20.2894 13.0736 20.3302 13.4143 19.934L13.4697 19.8621C13.5862 19.6864 13.6168 19.4713 13.5523 19.2708L12.4588 15.877C12.1497 14.9185 12.9211 13.9535 13.9965 13.9535H18.894C19.6701 13.9533 20.2334 13.2672 20.0293 12.5707L17.9856 5.6003C17.8459 5.12383 17.3812 4.79329 16.8504 4.79317H7.6742V14.1432ZM3.92764 13.9535H5.74656V4.79317H3.92764V13.9535Z"
fill="var(--c--contextuals--content--semantic--neutral--secondary)"
fill="#1C1B1F"
/>
</svg>
);
@@ -56,7 +56,7 @@ const ThumbDownFilled = () => (
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.8504 3C18.2501 3.00011 19.477 3.8724 19.8453 5.12866L21.889 12.0991C22.4272 13.9358 20.9405 15.7465 18.894 15.7467H14.4283L15.3984 18.7554C15.6541 19.5485 15.4745 20.4078 14.9185 21.0543C13.7389 22.4257 11.4514 22.2821 10.4799 20.7761L7.57803 16.2777C7.36518 15.948 6.98163 15.7467 6.56822 15.7467H3.60567C2.71925 15.7465 2.00023 15.0777 2 14.253V4.49366C2.00023 3.66904 2.71922 3.00021 3.60567 3H16.8504ZM5.92764 13.4535C5.92764 13.7297 6.1515 13.9535 6.42764 13.9535H7.07803C7.35417 13.9535 7.57803 13.7297 7.57803 13.4535V5.29317C7.57803 5.01703 7.35417 4.79317 7.07803 4.79317H6.42764C6.1515 4.79317 5.92764 5.01703 5.92764 5.29317V13.4535Z"
fill="var(--c--contextuals--content--semantic--brand--tertiary)"
fill="#3E5DE7"
/>
</svg>
);
@@ -73,7 +73,7 @@ const ThumbUpFilled = () => (
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.48 3.31533C11.4515 1.69679 13.739 1.54257 14.9186 3.01635C15.4744 3.71116 15.654 4.63525 15.3984 5.4876L14.4273 8.723H18.8941C20.9405 8.72323 22.427 10.6688 21.8891 12.6431L19.8454 20.1363C19.4771 21.4869 18.2502 22.4245 16.8505 22.4246H6.73028C6.72367 22.4247 6.71705 22.4256 6.71041 22.4256C6.70378 22.4256 6.69716 22.4247 6.69055 22.4246H3.60568C2.71921 22.4244 2.00016 21.7055 2 20.8189V10.3287C2.00022 9.44222 2.71921 8.72323 3.60568 8.723H6.56824C6.9817 8.723 7.36522 8.5056 7.57807 8.15119L10.48 3.31533ZM6.42765 10.6496C6.15151 10.6496 5.92765 10.8735 5.92765 11.1496V19.998C5.92765 20.2741 6.15151 20.498 6.42765 20.498H7C7.27614 20.498 7.5 20.2741 7.5 19.998V11.1496C7.5 10.8735 7.27614 10.6496 7 10.6496H6.42765Z"
fill="var(--c--contextuals--content--semantic--brand--tertiary)"
fill="#3E5DE7"
/>
</svg>
);
@@ -1,28 +1,20 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Button } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { Box, Icon, 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;
@@ -42,76 +34,6 @@ 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--contextuals--border--surface--primary);
position: relative;
background: var(--c--contextuals--background--surface--primary);
transition: all 0.2s ease;
`;
const FILE_DROP_CSS = `
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
width: 100%;
height: 100%;
outline: 2px solid var(--c--contextuals--border--semantic--brand--secondary);
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 0.5rem 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,
@@ -133,13 +55,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 => {
@@ -164,6 +87,13 @@ 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',
@@ -201,6 +131,32 @@ 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();
@@ -213,201 +169,154 @@ export const InputChat = ({
}
}, [status, input]);
const validateAndAddFiles = useCallback(
(filesToAdd: File[]) => {
const acceptedFiles: File[] = [];
const rejectedFiles: File[] = [];
useEffect(() => {
if (!fileUploadEnabled) {
return;
}
filesToAdd.forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file);
}
});
if (rejectedFiles.length > 0) {
showToastError();
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
}
};
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// Only hide when leaving the window completely
if (!e.relatedTarget) {
setIsDragActive(false);
}
};
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
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 dt.files;
return false;
});
}
},
[isFileAccepted, showToastError, setFiles],
);
};
const { isDragActive } = useFileDragDrop({
enabled: fileUploadEnabled,
isFileAccepted,
onFilesAccepted: validateAndAddFiles,
onFilesRejected: () => showToastError(),
});
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
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 handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!fileUploadEnabled) {
return;
}
const clipboardData = e.clipboardData;
if (!clipboardData) {
return;
}
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
// Due to browser limitations, only one file can be pasted at a time
// Check files first (for files from file system)
let file: File | null = null;
if (clipboardData.files && clipboardData.files.length > 0) {
file = clipboardData.files[0];
} else if (clipboardData.items) {
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.kind === 'file') {
file = item.getAsFile();
break;
Array.from(droppedFiles).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
}
if (file) {
e.preventDefault();
validateAndAddFiles([file]);
}
},
[fileUploadEnabled, validateAndAddFiles],
);
const handleAttachClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleWebSearchToggle = useCallback(() => {
onToggleWebSearch?.();
textareaRef.current?.focus();
}, [onToggleWebSearch]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList) {
return;
}
validateAndAddFiles(Array.from(fileList));
e.target.value = '';
},
[validateAndAddFiles],
);
const handleAttachmentRemove = useCallback(
(index: number) => {
if (!files) {
return;
}
const dt = new DataTransfer();
Array.from(files).forEach((f, i) => {
if (i !== index) {
dt.items.add(f);
if (acceptedFiles.length === 0) {
return;
}
});
setFiles(dt.files.length > 0 ? dt.files : null);
},
[files, setFiles],
);
const fileUrlMap = useFileUrls(files);
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
acceptedFiles.forEach((f) => {
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;
});
}
};
const attachments = useMemo(() => {
if (!files) {
return [];
}
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
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 () => {
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 isInputDisabled = status !== 'ready' || isUploadingFiles;
return (
<>
{isDragActive && <Box $position="fixed" $css={DRAG_FADE_CSS} />}
<Box $css={containerCss}>
{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;
`}
>
{/* Bouton de scroll vers le bas */}
{messagesLength > 1 && containerRef && onScrollToBottom && (
<Box $css={SCROLL_DOWN_WRAPPER_CSS}>
<Box
$css={`
position: relative;
height: 0;
width: 100%;
margin: auto;
max-width: 750px;
`}
>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
@@ -415,16 +324,48 @@ export const InputChat = ({
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && <WelcomeMessage />}
{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>
)}
<form onSubmit={handleSubmit} style={STYLES.form}>
<Box $padding={formPadding}>
<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' : ''}` }}>
<Box
$flex={1}
$radius="12px"
$position="relative"
$background="white"
$css={INPUT_BOX_CSS}
$css={`
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
border-radius: 12px;
border: 1px solid var(--c--contextuals--border--surface--primary);
position: relative;
background: var(--c--contextuals--background--surface--primary,);
transition: all 0.2s ease;
`}
>
{isDragActive && (
<Box
@@ -433,7 +374,16 @@ export const InputChat = ({
$direction="row"
$justify="center"
$gap="1rem"
$css={FILE_DROP_CSS}
$css={`
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
width: 100%;
height: 100%;
outline: 2px solid var(--c--contextuals--border--semantic--brand--secondary);
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
`}
>
<FilesIcon />
<Box>
@@ -457,15 +407,88 @@ export const InputChat = ({
aria-label={t('Enter your message or a question')}
value={input ?? ''}
name="inputchat-textarea"
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onPaste={handlePaste}
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();
}}
disabled={isInputDisabled}
rows={1}
style={textareaStyle}
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();
}
}}
/>
{!input && <SuggestionCarousel messagesLength={messagesLength} />}
{!input && (
<Box
$css={`
position: absolute;
top: 1rem;
left: 1.5rem;
right: 1.5rem;
height: 1.5rem;
pointer-events: none;
color: var(--c--globals--colors--gray-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
accept={conf?.chat_upload_accept}
@@ -473,37 +496,231 @@ export const InputChat = ({
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
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 = '';
}}
/>
{/*Aperçu des fichiers*/}
{files && files.length > 0 && (
<Box
$margin={STYLES.attachmentMargin}
$padding={STYLES.attachmentPadding}
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
$padding={{ horizontal: 'base' }}
>
<AttachmentList
attachments={attachments}
onRemove={handleAttachmentRemove}
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);
}}
isReadOnly={false}
/>
</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
$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="nano"
type="button"
variant="tertiary"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={() => fileInputRef.current?.click()}
aria-label={t('Add attach file')}
icon={
<Icon
iconName="attach_file"
$theme="neutral"
$variation="tertiary"
$size={`${isMobile ? '24px' : '16px'}`}
/>
}
>
{!isMobile && (
<Text $variation="secondary" $theme="neutral">
{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--contextuals--background--semantic--brand--secondary) !important;
}
`
: ''
}
`}
>
<Button
size="nano"
type="button"
className="research-web-button"
variant="tertiary"
disabled={!webSearchEnabled || isUploadingFiles}
onClick={() => {
onToggleWebSearch();
textareaRef.current?.focus();
}}
aria-label={t('Research on the web')}
icon={
<Icon
$theme={forceWebSearch ? 'brand' : 'neutral'}
$variation="tertiary"
iconName="language"
/>
}
>
{!isMobile && (
<Text
$theme={forceWebSearch ? 'brand' : 'neutral'}
$variation="tertiary"
>
{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={forceWebSearch ? 'brand' : 'gray'}
$variation="secondary"
$weight="500"
$css={`
display: flex;
align-items: center;
`}
>
{t('Web')}
</Text>
<Icon
iconName="close"
$variation="secondary"
$theme="brand"
$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>
</Box>
</Box>
</form>
@@ -1,231 +0,0 @@
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--contextuals--background--semantic--brand--secondary) !important;
color: var(--c--contextuals--content--semantic--brand--secondary) !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"
$align="end"
$padding={STYLES.horizontalPadding}
$gap="xs"
>
{/* Attach file button */}
<Button
size="nano"
type="button"
color="neutral"
className="c__button--neutral"
variant="tertiary"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={onAttachClick}
aria-label={t('Add attach file')}
icon={<Icon iconName="attach_file" $size={attachIconSize} />}
>
{!isMobile && <Text $weight="500">{t('Attach file')}</Text>}
</Button>
{/* Web search toggle button */}
{onWebSearchToggle && (
<Box $margin={STYLES.webSearchMargin} $css={webSearchWrapperCss}>
<Button
size="nano"
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" $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="brand"
$variation="secondary"
$weight="500"
$css={MOBILE_TEXT_CSS}
>
{t('Web')}
</Text>
<Icon
iconName="close"
$theme="brand"
$variation="secondary"
$size="md"
$css={CLOSE_ICON_CSS}
/>
</Box>
)}
</Button>
</Box>
)}
</Box>
{/* Right side: Model selector + Send */}
<Box
$direction="row"
$align="center"
$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';
@@ -1,89 +0,0 @@
// Memoized components for a single completed markdown blocks - only re-renders when content changes
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import React, { use } from 'react';
import { Components, MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { Text } from '@/components';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
// Memoized markdown plugins - created once at module level
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const REMARK_PLUGINS: any[] = [remarkGfm, remarkMath];
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
import { getHighlighter } from '../utils/shiki';
const highlighterPromise = getHighlighter();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rehypePluginsPromise: Promise<any[]> = highlighterPromise.then(
(highlighter) => [
rehypeKatex,
[
rehypeShikiFromHighlighter,
highlighter,
{
theme: 'github-dark-dimmed',
fallbackLanguage: 'plaintext',
},
],
],
);
// Memoized markdown components - created once at module level
const MARKDOWN_COMPONENTS: Components = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
pre: ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
node,
children,
...props
}) => <CodeBlock {...props}>{children}</CodeBlock>,
};
export const CompletedMarkdownBlock = React.memo(
({ content }: { content: string }) => {
const rehypePlugins = use(rehypePluginsPromise);
return (
<MarkdownHooks
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={rehypePlugins}
components={MARKDOWN_COMPONENTS}
>
{content}
</MarkdownHooks>
);
},
(prev, next) => prev.content === next.content,
);
CompletedMarkdownBlock.displayName = 'CompletedMarkdownBlock';
export const RawTextBlock = ({ content }: { content: string }) => (
<Text
as="div"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{content}
</Text>
);
@@ -1,554 +0,0 @@
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Loader, Text } from '@/components';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import {
CompletedMarkdownBlock,
RawTextBlock,
} from '@/features/chat/components/MessageBlock';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem';
// Memoized blocks list to prevent parent re-renders from causing block remounts
const BlocksList = React.memo(
({ blocks, pending }: { blocks: string[]; pending: string }) => (
<div>
{/* key={index} is safe here: blocks are append-only during streaming
and a completed block's content never changes once finalized. */}
{blocks.map((block, index) => (
<CompletedMarkdownBlock key={index} content={block} />
))}
{pending && <RawTextBlock content={pending} />}
</div>
),
(prev, next) => {
const lengthChanged = prev.blocks.length !== next.blocks.length;
const pendingChanged = prev.pending !== next.pending;
let blocksChanged = false;
for (let i = 0; i < Math.min(prev.blocks.length, next.blocks.length); i++) {
if (prev.blocks[i] !== next.blocks[i]) {
blocksChanged = true;
}
}
if (lengthChanged || pendingChanged || blocksChanged) {
return false; // needs re-render
}
return true;
},
);
BlocksList.displayName = 'BlocksList';
export interface StreamingContent {
completedBlocks: string[];
pending: string;
}
/**
* Splits content into blocks by double newlines, respecting code fences.
* Code fences may contain double newlines, so we merge blocks until fences are balanced.
*/
export const splitIntoBlocks = (content: string): string[] => {
if (!content) {
return [];
}
const rawBlocks = content.split('\n\n');
const blocks: string[] = [];
let currentBlock = '';
let fenceCount = 0;
for (const rawBlock of rawBlocks) {
const fences = (rawBlock.match(/```/g) || []).length;
currentBlock = currentBlock ? currentBlock + '\n\n' + rawBlock : rawBlock;
fenceCount += fences;
// Balanced fences = complete block
if (fenceCount % 2 === 0) {
if (currentBlock.trim()) {
blocks.push(currentBlock);
}
currentBlock = '';
fenceCount = 0;
}
}
if (currentBlock.trim()) {
blocks.push(currentBlock);
}
return blocks;
};
/**
* Splits streaming content into completed blocks (safe and ready to render as markdown)
* + a pending content (still being streamed, rendered as raw text).
*
* A block is considered completed when followed by a double newline.
* Each block is returned separately to enable independent memoization.
* NB: it respects code fences (``` ... ```) that may contain double newlines.
*/
export const splitStreamingContent = (content: string): StreamingContent => {
if (!content) {
return { completedBlocks: [], pending: '' };
}
// Find all code fence positions
// Note: this counts all ``` occurrences including those inside inline code spans.
// In practice this is unlikely to cause issues since inline code rarely contains ```.
const fenceRegex = /```/g;
const fences: number[] = [];
let match;
while ((match = fenceRegex.exec(content)) !== null) {
fences.push(match.index);
}
// Check if we're inside an unclosed code fence
const isInsideCodeFence = fences.length % 2 === 1;
let completedContent: string;
let pendingContent: string;
if (isInsideCodeFence) {
// Find the last opening fence
const lastFenceStart = fences[fences.length - 1];
// Everything before the unclosed fence is potentially complete
const beforeFence = content.slice(0, lastFenceStart);
const fenceAndAfter = content.slice(lastFenceStart);
// Find the last complete block boundary before the fence
const lastDoubleNewline = beforeFence.lastIndexOf('\n\n');
if (lastDoubleNewline !== -1) {
completedContent = beforeFence.slice(0, lastDoubleNewline);
pendingContent = beforeFence.slice(lastDoubleNewline) + fenceAndAfter;
} else {
// No complete blocks before fence
return { completedBlocks: [], pending: content };
}
} else {
// Not inside a code fence - find the last double newline as block boundary
const lastDoubleNewline = content.lastIndexOf('\n\n');
if (lastDoubleNewline === -1) {
// No double newline yet - everything is pending
return { completedBlocks: [], pending: content };
}
// Content up to the last \n\n is complete
completedContent = content.slice(0, lastDoubleNewline);
// Content after the last \n\n is pending (may be empty if content ends with \n\n)
pendingContent = content.slice(lastDoubleNewline + 2);
}
const completedBlocks = splitIntoBlocks(completedContent);
return { completedBlocks, pending: pendingContent };
};
interface SourceMetadata {
title: string | null;
favicon: string | null;
loading: boolean;
error: boolean;
}
export interface MessageItemProps {
message: Message;
isLastMessage: boolean;
isLastAssistantMessage: boolean;
isFirstConversationMessage: boolean;
streamingMessageHeight: number | null;
status: 'submitted' | 'streaming' | 'ready' | 'error';
conversationId: string | undefined;
isSourceOpen: string | null;
isMobile: boolean;
onCopyToClipboard: (content: string) => void;
onOpenSources: (messageId: string) => void;
getMetadata: (url: string) => SourceMetadata | undefined;
}
const MessageItemComponent: React.FC<MessageItemProps> = ({
message,
isLastMessage,
isLastAssistantMessage,
isFirstConversationMessage,
streamingMessageHeight,
status,
conversationId,
isSourceOpen,
isMobile,
onCopyToClipboard,
onOpenSources,
getMetadata,
}) => {
const { t } = useTranslation();
const shouldApplyStreamingHeight =
isLastAssistantMessage &&
isLastMessage &&
streamingMessageHeight &&
!isFirstConversationMessage;
const isCurrentlyStreaming =
isLastAssistantMessage &&
(status === 'streaming' || status === 'submitted');
const sourceParts = React.useMemo(() => {
if (!message.parts) {
return [];
}
return message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
}, [message.parts]);
const toolInvocationParts = React.useMemo(() => {
if (!message.parts) {
return [];
}
return message.parts.filter(
(part): part is ToolInvocationUIPart => part.type === 'tool-invocation',
);
}, [message.parts]);
const hasNonDocumentParsingTool = React.useMemo(() => {
return toolInvocationParts.some(
(part) => part.toolInvocation.toolName !== 'document_parsing',
);
}, [toolInvocationParts]);
const activeToolInvocation = React.useMemo(() => {
const tool = toolInvocationParts.find(
(part) => part.toolInvocation.toolName !== 'document_parsing',
);
return tool?.toolInvocation;
}, [toolInvocationParts]);
const activeToolInvocationDisplayName = React.useMemo(() => {
if (!activeToolInvocation) {
return '';
}
if (activeToolInvocation.toolName === 'summarize') {
return t('Summarizing...');
}
if (activeToolInvocation.toolName === 'translate') {
return t('Translation in progress...');
}
return t('Search...');
}, [activeToolInvocation, t]);
// Memoize the streaming content split to avoid recreating components in JSX
const { completedBlocks, pending } = React.useMemo(() => {
// When not streaming, everything is completed as a single block array
if (!isCurrentlyStreaming) {
return {
completedBlocks: splitIntoBlocks(message.content),
pending: '',
};
}
return splitStreamingContent(message.content);
}, [isCurrentlyStreaming, message.content]);
const handleCopy = React.useCallback(() => {
onCopyToClipboard(message.content);
}, [onCopyToClipboard, message.content]);
const handleCopyKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopyToClipboard(message.content);
}
},
[onCopyToClipboard, message.content],
);
const handleOpenSources = React.useCallback(() => {
onOpenSources(message.id);
}, [onOpenSources, message.id]);
const handleOpenSourcesKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenSources(message.id);
}
},
[onOpenSources, message.id],
);
return (
<Box
data-message-id={message.id}
data-testid={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessage ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
$radius="8px"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
$maxWidth="100%"
$padding={`${message.role === 'user' ? '12px' : '0'}`}
$margin={{ vertical: 'base' }}
$background={`${message.role === 'user' ? '#EEF1F4' : 'white'}`}
$css={`
display: inline-block;
float: right;
${shouldApplyStreamingHeight ? `min-height: ${streamingMessageHeight}px;` : ''}`}
>
{/* Message content */}
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
// Render completed blocks as markdown, pending block as plain text
<BlocksList blocks={completedBlocks} pending={pending} />
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessage &&
status === 'streaming' &&
hasNonDocumentParsingTool && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{activeToolInvocationDisplayName}
</Text>
</Box>
)}
{toolInvocationParts.map((part, partIndex) =>
isCurrentlyStreaming && isLastAssistantMessage ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(isLastAssistantMessage && status === 'streaming') && (
<Box
$css="color: #222631; font-size: 12px;"
$direction="row"
$align="center"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$direction="row"
$align="center"
$gap="4px"
className="c__button--neutral action-chat-button"
onClick={handleCopy}
onKeyDown={handleCopyKeyDown}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
{!isMobile && (
<Text $theme="greyscale" $variation="550">
{t('Copy')}
</Text>
)}
</Box>
{sourceParts.length > 0 && (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={handleOpenSources}
onKeyDown={handleOpenSourcesKeyDown}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceParts.length}{' '}
{sourceParts.length !== 1 ? t('sources') : t('source')}
</Text>
</Box>
)}
</Box>
<Box $direction="row" $gap="4px">
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{isSourceOpen === message.id && sourceParts.length > 0 && (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList parts={sourceParts} getMetadata={getMetadata} />
</Box>
)}
</Box>
</Box>
</Box>
);
};
MessageItemComponent.displayName = 'MessageItem';
// Custom comparison function for React.memo
// Only re-render when props that affect rendering change
const arePropsEqual = (
prevProps: MessageItemProps,
nextProps: MessageItemProps,
): boolean => {
// Always re-render if message content changed
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.message.content !== nextProps.message.content) {
return false;
}
if (prevProps.message.role !== nextProps.message.role) {
return false;
}
// Check parts changes (for streaming tool invocations and sources)
const prevPartsLength = prevProps.message.parts?.length ?? 0;
const nextPartsLength = nextProps.message.parts?.length ?? 0;
if (prevPartsLength !== nextPartsLength) {
return false;
}
// Check attachments
const prevAttachmentsLength =
prevProps.message.experimental_attachments?.length ?? 0;
const nextAttachmentsLength =
nextProps.message.experimental_attachments?.length ?? 0;
if (prevAttachmentsLength !== nextAttachmentsLength) {
return false;
}
// Check rendering flags
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
return false;
}
if (prevProps.isLastAssistantMessage !== nextProps.isLastAssistantMessage) {
return false;
}
if (
prevProps.isFirstConversationMessage !==
nextProps.isFirstConversationMessage
) {
return false;
}
if (prevProps.streamingMessageHeight !== nextProps.streamingMessageHeight) {
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isSourceOpen !== nextProps.isSourceOpen) {
return false;
}
if (prevProps.isMobile !== nextProps.isMobile) {
return false;
}
if (prevProps.conversationId !== nextProps.conversationId) {
return false;
}
return true;
};
export const MessageItem = React.memo(MessageItemComponent, arePropsEqual);

Some files were not shown because too many files have changed in this diff Show More