Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54d72f8f57 | |||
| dfb7fb81a6 | |||
| 8c62f5d933 | |||
| 88bdcc2e60 | |||
| 1c573ad3a7 | |||
| 3106d5f25f | |||
| 23fa1d6b9e | |||
| 8ed72fb305 | |||
| c231e69871 | |||
| fb297b97e6 | |||
| 7858476b84 | |||
| c02254ec93 | |||
| 853305ae74 | |||
| ab2ad0348b | |||
| d752334b5e | |||
| 30f825c337 | |||
| f52a27f218 | |||
| 3e467cacf2 | |||
| 120b204729 | |||
| d9078e75e5 | |||
| 09b003856b | |||
| 0b5317a773 | |||
| abf61a9556 | |||
| 3e8c5c77d5 | |||
| ddfc86a88f | |||
| e7d76e4477 | |||
| fd3399dd66 |
@@ -8,6 +8,7 @@ skip =
|
||||
**/node_modules/**,
|
||||
**/e2e/report/**,
|
||||
*.tsbuildinfo,
|
||||
**/uv.lock,
|
||||
check-filenames = true
|
||||
ignore-words-list =
|
||||
afterAll,
|
||||
|
||||
@@ -85,20 +85,24 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -181,25 +185,28 @@ jobs:
|
||||
mc version enable conversations/conversations-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
security-trivy-critical:
|
||||
permissions:
|
||||
@@ -210,6 +217,8 @@ jobs:
|
||||
- name: Run Trivy analysis for critical vulnerabilities
|
||||
# We use main branch while we might still iterate on the action
|
||||
uses: numerique-gouv/action-trivy-cache/security-trivy-critical@main
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
|
||||
security-trivy:
|
||||
permissions:
|
||||
@@ -219,3 +228,5 @@ jobs:
|
||||
- name: Run Trivy analysis for vulnerabilities
|
||||
# We use main branch while we might still iterate on the action
|
||||
uses: numerique-gouv/action-trivy-cache/security-trivy@main
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
|
||||
@@ -44,6 +44,9 @@ env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# Configuration
|
||||
**/conversations/configuration/llm/dev.json
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
+28
-3
@@ -8,11 +8,33 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add ui kit #240
|
||||
- 🧱(files) allow to use S3 storage without external access #849
|
||||
- ✨(backend) add FindRagBackend #209
|
||||
|
||||
### Changed
|
||||
|
||||
- 🏗️(back) migrate to uv
|
||||
|
||||
## [0.0.12] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡️(front) performance improvements on chat input
|
||||
- 💄(front) i18n and standardize pdf parsing display
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) consider PDF documents as other kind of documents #234
|
||||
|
||||
## [0.0.11] - 2026-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(front) add ui-kit
|
||||
- ✨(front) add dark-mode
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
|
||||
|
||||
### Fixed
|
||||
@@ -20,6 +42,7 @@ 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
|
||||
|
||||
@@ -179,7 +202,9 @@ and this project adheres to
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.12...main
|
||||
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
|
||||
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
|
||||
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
|
||||
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
|
||||
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
|
||||
|
||||
+32
-22
@@ -3,9 +3,6 @@
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
@@ -13,21 +10,31 @@ RUN apk update && \
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||
|
||||
# Install Rust and Cargo using Alpine's package manager
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
libffi-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
rust \
|
||||
cargo
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:24 AS mail-builder
|
||||
@@ -49,14 +56,16 @@ RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Copy conversations application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
@@ -79,6 +88,8 @@ RUN apk add \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
libxml2 \
|
||||
libxslt \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
@@ -92,17 +103,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy conversations application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
@@ -119,10 +130,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall conversations and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y conversations
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -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
@@ -71,9 +71,13 @@ 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
|
||||
@@ -89,6 +93,9 @@ 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:
|
||||
@@ -177,3 +184,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
@@ -95,6 +95,9 @@ 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
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# File Upload Modes
|
||||
|
||||
This document describes the different modes for handling file uploads in the Conversations application, and how to configure and use them.
|
||||
|
||||
## Overview
|
||||
|
||||
The application supports two independent configuration points:
|
||||
|
||||
1. **`FILE_UPLOAD_MODE`**: how the frontend uploads files (frontend → storage/backend)
|
||||
2. **`FILE_TO_LLM_MODE`**: how the backend provides files to the LLM (backend → LLM)
|
||||
|
||||
Each mode has different trade-offs in terms of security, performance, and LLM accessibility. The two settings can be combined based on your network constraints.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Frontend upload mode (`FILE_UPLOAD_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL upload (backward compatible)
|
||||
FILE_UPLOAD_MODE=presigned_url
|
||||
|
||||
# Frontend uploads directly to backend
|
||||
FILE_UPLOAD_MODE=backend_to_s3
|
||||
```
|
||||
|
||||
### Backend delivery mode (`FILE_TO_LLM_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL mode (backward compatible)
|
||||
FILE_TO_LLM_MODE=presigned_url
|
||||
|
||||
# Backend provides base64-encoded data URLs
|
||||
FILE_TO_LLM_MODE=backend_base64
|
||||
|
||||
# Backend provides temporary URLs through the backend
|
||||
FILE_TO_LLM_MODE=backend_temporary_url
|
||||
```
|
||||
|
||||
Additional settings for backend temporary URL mode:
|
||||
|
||||
```bash
|
||||
# Base URL to reach backend
|
||||
FILE_BACKEND_URL="http://localhost:8071"
|
||||
|
||||
# Expiration time for temporary URLs (in seconds, default: 180 = 3 minutes)
|
||||
FILE_BACKEND_TEMPORARY_URL_EXPIRATION=180
|
||||
```
|
||||
|
||||
## Mode Details
|
||||
|
||||
### 1. Presigned URL Mode (Default)
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=presigned_url`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=presigned_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend requests a presigned URL from the backend
|
||||
- Frontend uploads the file directly to S3 using the presigned URL
|
||||
- Frontend notifies the backend when upload is complete
|
||||
- Backend initiates malware detection
|
||||
- Backend returns presigned S3 URLs to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- Files don't pass through the backend server (lower bandwidth usage)
|
||||
- Faster uploads for large files (direct to S3)
|
||||
- S3 handles the upload, no backend load
|
||||
- Backward compatible with existing frontend implementations
|
||||
|
||||
**Disadvantages:**
|
||||
- S3 bucket must be accessible from the frontend
|
||||
- Presigned URLs can be leaked if not handled carefully
|
||||
- Frontend needs to handle S3 credentials/configuration
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
- Documents: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When frontend has direct access to S3
|
||||
- When you want to minimize backend load
|
||||
- When S3 is publicly accessible or accessible via VPN
|
||||
|
||||
|
||||
### 2. Backend Base64 Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_base64`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend reads the file, encodes it as base64, and creates a data URL
|
||||
- LLM receives the file as a base64-encoded data URL
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend)
|
||||
- Files always go through the backend for validation
|
||||
- No presigned URLs to manage
|
||||
- Better control over file access
|
||||
- Data URLs work with all LLMs that support file content
|
||||
|
||||
**Disadvantages:**
|
||||
- Backend memory usage increases (entire file loaded for base64 encoding)
|
||||
- Slower for very large files (encoding overhead)
|
||||
- Increased bandwidth on backend
|
||||
- Data URLs can be very large in responses
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Base64-encoded data URLs (format: `data:image/png;base64,...`)
|
||||
- Documents: Base64-encoded data URLs (format: `data:application/pdf;base64,...`)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend
|
||||
- When you want all file uploads to go through the backend
|
||||
- When the LLM supports base64-encoded data URLs
|
||||
- For smaller files (< 50MB)
|
||||
|
||||
|
||||
### 3. Backend Temporary URL Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_temporary_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend generates a secure temporary access token stored in cache (TTL: 3 minutes by default)
|
||||
- Backend returns a temporary URL pointing to the backend's file-stream endpoint
|
||||
- LLM receives the temporary URL and accesses the file through the backend
|
||||
- Backend validates the token and streams the file content from S3 to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend or LLM directly)
|
||||
- Files always go through the backend for validation and access control
|
||||
- LLM doesn't need direct access to S3
|
||||
- Tokens expire quickly (better security than long-lived presigned URLs)
|
||||
- No large data URL strings in memory or responses
|
||||
- Lower backend memory usage than base64 mode
|
||||
- Centralized file access control through the backend
|
||||
- Good balance between security and performance
|
||||
|
||||
**Disadvantages:**
|
||||
- LLM must be able to access the backend server
|
||||
- File streaming goes through the backend (adds some latency)
|
||||
- Time-limited access (token expires)
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
- Documents: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend or LLM
|
||||
- When you want backend control over uploads and file access
|
||||
- When you want time-limited access to files with centralized control
|
||||
- When you want the LLM to access files through the backend gateway
|
||||
- For large files (backend streams directly from S3 without loading entirely into memory)
|
||||
@@ -244,9 +244,9 @@ For Mistral AI models using the Etalab platform:
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "mistral-large",
|
||||
"model_name": "mistral-large-latest",
|
||||
"human_readable_name": "Mistral Large (Etalab)",
|
||||
"hrid": "mistral-medium",
|
||||
"model_name": "mistral-medium-2508",
|
||||
"human_readable_name": "Mistral Medium (Etalab)",
|
||||
"provider_name": "mistral-etalab",
|
||||
"profile": null,
|
||||
"settings": {
|
||||
|
||||
@@ -357,6 +357,7 @@ 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
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ 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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Document parsers for RAG backends."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
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
|
||||
)
|
||||
@@ -13,7 +13,7 @@ import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.agent_rag.document_converter.parser import AlbertParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,9 +26,6 @@ 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.
|
||||
"""
|
||||
@@ -46,10 +43,9 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
}
|
||||
self._collections_endpoint = urljoin(self._base_url, "/v1/collections")
|
||||
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
|
||||
self._pdf_parser_endpoint = urljoin(self._base_url, "/v1/parse-beta")
|
||||
self._search_endpoint = urljoin(self._base_url, "/v1/search")
|
||||
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser = AlbertParser()
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -91,7 +87,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) -> None:
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Delete the current collection
|
||||
"""
|
||||
@@ -102,7 +98,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def adelete_collection(self) -> None:
|
||||
async def adelete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Asynchronously delete the current collection
|
||||
"""
|
||||
@@ -114,59 +110,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
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:
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -174,6 +118,7 @@ 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),
|
||||
@@ -188,7 +133,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) -> None:
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -196,6 +141,7 @@ 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(
|
||||
@@ -213,13 +159,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> 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.
|
||||
@@ -256,13 +203,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
),
|
||||
)
|
||||
|
||||
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
async def asearch(self, query, results_count: int = 4, **kwargs) -> 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,18 +1,19 @@
|
||||
"""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:
|
||||
class BaseRagBackend(ABC):
|
||||
"""Base class for RAG backends."""
|
||||
|
||||
def __init__(
|
||||
@@ -38,6 +39,7 @@ class BaseRagBackend:
|
||||
self.collection_id = collection_id
|
||||
self.read_only_collection_id = read_only_collection_id or []
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser: BaseParser = BaseParser()
|
||||
|
||||
def get_all_collection_ids(self) -> List[str]:
|
||||
"""
|
||||
@@ -53,13 +55,14 @@ class BaseRagBackend:
|
||||
|
||||
collection_ids = []
|
||||
if self.collection_id:
|
||||
collection_ids.append(int(self.collection_id))
|
||||
collection_ids.append(self.collection_id)
|
||||
if self.read_only_collection_id:
|
||||
collection_ids.extend(
|
||||
[int(collection_id) for collection_id in self.read_only_collection_id]
|
||||
)
|
||||
return collection_ids
|
||||
|
||||
@abstractmethod
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
Create a temporary collection for the search operation.
|
||||
@@ -74,7 +77,7 @@ class BaseRagBackend:
|
||||
"""
|
||||
return await sync_to_async(self.create_collection)(name=name, description=description)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
def parse_document(self, name: str, content_type: str, content: bytes):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
@@ -83,14 +86,15 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
return self.parser.parse_document(name, content_type, content)
|
||||
|
||||
def store_document(self, name: str, content: str) -> None:
|
||||
@abstractmethod
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -98,10 +102,11 @@ class BaseRagBackend:
|
||||
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) -> None:
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -109,50 +114,66 @@ class BaseRagBackend:
|
||||
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)
|
||||
return await sync_to_async(self.store_document)(name=name, content=content, **kwargs)
|
||||
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
def parse_and_store_document(
|
||||
self, name: str, content_type: str, content: bytes, **kwargs
|
||||
) -> 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 (BytesIO): The content of the document as a BytesIO stream.
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
if not self.collection_id:
|
||||
raise RuntimeError("The RAG backend requires collection_id")
|
||||
|
||||
document_content = self.parse_document(name, content_type, content)
|
||||
self.store_document(name, document_content)
|
||||
self.store_document(name, document_content, **kwargs)
|
||||
return document_content
|
||||
|
||||
def delete_collection(self) -> None:
|
||||
@abstractmethod
|
||||
def delete_collection(self, **kwargs) -> 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) -> None:
|
||||
async def adelete_collection(self, **kwargs) -> 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)()
|
||||
return await sync_to_async(self.delete_collection)(**kwargs)
|
||||
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
@abstractmethod
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> 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, results_count: int = 4) -> RAGWebResults:
|
||||
async def asearch(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
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.
|
||||
"""
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count)
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count, **kwargs)
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
@@ -168,7 +189,9 @@ class BaseRagBackend:
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
|
||||
async def temporary_collection_async(
|
||||
cls, name: str, description: Optional[str] = None, **kwargs
|
||||
):
|
||||
"""Context manager for RAG backend with temporary collections."""
|
||||
backend = cls()
|
||||
|
||||
@@ -176,4 +199,4 @@ class BaseRagBackend:
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
await backend.adelete_collection()
|
||||
await backend.adelete_collection(**kwargs)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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")
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -174,20 +173,18 @@ class BaseAgent(Agent):
|
||||
# and pydantic_ai.models.infer_model()
|
||||
_model_instance = self.configuration.model_name
|
||||
|
||||
_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
|
||||
)
|
||||
_system_prompt = self.get_system_prompt()
|
||||
|
||||
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
_tools = self.get_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]
|
||||
|
||||
@@ -131,3 +131,25 @@ 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,14 +6,103 @@ for the LLM to access them, and then reverting them back to local URLs when
|
||||
storing the messages in the database.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from pydantic_ai import DocumentUrl, ImageUrl, ModelMessage, ModelRequest, UserPromptPart
|
||||
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
from core.file_upload.utils import generate_retrieve_policy
|
||||
|
||||
from chat.models import ChatConversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_temporary_url(key: str) -> str:
|
||||
"""
|
||||
Generate a temporary URL for accessing a file through the backend.
|
||||
|
||||
Instead of using S3 presigned URLs, this creates a temporary access key
|
||||
that's stored in cache (3 minutes TTL). The LLM accesses the file through
|
||||
a backend endpoint that validates the key and streams the file content.
|
||||
|
||||
This approach:
|
||||
- Works even when S3 is not accessible from the LLM
|
||||
- Provides better security (key is time-limited and single-use)
|
||||
- Allows the backend to control file access centrally
|
||||
|
||||
Args:
|
||||
key (str): The S3 object key where the file is stored.
|
||||
|
||||
Returns:
|
||||
str: A temporary URL with format: /api/v1.0/file-stream/{temporary_key}/
|
||||
"""
|
||||
# Generate a secure random key
|
||||
temporary_key = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the S3 key in cache
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
cache.set(cache_key, key, timeout=settings.FILE_BACKEND_TEMPORARY_URL_EXPIRATION)
|
||||
|
||||
logger.info("Generated temporary file access key for S3 key: %s", key)
|
||||
|
||||
# Return the URL that the LLM will use to access the file
|
||||
return f"{settings.FILE_BACKEND_URL}/api/v1.0/file-stream/{temporary_key}/"
|
||||
|
||||
|
||||
def _get_file_url_for_llm(key: str, mode: str | None = None) -> str:
|
||||
"""
|
||||
Get the appropriate URL for the LLM to access a file based on the upload mode.
|
||||
|
||||
Args:
|
||||
key (str): The S3 object key where the file is stored.
|
||||
mode (str, optional): The upload mode. Defaults to FILE_TO_LLM_MODE setting.
|
||||
|
||||
Returns:
|
||||
str: The URL or data URL for the LLM to use.
|
||||
|
||||
Supported modes:
|
||||
- presigned_url: Returns a presigned S3 URL (default)
|
||||
- backend_temporary_url: Returns a presigned URL with shorter expiration
|
||||
- backend_base64: Returns a data URL with base64-encoded file content
|
||||
"""
|
||||
if mode is None:
|
||||
mode = settings.FILE_TO_LLM_MODE
|
||||
|
||||
if mode == FileToLLMMode.BACKEND_BASE64:
|
||||
# Read file from S3 and encode as base64 data URL
|
||||
try:
|
||||
with default_storage.open(key, "rb") as file:
|
||||
file_content = file.read()
|
||||
# Detect MIME type from file extension or default to octet-stream
|
||||
mime_type, _ = mimetypes.guess_type(key)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# Create data URL
|
||||
b64_content = base64.b64encode(file_content).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{b64_content}"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Fall back to presigned URL on error
|
||||
logger.exception(
|
||||
"Failed to read file for base64 encoding, falling back to presigned URL"
|
||||
)
|
||||
return generate_retrieve_policy(key)
|
||||
|
||||
elif mode == FileToLLMMode.BACKEND_TEMPORARY_URL:
|
||||
return generate_temporary_url(key)
|
||||
|
||||
# FileToLLMMode.PRESIGNED_URL or default
|
||||
return generate_retrieve_policy(key)
|
||||
|
||||
|
||||
def update_local_urls(
|
||||
conversation: ChatConversation,
|
||||
@@ -21,7 +110,9 @@ def update_local_urls(
|
||||
updated_url: Dict[str, str] | None = None,
|
||||
) -> Iterable[ImageUrl | DocumentUrl]:
|
||||
"""
|
||||
Replace local image or document URLs in the content list to use presigned S3 URLs.
|
||||
Replace local image or document URLs in the content list to use appropriate S3 URLs
|
||||
based on the configured FILE_TO_LLM_MODE.
|
||||
|
||||
⚠️Be careful, `media_contents` are replaced in place.
|
||||
|
||||
Args:
|
||||
@@ -31,7 +122,7 @@ def update_local_urls(
|
||||
mapping of original URLs to updated URLs.
|
||||
Returns:
|
||||
Iterable[ImageUrl | DocumentUrl]: Updated iterable of UserContent objects
|
||||
with presigned URLs.
|
||||
with appropriate S3 URLs based on the configured mode.
|
||||
"""
|
||||
# When images are stored locally, there is no host in the URL, so we can
|
||||
# just check if the URL starts, frontend adds a prefix `/media-key/` to the key.
|
||||
@@ -41,7 +132,9 @@ def update_local_urls(
|
||||
# Filter only ImageUrl contents
|
||||
media_contents = (c for c in contents if isinstance(c, (ImageUrl, DocumentUrl)))
|
||||
|
||||
# Replace URLs with presigned URLs
|
||||
# Replace URLs with appropriate S3 URLs based on mode
|
||||
upload_mode = settings.FILE_TO_LLM_MODE
|
||||
|
||||
for content in media_contents:
|
||||
idx = content.url.find(local_media_url_prefix)
|
||||
|
||||
@@ -57,7 +150,7 @@ def update_local_urls(
|
||||
# except if the user tampers with the conversation.
|
||||
continue
|
||||
|
||||
content.url = generate_retrieve_policy(key)
|
||||
content.url = _get_file_url_for_llm(key, upload_mode)
|
||||
if updated_url is not None:
|
||||
updated_url[content.url] = _initial_url
|
||||
|
||||
@@ -68,7 +161,7 @@ def update_history_local_urls(
|
||||
conversation: ChatConversation, messages: list[ModelMessage]
|
||||
) -> list[ModelMessage]:
|
||||
"""
|
||||
Replace local image/documents URLs in the message list to use presigned S3 URLs.
|
||||
Replace local image/documents URLs in the message list to use appropriate S3 URLs.
|
||||
|
||||
⚠️Be careful, `messages` are replaced in place.
|
||||
|
||||
@@ -79,7 +172,7 @@ def update_history_local_urls(
|
||||
Args:
|
||||
messages (list[ModelMessage]): List of ModelMessage objects.
|
||||
Returns:
|
||||
list[ModelMessage]: Updated list of ModelMessage objects with presigned URLs.
|
||||
list[ModelMessage]: Updated list of ModelMessage objects with appropriate S3 URLs.
|
||||
"""
|
||||
# Filter only ModelRequest messages
|
||||
requests = (msg for msg in messages if isinstance(msg, ModelRequest))
|
||||
|
||||
@@ -6,6 +6,7 @@ implementation while keeping the *exact* same public API so that no
|
||||
changes are needed in views.py or tests.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
@@ -52,10 +53,9 @@ from pydantic_ai.messages import (
|
||||
)
|
||||
|
||||
from core.feature_flags.helpers import is_feature_enabled
|
||||
from core.file_upload.utils import generate_retrieve_policy
|
||||
|
||||
from chat import models
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.agents.conversation import ConversationAgent, TitleGenerationAgent
|
||||
from chat.agents.local_media_url_processors import (
|
||||
update_history_local_urls,
|
||||
update_local_urls,
|
||||
@@ -76,7 +76,7 @@ from chat.tools.document_generic_search_rag import add_document_rag_search_tool_
|
||||
from chat.tools.document_search_rag import add_document_rag_search_tool
|
||||
from chat.tools.document_summarize import document_summarize
|
||||
from chat.vercel_ai_sdk.core import events_v4, events_v5
|
||||
from chat.vercel_ai_sdk.encoder import EventEncoder
|
||||
from chat.vercel_ai_sdk.encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder
|
||||
|
||||
# Keep at the top of the file to avoid mocking issues
|
||||
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
|
||||
@@ -92,6 +92,7 @@ class ContextDeps:
|
||||
|
||||
conversation: models.ChatConversation
|
||||
user: User
|
||||
session: Optional[Dict] = None
|
||||
web_search_enabled: bool = False
|
||||
|
||||
|
||||
@@ -106,7 +107,14 @@ def get_model_configuration(model_hrid: str):
|
||||
class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
"""Service class for AI-related operations (Pydantic-AI edition)."""
|
||||
|
||||
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None):
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self,
|
||||
conversation: models.ChatConversation,
|
||||
user,
|
||||
session=None,
|
||||
model_hrid=None,
|
||||
language=None,
|
||||
):
|
||||
"""
|
||||
Initialize the AI agent service.
|
||||
|
||||
@@ -122,7 +130,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
self._langfuse_available = settings.LANGFUSE_ENABLED
|
||||
self._store_analytics = self._langfuse_available and user.allow_conversation_analytics
|
||||
self.event_encoder = EventEncoder("v4") # Always use v4 for now
|
||||
self.event_encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION) # We use v4 for now
|
||||
|
||||
self._support_streaming = True
|
||||
if (streaming := get_model_configuration(self.model_hrid).supports_streaming) is not None:
|
||||
@@ -136,6 +144,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
self._context_deps = ContextDeps(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
session=session,
|
||||
web_search_enabled=self._is_web_search_enabled,
|
||||
)
|
||||
|
||||
@@ -274,19 +283,25 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
# Retrieve the document data
|
||||
with default_storage.open(key, "rb") as file:
|
||||
document_data = file.read()
|
||||
parsed_content = document_store.parse_and_store_document(
|
||||
# Run in thread to avoid blocking the event loop during parsing
|
||||
parsed_content = await asyncio.to_thread(
|
||||
document_store.parse_and_store_document,
|
||||
name=document.identifier,
|
||||
content_type=document.media_type,
|
||||
content=document_data,
|
||||
user_sub=self.user.sub,
|
||||
)
|
||||
else:
|
||||
# Remote URL
|
||||
raise ValueError("External document URL are not accepted yet.")
|
||||
else:
|
||||
parsed_content = document_store.parse_and_store_document(
|
||||
# Run in thread to avoid blocking the event loop during parsing
|
||||
parsed_content = await asyncio.to_thread(
|
||||
document_store.parse_and_store_document,
|
||||
name=document.identifier,
|
||||
content_type=document.media_type,
|
||||
content=document.data,
|
||||
user_sub=self.user.sub,
|
||||
)
|
||||
|
||||
if not document.media_type.startswith("text/"):
|
||||
@@ -471,27 +486,19 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
_tool_is_streaming = False
|
||||
_model_response_message_id = None
|
||||
|
||||
# Check for existing non-PDF documents in the conversation:
|
||||
# - if no document at all: do nothing
|
||||
# - if only PDFs: prepare document URLs for the agent
|
||||
# - if other document types: add the RAG search tool
|
||||
# to allow searching in all kinds of documents
|
||||
has_not_pdf_docs = await (
|
||||
# Check for existing documents (any non-image attachment for this conversation)
|
||||
has_documents = await (
|
||||
models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(
|
||||
Q(content_type__startswith="image/") | Q(content_type="application/pdf"),
|
||||
)
|
||||
.exclude(content_type__startswith="image/")
|
||||
.aexists()
|
||||
)
|
||||
|
||||
document_urls = []
|
||||
if not conversation_has_documents and not has_not_pdf_docs:
|
||||
# No documents to process
|
||||
pass
|
||||
elif has_not_pdf_docs:
|
||||
should_enable_rag = conversation_has_documents or has_documents
|
||||
|
||||
if should_enable_rag:
|
||||
add_document_rag_search_tool(self.conversation_agent)
|
||||
|
||||
@self.conversation_agent.instructions
|
||||
@@ -521,30 +528,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
async def summarize(ctx: RunContext, *args, **kwargs) -> ToolReturn:
|
||||
"""Wrap the document_summarize tool to provide context and add the tool."""
|
||||
return await document_summarize(ctx, *args, **kwargs)
|
||||
else:
|
||||
conversation_documents = [
|
||||
cd
|
||||
async for cd in models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(
|
||||
content_type__startswith="image/",
|
||||
)
|
||||
.values_list("key", "content_type")
|
||||
]
|
||||
|
||||
for doc_key, doc_content_type in conversation_documents:
|
||||
if doc_content_type == "application/pdf":
|
||||
_presigned_url = generate_retrieve_policy(doc_key)
|
||||
document_urls.append(
|
||||
DocumentUrl(
|
||||
url=_presigned_url,
|
||||
identifier=doc_key.split("/")[-1],
|
||||
media_type="application/pdf",
|
||||
)
|
||||
)
|
||||
image_key_mapping[_presigned_url] = f"/media-key/{doc_key}"
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# MCP servers (if any) can be initialized here
|
||||
@@ -559,7 +542,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
history.append(ModelResponse(parts=[TextPart(content="ok")], kind="response"))
|
||||
|
||||
async with self.conversation_agent.iter(
|
||||
[user_prompt] + input_images + document_urls,
|
||||
[user_prompt] + input_images,
|
||||
message_history=history, # history will pass through agent's history_processors
|
||||
deps=self._context_deps,
|
||||
toolsets=mcp_servers,
|
||||
@@ -720,8 +703,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
await self._agent_stop_streaming(force_cache_check=True)
|
||||
|
||||
# Persist conversation
|
||||
await sync_to_async(self._update_conversation)(
|
||||
# Prepare conversation update (save deferred until after potential title generation)
|
||||
await sync_to_async(self._prepare_update_conversation)(
|
||||
final_output=run.result.new_messages(),
|
||||
usage=usage,
|
||||
final_output_from_tool=_final_output_from_tool,
|
||||
@@ -730,6 +713,35 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
image_key_mapping=image_key_mapping or None,
|
||||
)
|
||||
|
||||
generated_title = None
|
||||
|
||||
# Auto-generate title after N user messages if not manually set
|
||||
user_messages_count = sum(1 for msg in self.conversation.messages if msg.role == "user")
|
||||
|
||||
should_generate_title = (
|
||||
user_messages_count == settings.AUTO_TITLE_AFTER_USER_MESSAGES
|
||||
and not self.conversation.title_set_by_user_at
|
||||
)
|
||||
|
||||
if should_generate_title:
|
||||
if generated_title := await self._generate_title():
|
||||
self.conversation.title = generated_title
|
||||
|
||||
# Persist conversation (including any generated title)
|
||||
await sync_to_async(self.conversation.save)()
|
||||
|
||||
# Notify frontend about the title update
|
||||
if generated_title:
|
||||
yield events_v4.DataPart(
|
||||
data=[
|
||||
{
|
||||
"type": "conversation_metadata",
|
||||
"conversationId": str(self.conversation.pk),
|
||||
"title": generated_title,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if self._langfuse_available:
|
||||
langfuse.update_current_trace(
|
||||
output=run.result.output if self._store_analytics else "REDACTED"
|
||||
@@ -743,7 +755,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
),
|
||||
)
|
||||
|
||||
def _update_conversation( # noqa: PLR0913
|
||||
def _prepare_update_conversation( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
final_output: List[ModelRequest | ModelMessage],
|
||||
@@ -810,4 +822,39 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
ModelMessagesTypeAdapter.dump_json(final_output).decode("utf-8")
|
||||
)
|
||||
|
||||
self.conversation.save()
|
||||
async def _generate_title(self) -> str | None:
|
||||
"""Generate a title for the conversation using LLM based on first messages."""
|
||||
|
||||
# Build context from messages
|
||||
# Note: We intentionally use only msg.content for title generation.
|
||||
# Parts containing tool invocations or reasoning are excluded as they
|
||||
# don't contribute to a meaningful context here
|
||||
context = "\n".join(
|
||||
f"{msg.role}: {(msg.content or '')[:300]}" # Limit content length per message
|
||||
for msg in self.conversation.messages
|
||||
if msg.content
|
||||
)
|
||||
|
||||
language = self.language or settings.LANGUAGE_CODE
|
||||
prompt = (
|
||||
"Generate a concise title (3-5 words, max 100 characters) for this conversation.\n\n"
|
||||
"Requirements:\n"
|
||||
"- Capture the main topic or user intent\n"
|
||||
"- The title must be a simple string, no markdown\n"
|
||||
"- Help the user quickly identify the conversation\n"
|
||||
f"- Match the language of the user messages (default: {language})\n"
|
||||
"- Avoid the word 'summary' unless explicitly requested\n\n"
|
||||
"Output: Title text only, no quotes, labels, or explanation.\n\n"
|
||||
f"Conversation:\n{context}"
|
||||
)
|
||||
try:
|
||||
agent = TitleGenerationAgent()
|
||||
result = await agent.run(prompt)
|
||||
title = (result.output or "").strip()[:100] # Enforce max length (conversation.title)
|
||||
logger.info("Generated title for conversation %s: %s", self.conversation.pk, title)
|
||||
return title if title else None
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to generate title for conversation %s: %s", self.conversation.pk, exc
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -44,7 +44,12 @@ 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,
|
||||
|
||||
@@ -4,12 +4,13 @@ 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
|
||||
from core.file_upload.enums import AttachmentStatus, FileUploadMode
|
||||
from core.file_upload.utils import generate_upload_policy
|
||||
|
||||
from chat import models
|
||||
@@ -27,6 +28,12 @@ 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):
|
||||
"""
|
||||
@@ -173,7 +180,11 @@ class ChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating chat conversation attachments."""
|
||||
"""Serializer for creating chat conversation attachments.
|
||||
|
||||
For presigned_url mode: returns 'policy' field with presigned URL for direct S3 upload
|
||||
For backend modes: does not return 'policy' field (upload handled via backend endpoint)
|
||||
"""
|
||||
|
||||
policy = serializers.SerializerMethodField()
|
||||
uploaded_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
@@ -183,9 +194,15 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
model = models.ChatConversationAttachment
|
||||
fields = ["id", "key", "content_type", "file_name", "size", "policy", "uploaded_by"]
|
||||
|
||||
def get_policy(self, attachment) -> str:
|
||||
"""Return the policy to use if the item is a file."""
|
||||
return generate_upload_policy(attachment.key)
|
||||
def get_policy(self, attachment) -> str | None:
|
||||
"""Return the policy (presigned URL) only for presigned_url mode."""
|
||||
upload_mode = settings.FILE_UPLOAD_MODE
|
||||
|
||||
# Only return presigned URL in presigned_url mode
|
||||
if upload_mode == FileUploadMode.PRESIGNED_URL:
|
||||
return generate_upload_policy(attachment.key)
|
||||
|
||||
return None
|
||||
|
||||
def validate_size(self, size: Optional[int]) -> Optional[int]:
|
||||
"""Validate that the size is not greater than the maximum allowed size."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
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.clients.pydantic_ai import AIAgentService
|
||||
|
||||
@@ -90,3 +96,35 @@ PIXEL_PNG = (
|
||||
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,5 +1,6 @@
|
||||
"""Tests for local_media_url_processors."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +13,10 @@ from pydantic_ai import (
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
|
||||
from chat.agents.local_media_url_processors import (
|
||||
_get_file_url_for_llm,
|
||||
update_history_local_urls,
|
||||
update_local_urls,
|
||||
)
|
||||
@@ -121,3 +125,549 @@ def test_update_history_local_urls_no_user_prompt_parts():
|
||||
result = update_history_local_urls(conversation, messages)
|
||||
assert result == messages
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_uses_get_file_url_for_llm(mock_get_file_url):
|
||||
"""Test that update_local_urls uses _get_file_url_for_llm for mode-aware URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "mode-aware-url"
|
||||
mock_get_file_url.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_get_file_url_presigned_mode(mock_policy):
|
||||
"""Test URL generation in presigned_url mode."""
|
||||
|
||||
mock_policy.return_value = "presigned_s3_url"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.PRESIGNED_URL)
|
||||
|
||||
assert url == "presigned_s3_url"
|
||||
mock_policy.assert_called_once_with("test/key.pdf")
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_get_file_url_backend_temporary_url_mode(mock_temp_url):
|
||||
"""Test URL generation in backend_temporary_url mode."""
|
||||
|
||||
mock_temp_url.return_value = "/api/v1.0/file-stream/token123/"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_TEMPORARY_URL)
|
||||
|
||||
assert url == "/api/v1.0/file-stream/token123/"
|
||||
mock_temp_url.assert_called_once_with("test/key.pdf")
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_get_file_url_backend_base64_mode(mock_storage):
|
||||
"""Test URL generation in backend_base64 mode."""
|
||||
|
||||
# Mock file content
|
||||
file_content = b"PDF binary content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
|
||||
|
||||
# URL should be a data URL
|
||||
assert url.startswith("data:")
|
||||
assert "base64" in url
|
||||
mock_storage.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"chat.agents.local_media_url_processors.default_storage.open", side_effect=Exception("S3 error")
|
||||
)
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_get_file_url_backend_base64_fallback(mock_policy, _mock_storage):
|
||||
"""Test fallback to presigned URL when base64 encoding fails."""
|
||||
|
||||
mock_policy.return_value = "fallback_presigned_url"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
|
||||
|
||||
# Should fall back to presigned URL
|
||||
assert url == "fallback_presigned_url"
|
||||
mock_policy.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_multiple_images_with_modes(mock_get_file_url):
|
||||
"""Test handling multiple images with mode-aware URL generation."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Mock different URLs for different calls
|
||||
urls = ["url1", "url2", "url3"]
|
||||
mock_get_file_url.side_effect = urls
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "url1"
|
||||
assert result[1].url == "url2"
|
||||
assert result[2].url == "url3"
|
||||
assert mock_get_file_url.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_mixed_external_and_local_urls(mock_get_file_url):
|
||||
"""Test handling of mixed external and local URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key}"), # Local URL - will be processed
|
||||
ImageUrl(url="https://external.com/image.jpg"), # External URL - kept as is
|
||||
ImageUrl(url="http://another.com/image.png"), # External URL - kept as is
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "mode-aware-url"
|
||||
assert result[1].url == "https://external.com/image.jpg"
|
||||
assert result[2].url == "http://another.com/image.png"
|
||||
# Only one local URL was processed
|
||||
assert mock_get_file_url.call_count == 1
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_history_local_urls_with_mode_detection(mock_get_file_url):
|
||||
"""Test that update_history_local_urls uses mode detection for URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
user_prompt_content = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
messages = [
|
||||
ModelRequest(parts=[UserPromptPart(content=user_prompt_content)]),
|
||||
ModelResponse(parts=[TextPart(content="I see your image.")]),
|
||||
]
|
||||
|
||||
with patch("chat.agents.local_media_url_processors.update_local_urls") as mock_update:
|
||||
mock_update.return_value = iter([ImageUrl(url="mode-aware-url")])
|
||||
result = update_history_local_urls(conversation, messages)
|
||||
|
||||
assert len(result) == 2
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
def test_update_local_urls_preserves_other_url_types():
|
||||
"""Test that update_local_urls preserves other URL types unchanged."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
contents = [
|
||||
ImageUrl(url="data:image/png;base64,iVBORw0KG..."), # Already data URL
|
||||
ImageUrl(url="https://example.com/image.jpg"), # HTTPS
|
||||
ImageUrl(url="http://example.com/image.jpg"), # HTTP
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "data:image/png;base64,iVBORw0KG..."
|
||||
assert result[1].url == "https://example.com/image.jpg"
|
||||
assert result[2].url == "http://example.com/image.jpg"
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_stores_updated_urls_mapping(mock_get_file_url):
|
||||
"""Test that update_local_urls stores the mapping of new to old URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "new-mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
old_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=old_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert "new-mode-aware-url" in updated_urls
|
||||
assert updated_urls["new-mode-aware-url"] == old_url
|
||||
|
||||
|
||||
def test_update_local_urls_security_prevents_other_conversation_access():
|
||||
"""Test that security check prevents accessing other conversation's files."""
|
||||
conversation = ChatConversationFactory()
|
||||
other_conversation_key = "other-uuid/attachments/file.jpg"
|
||||
|
||||
# Try to access file from different conversation
|
||||
contents = [ImageUrl(url=f"/media-key/{other_conversation_key}")]
|
||||
|
||||
with patch("chat.agents.local_media_url_processors._get_file_url_for_llm") as mock_get:
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should not be processed (security check failed)
|
||||
assert result[0].url == f"/media-key/{other_conversation_key}"
|
||||
# _get_file_url_for_llm should NOT be called
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_get_file_url_is_called_with_correct_arguments(mock_get_file_url):
|
||||
"""Test that _get_file_url_for_llm is called with correct arguments."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "processed-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
list(update_local_urls(conversation, contents))
|
||||
|
||||
# Verify the function was called with the S3 key (without /media-key/ prefix)
|
||||
mock_get_file_url.assert_called_once()
|
||||
call_args = mock_get_file_url.call_args
|
||||
assert call_args[0][0] == key # First argument should be the S3 key
|
||||
|
||||
|
||||
# ==================== Tests for FILE_TO_LLM_MODE settings ====================
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_with_presigned_url_mode(mock_policy, settings):
|
||||
"""Test update_local_urls with PRESIGNED_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.return_value = "https://s3.example.com/presigned-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "https://s3.example.com/presigned-url"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_with_backend_temporary_url_mode(mock_temp_url, settings):
|
||||
"""Test update_local_urls with BACKEND_TEMPORARY_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_temp_url.return_value = "/api/v1.0/file-stream/temp-token-123/"
|
||||
|
||||
key = f"{conversation.pk}/test.pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "/api/v1.0/file-stream/temp-token-123/"
|
||||
mock_temp_url.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_with_backend_base64_mode(mock_storage, settings):
|
||||
"""Test update_local_urls with BACKEND_BASE64 mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
file_content = b"Mock image binary content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should be a data URL
|
||||
assert result[0].url.startswith("data:")
|
||||
assert "base64" in result[0].url
|
||||
mock_storage.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_backend_base64_fallback_on_error(mock_storage, mock_policy, settings):
|
||||
"""Test that update_local_urls falls back to presigned URL when base64 fails."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
mock_storage.side_effect = Exception("S3 connection error")
|
||||
mock_policy.return_value = "https://s3.example.com/fallback-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should fall back to presigned URL
|
||||
assert result[0].url == "https://s3.example.com/fallback-url"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_multiple_files_presigned_mode(mock_policy, settings):
|
||||
"""Test update_local_urls with multiple files in PRESIGNED_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.side_effect = [
|
||||
"https://s3.example.com/image1-presigned",
|
||||
"https://s3.example.com/image2-presigned",
|
||||
"https://s3.example.com/document-presigned",
|
||||
]
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "https://s3.example.com/image1-presigned"
|
||||
assert result[1].url == "https://s3.example.com/image2-presigned"
|
||||
assert result[2].url == "https://s3.example.com/document-presigned"
|
||||
assert mock_policy.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_multiple_files_temporary_url_mode(mock_temp_url, settings):
|
||||
"""Test update_local_urls with multiple files in BACKEND_TEMPORARY_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_temp_url.side_effect = [
|
||||
"/api/v1.0/file-stream/token1/",
|
||||
"/api/v1.0/file-stream/token2/",
|
||||
"/api/v1.0/file-stream/token3/",
|
||||
]
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "/api/v1.0/file-stream/token1/"
|
||||
assert result[1].url == "/api/v1.0/file-stream/token2/"
|
||||
assert result[2].url == "/api/v1.0/file-stream/token3/"
|
||||
assert mock_temp_url.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_preserves_mapping(mock_policy, settings):
|
||||
"""Test that PRESIGNED_URL mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
presigned_url = "https://s3.example.com/presigned-123"
|
||||
mock_policy.return_value = presigned_url
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert presigned_url in updated_urls
|
||||
assert updated_urls[presigned_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_temporary_url_mode_preserves_mapping(mock_temp_url, settings):
|
||||
"""Test that BACKEND_TEMPORARY_URL mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
temp_url = "/api/v1.0/file-stream/temp-abc-def/"
|
||||
mock_temp_url.return_value = temp_url
|
||||
|
||||
key = f"{conversation.pk}/test.pdf"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [DocumentUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert temp_url in updated_urls
|
||||
assert updated_urls[temp_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_preserves_mapping(mock_storage, settings):
|
||||
"""Test that BACKEND_BASE64 mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
file_content = b"test image content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
result = list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
# Verify mapping was stored
|
||||
assert len(updated_urls) == 1
|
||||
# The data URL should be the key in the mapping
|
||||
data_url = result[0].url
|
||||
assert data_url in updated_urls
|
||||
assert updated_urls[data_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_security_check(mock_policy, settings):
|
||||
"""Test that PRESIGNED_URL mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# generate_retrieve_policy should not be called
|
||||
mock_policy.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_temporary_mode_security_check(mock_temp_url, settings):
|
||||
"""Test that BACKEND_TEMPORARY_URL mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# generate_temporary_url should not be called
|
||||
mock_temp_url.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_security_check(mock_storage, settings):
|
||||
"""Test that BACKEND_BASE64 mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# Storage should not be opened
|
||||
mock_storage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_to_llm_mode",
|
||||
[
|
||||
FileToLLMMode.PRESIGNED_URL,
|
||||
FileToLLMMode.BACKEND_TEMPORARY_URL,
|
||||
FileToLLMMode.BACKEND_BASE64,
|
||||
],
|
||||
)
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_all_modes_with_external_urls(
|
||||
mock_get_file_url, file_to_llm_mode, settings
|
||||
):
|
||||
"""Test that all modes preserve external URLs unchanged."""
|
||||
settings.FILE_TO_LLM_MODE = file_to_llm_mode
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "processed-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
external_urls = [
|
||||
"https://example.com/image.jpg",
|
||||
"http://another.com/image.png",
|
||||
"data:image/png;base64,iVBORw0KG...",
|
||||
]
|
||||
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")] + [ImageUrl(url=url) for url in external_urls]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 4
|
||||
assert result[0].url == "processed-url"
|
||||
for i, external_url in enumerate(external_urls, start=1):
|
||||
assert result[i].url == external_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_with_different_file_types(mock_storage, settings):
|
||||
"""Test BACKEND_BASE64 mode with different file MIME types."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Test with different file types
|
||||
test_cases = [
|
||||
("test.jpg", b"JPEG binary"),
|
||||
("test.png", b"PNG binary"),
|
||||
("test.pdf", b"PDF binary"),
|
||||
("test.txt", b"Text content"),
|
||||
]
|
||||
|
||||
for filename, content in test_cases:
|
||||
mock_file = BytesIO(content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/{filename}"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should be a data URL
|
||||
assert result[0].url.startswith("data:")
|
||||
assert "base64" in result[0].url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_with_special_characters_in_key(mock_policy, settings):
|
||||
"""Test PRESIGNED_URL mode handles keys with special characters."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.return_value = "https://s3.example.com/presigned"
|
||||
|
||||
# Key with special characters (should be handled properly by S3)
|
||||
key = f"{conversation.pk}/attachments/file (1).pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "https://s3.example.com/presigned"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""Tests for the Brave web search tool."""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
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 (
|
||||
@@ -38,9 +43,6 @@ 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
|
||||
|
||||
|
||||
@@ -48,6 +50,13 @@ 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 = {}
|
||||
@@ -1011,7 +1020,12 @@ 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("test query", results_count=5)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.file_upload.enums import AttachmentStatus
|
||||
from core.file_upload.enums import AttachmentStatus, FileUploadMode
|
||||
|
||||
from chat import factories, models
|
||||
from chat.tests.conftest import PIXEL_PNG
|
||||
@@ -266,3 +266,40 @@ def test_upload_ended_fix_extension(api_client, name, content, _extension, conte
|
||||
assert attachment.content_type == content_type # updated
|
||||
assert attachment.file_name == name # updated
|
||||
assert attachment.size == len(content) # updated
|
||||
|
||||
|
||||
def test_attachment_create_presigned_url_mode_returns_policy(api_client, settings):
|
||||
"""Test that presigned_url mode returns policy field."""
|
||||
settings.FILE_UPLOAD_MODE = FileUploadMode.PRESIGNED_URL
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
|
||||
response = api_client.post(
|
||||
url,
|
||||
{"file_name": "test.pdf", "size": 1000, "content_type": "application/pdf"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["policy"] is not None
|
||||
assert "s3" in data["policy"].lower() or "minio" in data["policy"].lower()
|
||||
|
||||
|
||||
def test_attachment_create_backend_to_s3_mode_no_policy(api_client, settings):
|
||||
"""Test that backend_to_s3 mode does not return policy."""
|
||||
settings.FILE_UPLOAD_MODE = FileUploadMode.BACKEND_TO_S3
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
|
||||
response = api_client.post(
|
||||
url, {"file_name": "test.pdf", "content_type": "application/pdf"}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["policy"] is None
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for file upload mode `backend_to_s3`."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pytest
|
||||
|
||||
from chat import factories, models
|
||||
from chat.tests.conftest import PIXEL_PNG
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_backend_upload_anonymous_forbidden(api_client):
|
||||
"""Anonymous users should not be able to use backend upload."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
|
||||
response = api_client.post(url, {}, format="multipart")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_backend_upload_not_owner_forbidden(api_client):
|
||||
"""Users who don't own the conversation cannot upload via backend."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
user = factories.UserFactory()
|
||||
api_client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@mock.patch("chat.views.malware_detection.analyse_file")
|
||||
def test_backend_upload_success(mock_malware, api_client):
|
||||
"""Test successful backend file upload."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "id" in data
|
||||
assert "key" in data
|
||||
assert data["file_name"] == "test.png"
|
||||
assert data["size"] == len(PIXEL_PNG)
|
||||
assert data["content_type"] == "image/png"
|
||||
|
||||
# Verify attachment was created
|
||||
attachment = models.ChatConversationAttachment.objects.get(pk=data["id"])
|
||||
assert attachment.conversation == conversation
|
||||
assert attachment.uploaded_by == conversation.owner
|
||||
assert attachment.file_name == "test.png"
|
||||
assert attachment.size == len(PIXEL_PNG)
|
||||
|
||||
# Verify malware detection was called
|
||||
mock_malware.assert_called_once()
|
||||
|
||||
|
||||
def test_backend_upload_missing_file(api_client):
|
||||
"""Test that backend upload fails without file field."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
|
||||
response = api_client.post(
|
||||
url,
|
||||
{"file_name": "test.png"}, # No file field
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "file" in response.json()
|
||||
|
||||
|
||||
@mock.patch("chat.views.malware_detection.analyse_file")
|
||||
def test_backend_upload_creates_s3_file(_mock_malware, api_client):
|
||||
"""Test that backend upload creates file in S3."""
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
key = data["key"]
|
||||
|
||||
# Verify file exists in S3
|
||||
assert default_storage.exists(key)
|
||||
|
||||
# Verify file content
|
||||
with default_storage.open(key, "rb") as f:
|
||||
content = f.read()
|
||||
assert content == PIXEL_PNG
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Common test fixtures for chat conversation endpoint tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
@@ -10,15 +11,9 @@ import respx
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
@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 = (
|
||||
def _create_openai_stream_data():
|
||||
"""Helper to create OpenAI stream data."""
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
@@ -59,12 +54,111 @@ def fixture_mock_openai_stream():
|
||||
"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():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
lines = openai_stream.splitlines(keepends=True)
|
||||
for i, line in enumerate(lines):
|
||||
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(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
side_effect=handle_request
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -221,6 +223,133 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
]
|
||||
|
||||
|
||||
@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",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"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
|
||||
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
@@ -1344,3 +1473,246 @@ 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",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"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
|
||||
|
||||
+74
-22
@@ -8,6 +8,7 @@ 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
|
||||
@@ -41,28 +42,49 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
@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):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
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 :)"
|
||||
|
||||
# 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"
|
||||
# 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
|
||||
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"
|
||||
|
||||
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."""
|
||||
@@ -81,10 +103,18 @@ def fixture_sample_pdf_content():
|
||||
return BytesIO(pdf_data)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_albert_api")
|
||||
def fixture_mock_albert_api():
|
||||
@pytest.fixture(name="mock_document_api")
|
||||
def fixture_mock_document_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"},
|
||||
@@ -101,7 +131,7 @@ def fixture_mock_albert_api():
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -119,20 +149,42 @@ def fixture_mock_albert_api():
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"method": "semantic",
|
||||
"method": search_method,
|
||||
"chunk": {
|
||||
"id": 123,
|
||||
"content": "This is the content of the PDF.",
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
"content": document_content,
|
||||
"metadata": {"document_name": document_name},
|
||||
},
|
||||
"score": 0.9,
|
||||
"score": search_score,
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
},
|
||||
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():
|
||||
@@ -219,7 +271,7 @@ def fixture_mock_openai_stream():
|
||||
def test_post_conversation_with_document_upload(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_ai_agent_service,
|
||||
@@ -548,7 +600,7 @@ def test_post_conversation_with_document_upload_feature_disabled(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
|
||||
api_client,
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_ai_agent_service,
|
||||
|
||||
+56
-33
@@ -37,11 +37,20 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
@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):
|
||||
"""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
|
||||
@@ -85,6 +94,10 @@ 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)
|
||||
@@ -120,29 +133,28 @@ 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?",
|
||||
DocumentUrl(
|
||||
url=presigned_url, # presigned URL for this conversation
|
||||
media_type="application/pdf",
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
|
||||
],
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\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, 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."
|
||||
),
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
]
|
||||
@@ -186,9 +198,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
experimental_attachments=[
|
||||
Attachment(name="sample.pdf", contentType="application/pdf", url=document_url)
|
||||
],
|
||||
experimental_attachments=None, # We should fix this, but for now document appears in source
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
@@ -220,20 +230,29 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
{
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.",
|
||||
"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.",
|
||||
"kind": "request",
|
||||
"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,
|
||||
@@ -795,6 +814,10 @@ 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)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -11,6 +12,7 @@ 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,
|
||||
@@ -35,6 +37,7 @@ 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
|
||||
|
||||
|
||||
@@ -1573,3 +1576,307 @@ 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
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
@@ -26,6 +27,34 @@ 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):
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the file stream endpoint."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
def test_file_stream_invalid_key(api_client):
|
||||
"""Test that invalid temporary keys return 404."""
|
||||
cache.clear()
|
||||
url = "/api/v1.0/file-stream/invalid-key/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
error = response.json()["detail"].lower()
|
||||
assert "expired" in error or "invalid" in error
|
||||
|
||||
|
||||
def test_file_stream_expired_key(api_client):
|
||||
"""Test that expired keys return 404."""
|
||||
cache.clear()
|
||||
# Create a key that's already expired
|
||||
cache.set("file_access:expired-key", "path/to/file.pdf", timeout=0)
|
||||
|
||||
url = "/api/v1.0/file-stream/expired-key/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@mock.patch("chat.views.magic.Magic")
|
||||
@mock.patch("chat.views.default_storage.open")
|
||||
def test_file_stream_valid_key_streams_file(mock_storage_open, mock_magic, api_client):
|
||||
"""Test that valid temporary keys stream file content."""
|
||||
cache.clear()
|
||||
|
||||
# Create a valid temporary key
|
||||
temporary_key = "test-valid-key"
|
||||
s3_key = "test/path/file.pdf"
|
||||
cache.set(f"file_access:{temporary_key}", s3_key, timeout=300)
|
||||
|
||||
# Mock storage.open to return file content
|
||||
file_mock = BytesIO(b"PDF content here")
|
||||
mock_storage_open.return_value = file_mock
|
||||
|
||||
# Mock magic detector
|
||||
mock_magic_instance = mock.MagicMock()
|
||||
mock_magic_instance.from_buffer.return_value = "application/pdf"
|
||||
mock_magic.return_value = mock_magic_instance
|
||||
|
||||
url = f"/api/v1.0/file-stream/{temporary_key}/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -26,7 +26,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
|
||||
|
||||
document_store = document_store_backend(ctx.deps.conversation.collection_id)
|
||||
|
||||
rag_results = document_store.search(query)
|
||||
rag_results = document_store.search(query, session=ctx.deps.session)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -51,26 +51,33 @@ async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def document_summarize( # pylint: disable=too-many-locals
|
||||
ctx: RunContext, *, instructions: str | None = None
|
||||
async def document_summarize( # pylint: disable=too-many-locals, too-many-statements
|
||||
ctx: RunContext,
|
||||
*,
|
||||
instructions: str | None = None,
|
||||
doc_index: int | None = None,
|
||||
) -> ToolReturn:
|
||||
"""
|
||||
Generate a complete, ready-to-use summary of the documents in context
|
||||
(do not request the documents to the user).
|
||||
Return this summary directly to the user WITHOUT any modification,
|
||||
or additional summarization.
|
||||
The summary is already optimized and MUST be presented as-is in the final response
|
||||
or translated preserving the information.
|
||||
|
||||
Instructions are optional but should reflect the user's request.
|
||||
|
||||
Examples:
|
||||
"Summarize this doc in 2 paragraphs" -> instructions = "summary in 2 paragraphs"
|
||||
"Summarize this doc in English" -> instructions = "In English"
|
||||
"Summarize this doc" -> instructions = "" (default)
|
||||
|
||||
Args:
|
||||
instructions (str | None): The instructions the user gave to use for the summarization
|
||||
Produce a final, user-ready summary for one or more text documents from the conversation.
|
||||
|
||||
Builds per-chunk summaries for the selected documents, synthesizes them into a single coherent
|
||||
markdown-formatted summary that is intended to be returned to the user verbatim.
|
||||
|
||||
Parameters:
|
||||
instructions (str | None): Optional user instructions to guide the summary (e.g., length,
|
||||
language, style). When omitted, a default hint is used.
|
||||
doc_index (int | None): If provided, summarize only the document at this index (0-based;
|
||||
negative indices allowed, e.g. -1 for the last document). If `None`, all text documents
|
||||
found in the conversation are summarized.
|
||||
|
||||
Returns:
|
||||
str: The final synthesized summary formatted in Markdown.
|
||||
|
||||
Raises:
|
||||
ModelCannotRetry: If no text documents are found in the conversation or on unexpected errors
|
||||
that should stop processing and be reported to the user.
|
||||
ModelRetry: For retryable errors such as an out-of-range `doc_index`, errors during chunk
|
||||
processing, merge-generation failures, or when the summarization produces an empty result.
|
||||
"""
|
||||
try:
|
||||
instructions_hint = (
|
||||
@@ -91,6 +98,15 @@ async def document_summarize( # pylint: disable=too-many-locals
|
||||
"You must explain this to the user and ask them to provide documents."
|
||||
)
|
||||
|
||||
if doc_index is not None:
|
||||
try:
|
||||
text_attachment = [text_attachment[doc_index]]
|
||||
except IndexError as exc:
|
||||
raise ModelRetry(
|
||||
f"Document index {doc_index} is out of range. "
|
||||
f"There are {len(text_attachment)} documents available."
|
||||
) from exc
|
||||
|
||||
documents = [await read_document_content(doc) for doc in text_attachment]
|
||||
|
||||
# Chunk documents and summarize each chunk
|
||||
@@ -186,4 +202,4 @@ async def document_summarize( # pylint: disable=too-many-locals
|
||||
raise ModelCannotRetry(
|
||||
f"An unexpected error occurred during document summarization: {type(exc).__name__}. "
|
||||
"You must explain this to the user and not try to answer based on your knowledge."
|
||||
) from exc
|
||||
) from exc
|
||||
@@ -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) -> None:
|
||||
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> 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) -> None:
|
||||
logger.debug("Fetched document: %s", document)
|
||||
|
||||
if document:
|
||||
await document_store.astore_document(url, document)
|
||||
await document_store.astore_document(url, document, **kwargs)
|
||||
except DocumentFetchError as e:
|
||||
logger.warning("Failed to fetch and store %s: %s", url, e)
|
||||
# Continue with other documents
|
||||
@@ -307,19 +307,26 @@ 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
|
||||
temp_collection_name, session=ctx.deps.session
|
||||
) as document_store:
|
||||
# Fetch and store all documents concurrently
|
||||
tasks = [
|
||||
_fetch_and_store_async(result["url"], document_store)
|
||||
_fetch_and_store_async(
|
||||
result["url"],
|
||||
document_store,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
session=ctx.deps.session,
|
||||
)
|
||||
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 EventEncoder
|
||||
from .encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder, EventEncoderVersion
|
||||
|
||||
__all__ = ["EventEncoder"]
|
||||
__all__ = ["EventEncoder", "CURRENT_EVENT_ENCODER_VERSION", "EventEncoderVersion"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Event Encoder for Vercel AI SDK"""
|
||||
|
||||
from typing import Literal, Union
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
from ..core.events_v4 import BaseEvent as V4BaseEvent
|
||||
from ..core.events_v4 import TextPart
|
||||
@@ -8,16 +9,26 @@ 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: Literal["v4", "v5"] = None):
|
||||
def __init__(self, version: EventEncoderVersion):
|
||||
"""
|
||||
Initializes the EventEncoder with the specified version.
|
||||
"""
|
||||
if version not in ["v4", "v5"]:
|
||||
if version not in [EventEncoderVersion.V4, EventEncoderVersion.V5]:
|
||||
raise ValueError("Unsupported version. Supported versions are 'v4' and 'v5'.")
|
||||
|
||||
self.version = version
|
||||
@@ -28,7 +39,7 @@ class EventEncoder:
|
||||
"""
|
||||
return "text/event-stream"
|
||||
|
||||
def encode(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -38,15 +49,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 == "v4" and isinstance(event, V4BaseEvent):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, V4BaseEvent):
|
||||
return self._encode_v4_streaming(event)
|
||||
|
||||
if self.version == "v5" and isinstance(event, V5BaseEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, V5BaseEvent):
|
||||
return self._encode_sse(event)
|
||||
|
||||
return None
|
||||
|
||||
def encode_text(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -56,10 +67,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 == "v4" and isinstance(event, TextPart):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
|
||||
return event.text
|
||||
|
||||
if self.version == "v5" and isinstance(event, TextDeltaEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, TextDeltaEvent):
|
||||
return event.delta
|
||||
|
||||
return None
|
||||
@@ -70,7 +81,7 @@ class EventEncoder:
|
||||
"""
|
||||
return f"{event.type}:{event.model_dump_json(by_alias=True, exclude={'type'})}\n"
|
||||
|
||||
def _encode_sse(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str:
|
||||
def _encode_sse(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str:
|
||||
"""
|
||||
Encodes an event into an SSE string.
|
||||
"""
|
||||
|
||||
+198
-9
@@ -5,32 +5,51 @@ 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."""
|
||||
|
||||
@@ -122,6 +141,7 @@ 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,
|
||||
@@ -173,6 +193,7 @@ 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
|
||||
@@ -188,29 +209,28 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
if is_async_mode:
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
base_stream = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
base_stream = 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":
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = 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
|
||||
@@ -371,7 +391,6 @@ 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
|
||||
|
||||
@@ -435,3 +454,173 @@ class ChatConversationAttachmentViewSet(
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="backend-upload",
|
||||
url_name="backend-upload",
|
||||
)
|
||||
def backend_upload_attachment(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handle backend file upload for backend_to_s3 mode.
|
||||
|
||||
This endpoint is used when FILE_UPLOAD_MODE is set to backend_to_s3.
|
||||
The frontend sends the file directly to this endpoint,
|
||||
and the backend stores it on S3 and initiates malware detection.
|
||||
|
||||
The attachment lifecycle:
|
||||
1. Frontend sends file via this endpoint
|
||||
2. Backend stores file on S3
|
||||
3. Backend detects MIME type and file size
|
||||
4. Backend initiates malware detection
|
||||
5. After detection, attachment status becomes READY or SUSPICIOUS
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# Verify the user owns the conversation
|
||||
conversation_id = self.kwargs["conversation_pk"]
|
||||
if not models.ChatConversation.objects.filter(
|
||||
pk=conversation_id,
|
||||
owner=request.user,
|
||||
).exists():
|
||||
raise Http404
|
||||
|
||||
serializer = FileUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
file_obj = serializer.validated_data["file"]
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
|
||||
# Generate unique file ID and storage key
|
||||
file_id = uuid4()
|
||||
extension = file_name.rpartition(".")[-1] if "." in file_name else None
|
||||
ext_suffix = f".{extension}" if extension else ""
|
||||
key = f"{conversation_id}/{AttachmentMixin.ATTACHMENTS_FOLDER}/{file_id}{ext_suffix}"
|
||||
|
||||
# Store file on S3
|
||||
try:
|
||||
stored_path = default_storage.save(key, file_obj)
|
||||
logger.info("File uploaded to S3: %s", stored_path)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to upload file to S3 for conversation %s", conversation_id)
|
||||
return Response(
|
||||
{"detail": "Failed to upload file to storage"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Detect MIME type
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
with default_storage.open(key, "rb") as file:
|
||||
mimetype = mime_detector.from_buffer(file.read(2048))
|
||||
file_size = file.size
|
||||
|
||||
# Create attachment record with ANALYZING status
|
||||
attachment = models.ChatConversationAttachment.objects.create(
|
||||
conversation_id=conversation_id,
|
||||
uploaded_by=request.user,
|
||||
upload_state=AttachmentStatus.ANALYZING,
|
||||
key=key,
|
||||
file_name=file_name,
|
||||
content_type=mimetype,
|
||||
size=file_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created attachment %s for conversation %s, starting malware detection",
|
||||
attachment.pk,
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# Start malware detection (will update status to READY or SUSPICIOUS via callbacks)
|
||||
malware_detection.analyse_file(
|
||||
key,
|
||||
safe_callback="chat.malware_detection.conversation_safe_attachment_callback",
|
||||
unknown_callback="chat.malware_detection.unknown_attachment_callback",
|
||||
unsafe_callback="chat.malware_detection.conversation_unsafe_attachment_callback",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Track upload event
|
||||
if settings.POSTHOG_KEY:
|
||||
posthog.capture(
|
||||
"item_uploaded_backend",
|
||||
distinct_id=str(request.user.pk),
|
||||
properties={
|
||||
"id": attachment.pk,
|
||||
"file_name": attachment.file_name,
|
||||
"size": attachment.size,
|
||||
"mimetype": attachment.content_type,
|
||||
"mode": settings.FILE_UPLOAD_MODE,
|
||||
},
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(attachment)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FileStreamView(APIView):
|
||||
"""
|
||||
Stream file content for temporary access URLs.
|
||||
|
||||
This view is used by LLMs to access file content when they cannot directly
|
||||
access S3. A temporary key is stored in cache and validated before serving
|
||||
the file.
|
||||
|
||||
Security:
|
||||
- Temporary key expires after FILE_BACKEND_TEMPORARY_URL_EXPIRATION seconds
|
||||
(default: 180 seconds / 3 minutes)
|
||||
- No authentication required (key is single-use temporary token)
|
||||
- Key is generated using secure random tokens
|
||||
"""
|
||||
|
||||
permission_classes = [] # No authentication needed for temporary keys
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "file-stream"
|
||||
|
||||
def get(self, request, temporary_key):
|
||||
"""
|
||||
Stream file content using a temporary access key.
|
||||
|
||||
Args:
|
||||
temporary_key: The temporary key generated by generate_temporary_url()
|
||||
|
||||
Returns:
|
||||
StreamingHttpResponse with file content
|
||||
"""
|
||||
# Retrieve the S3 key from cache using the temporary key
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
s3_key = cache.get(cache_key)
|
||||
|
||||
if not s3_key:
|
||||
logger.warning("Temporary file access key not found or expired: %s", temporary_key)
|
||||
raise Http404("File access key expired or invalid")
|
||||
|
||||
# Delete the key from cache to prevent reuse
|
||||
cache.delete(cache_key)
|
||||
|
||||
logger.info("Serving file via temporary key: %s", s3_key)
|
||||
|
||||
try:
|
||||
# Open the file from S3
|
||||
file_obj = default_storage.open(s3_key, "rb")
|
||||
|
||||
# Detect MIME type for proper content-type header
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
file_content = file_obj.read(2048)
|
||||
file_obj.seek(0)
|
||||
content_type = mime_detector.from_buffer(file_content)
|
||||
|
||||
# Extract filename from S3 key (last part after /)
|
||||
filename = s3_key.split("/")[-1]
|
||||
|
||||
# Stream the file content
|
||||
response = StreamingHttpResponse(
|
||||
file_obj,
|
||||
content_type=content_type,
|
||||
)
|
||||
response["Content-Disposition"] = f'inline; filename="{filename}"'
|
||||
return response
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to serve file via temporary key: %s", temporary_key)
|
||||
raise Http404("Failed to retrieve file") from exc
|
||||
|
||||
@@ -22,7 +22,7 @@ def no_http_requests(monkeypatch):
|
||||
Credits: https://blog.jerrycodes.com/no-http-requests/
|
||||
"""
|
||||
|
||||
allowed_hosts = {"localhost", "minio", "minio:9000"}
|
||||
allowed_hosts = {"localhost", "127.0.0.1", "minio", "minio:9000"}
|
||||
original_urlopen = HTTPConnectionPool.urlopen
|
||||
|
||||
def urlopen_mock(self, method, url, *args, **kwargs):
|
||||
|
||||
@@ -230,6 +230,27 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="ATTACHMENT_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_UPLOAD_MODE = values.Value(
|
||||
"presigned_url",
|
||||
environ_name="FILE_UPLOAD_MODE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_TO_LLM_MODE = values.Value(
|
||||
"presigned_url",
|
||||
environ_name="FILE_TO_LLM_MODE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_BACKEND_URL = values.Value(
|
||||
"",
|
||||
environ_name="FILE_BACKEND_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_BACKEND_TEMPORARY_URL_EXPIRATION = values.IntegerValue(
|
||||
180,
|
||||
environ_name="FILE_BACKEND_TEMPORARY_URL_EXPIRATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
MALWARE_DETECTION = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.malware_detection.backends.dummy.DummyBackend",
|
||||
@@ -395,6 +416,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"file-stream": values.Value(
|
||||
default="60/minute",
|
||||
environ_name="API_FILE_STREAM_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -841,6 +867,23 @@ 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.
|
||||
@@ -911,7 +954,9 @@ 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,
|
||||
@@ -919,6 +964,12 @@ 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):
|
||||
@@ -1038,6 +1089,31 @@ 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."
|
||||
)
|
||||
|
||||
# Langfuse initialization
|
||||
if cls.LANGFUSE_ENABLED:
|
||||
if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED:
|
||||
@@ -1131,6 +1207,8 @@ 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"]
|
||||
|
||||
@@ -211,6 +211,7 @@ class ConfigView(drf.views.APIView):
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
"FEATURE_FLAGS",
|
||||
"FILE_UPLOAD_MODE",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
|
||||
@@ -20,3 +20,28 @@ class AttachmentStatus(StrEnum):
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
|
||||
class FileUploadMode(StrEnum):
|
||||
"""Defines the possible modes for file upload (from frontend) handling."""
|
||||
|
||||
PRESIGNED_URL = "presigned_url"
|
||||
BACKEND_TO_S3 = "backend_to_s3"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
|
||||
class FileToLLMMode(StrEnum):
|
||||
"""Defines the possible modes to send file to the LLM."""
|
||||
|
||||
PRESIGNED_URL = "presigned_url"
|
||||
BACKEND_BASE64 = "backend_base64"
|
||||
BACKEND_TEMPORARY_URL = "backend_temporary_url"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for generate_temporary_url utility function."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.agents.local_media_url_processors import generate_temporary_url
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_generate_temporary_url_returns_string():
|
||||
"""Test that generate_temporary_url returns a valid backend streaming URL."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test/file.pdf")
|
||||
|
||||
assert isinstance(url, str)
|
||||
assert url.startswith(settings.FILE_BACKEND_URL + "/api/v1.0/file-stream/")
|
||||
assert url.endswith("/")
|
||||
|
||||
|
||||
def test_generate_temporary_url_creates_cache_entry():
|
||||
"""Test that a cache entry is created with correct mapping."""
|
||||
cache.clear()
|
||||
s3_key = "conversation-id/attachments/file-uuid.pdf"
|
||||
url = generate_temporary_url(s3_key)
|
||||
|
||||
# Extract temporary key from URL
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Verify cache entry
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
cached_value = cache.get(cache_key)
|
||||
assert cached_value == s3_key
|
||||
|
||||
|
||||
def test_generate_temporary_url_unique_tokens():
|
||||
"""Test that different S3 keys produce different temporary tokens."""
|
||||
cache.clear()
|
||||
url1 = generate_temporary_url("file1.pdf")
|
||||
url2 = generate_temporary_url("file2.pdf")
|
||||
|
||||
assert url1 != url2
|
||||
|
||||
key1 = url1.split("/file-stream/")[1].rstrip("/")
|
||||
key2 = url2.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
assert cache.get(f"file_access:{key1}") == "file1.pdf"
|
||||
assert cache.get(f"file_access:{key2}") == "file2.pdf"
|
||||
|
||||
|
||||
def test_generate_temporary_url_token_is_url_safe():
|
||||
"""Test that generated tokens contain only URL-safe characters."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test.pdf")
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Token should only contain alphanumeric, dash, and underscore
|
||||
assert all(c.isalnum() or c in "-_" for c in temporary_key)
|
||||
|
||||
|
||||
def test_generate_temporary_url_token_sufficient_entropy():
|
||||
"""Test that generated tokens have sufficient entropy."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test.pdf")
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Token should be reasonably long
|
||||
assert len(temporary_key) >= 32
|
||||
|
||||
|
||||
def test_generate_temporary_url_no_sensitive_data_in_url():
|
||||
"""Test that temporary URLs don't contain S3 key information."""
|
||||
cache.clear()
|
||||
|
||||
s3_key = "secret/conversation-123/attachments/file.pdf"
|
||||
url = generate_temporary_url(s3_key)
|
||||
|
||||
# URL should not contain the actual S3 key
|
||||
assert "secret" not in url
|
||||
assert "conversation-123" not in url
|
||||
assert "file.pdf" not in url
|
||||
# Only the endpoint and random token
|
||||
assert "/api/v1.0/file-stream/" in url
|
||||
|
||||
|
||||
def test_generate_temporary_url_various_key_formats():
|
||||
"""Test generate_temporary_url with various S3 key formats."""
|
||||
cache.clear()
|
||||
|
||||
test_keys = [
|
||||
"simple/key.pdf",
|
||||
"conversation-123/attachments/file-uuid.pdf",
|
||||
"nested/folder/structure/file.jpg",
|
||||
"file_with_special-chars_123.png",
|
||||
]
|
||||
|
||||
urls = []
|
||||
for key in test_keys:
|
||||
url = generate_temporary_url(key)
|
||||
urls.append(url)
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
assert cache.get(f"file_access:{temporary_key}") == key
|
||||
|
||||
# All URLs should be different
|
||||
assert len(set(urls)) == len(urls)
|
||||
@@ -47,6 +47,7 @@ def test_api_config(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
|
||||
"FILE_UPLOAD_MODE": "presigned_url",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
@@ -189,6 +190,7 @@ async def test_api_config_async(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
|
||||
"FILE_UPLOAD_MODE": "presigned_url",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""URL configuration for the core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
|
||||
from activation_codes import viewsets as activation_viewsets
|
||||
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
|
||||
from chat.views import (
|
||||
ChatConversationAttachmentViewSet,
|
||||
ChatViewSet,
|
||||
FileStreamView,
|
||||
LLMConfigurationView,
|
||||
)
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -37,6 +43,21 @@ urlpatterns = [
|
||||
include(conversation_router.urls),
|
||||
),
|
||||
]
|
||||
+ (
|
||||
# Only allow file stream URL when configured for backend temporary URL mode
|
||||
[
|
||||
re_path(
|
||||
r"^file-stream/(?P<temporary_key>[a-zA-Z0-9_-]+)/$",
|
||||
FileStreamView.as_view(),
|
||||
name="file-stream",
|
||||
),
|
||||
]
|
||||
if (
|
||||
settings.FILE_TO_LLM_MODE == FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
or settings.ENVIRONMENT == "test"
|
||||
)
|
||||
else []
|
||||
)
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "conversations"
|
||||
version = "0.0.10"
|
||||
version = "0.0.12"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -23,7 +23,7 @@ description = "An application to chat with your own AI."
|
||||
keywords = ["Django", "AI", "Chatbot", "OpenAI", "Pydantic AI", "Conversations"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
requires-python = "~=3.13.0"
|
||||
dependencies = [
|
||||
"deprecated",
|
||||
"beautifulsoup4==4.14.2",
|
||||
@@ -46,6 +46,7 @@ dependencies = [
|
||||
"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",
|
||||
@@ -106,6 +107,14 @@ zip-safe = true
|
||||
[tool.distutils.bdist_wheel]
|
||||
universal = true
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-root = ""
|
||||
source-exclude = [
|
||||
"**/tests/**",
|
||||
"**/test_*.py",
|
||||
"**/tests.py",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".git",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Setup file for the conversations module. All configuration stands in the setup.cfg file."""
|
||||
# coding: utf-8
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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
|
||||
Generated
+3106
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,16 @@ export const commonTokenOverrides = {
|
||||
};
|
||||
|
||||
export const commonGlobals = {
|
||||
components: {
|
||||
'la-gaufre': false,
|
||||
'home-proconnect': false,
|
||||
logo: {
|
||||
src: '',
|
||||
alt: '',
|
||||
widthHeader: '',
|
||||
widthFooter: '',
|
||||
},
|
||||
},
|
||||
font: {
|
||||
sizes: {
|
||||
xs: '0.75rem',
|
||||
@@ -480,6 +490,18 @@ 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',
|
||||
@@ -810,7 +832,6 @@ export const dsfrGlobals = {
|
||||
'white-950': '#F6F8F9F2',
|
||||
'white-975': '#F6F8F9F9',
|
||||
},
|
||||
...commonGlobals,
|
||||
};
|
||||
|
||||
const whiteLabelThemes = getThemesFromGlobals(whiteLabelGlobals, {
|
||||
@@ -820,7 +841,6 @@ const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
|
||||
overrides: commonTokenOverrides,
|
||||
});
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
@@ -40,7 +41,7 @@
|
||||
"lottie-react": "^2.4.1",
|
||||
"luxon": "3.6.1",
|
||||
"micromark-extension-llm-math": "3.1.1-20250610",
|
||||
"next": "15.3.8",
|
||||
"next": "15.3.9",
|
||||
"posthog-js": "1.249.3",
|
||||
"react": "19.2.1",
|
||||
"react-aria-components": "1.9.0",
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Box, BoxType } from '.';
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
$css,
|
||||
...props
|
||||
}: PropsWithChildren<BoxType>) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
|
||||
return (
|
||||
<Box
|
||||
className={`--docs--card ${props.className || ''}`}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const DropdownMenu = ({
|
||||
label,
|
||||
topMessage,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
|
||||
const blockButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,16 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import styled, { RuleSet } from 'styled-components';
|
||||
|
||||
export interface LinkProps {
|
||||
interface StyledLinkProps {
|
||||
$css?: string | RuleSet<object>;
|
||||
}
|
||||
|
||||
export const StyledLink = styled(Link)<LinkProps>`
|
||||
const Anchor = styled.a<StyledLinkProps>`
|
||||
text-decoration: none;
|
||||
color: #ffffff;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
|
||||
cursor: pointer;
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css}` : $css)}
|
||||
`;
|
||||
|
||||
interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
$css?: string | RuleSet<object>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link that avoids re-renders from Next.js router context.
|
||||
*
|
||||
* Use instead of Next.js `Link` in large lists (sidebars, tables) where
|
||||
* router-triggered re-renders cause performance issues.
|
||||
*
|
||||
* Warning: No automatic prefetching.
|
||||
*
|
||||
*/
|
||||
export const StyledLink = memo(function StyledLink({
|
||||
href,
|
||||
onClick,
|
||||
...props
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
|
||||
// avoid rerenders
|
||||
useEffect(() => {
|
||||
routerRef.current = router;
|
||||
}, [router]);
|
||||
|
||||
// Memoized click handler to maintain stable reference across re-renders.
|
||||
// Necessary for memo() to work correctly
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Allow default browser behavior for modifier keys (new tab, etc.)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
routerRef.current.push(href);
|
||||
},
|
||||
[href, onClick],
|
||||
);
|
||||
|
||||
return <Anchor href={href} onClick={handleClick} {...props} />;
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Box, Text, TextType } from '@/components';
|
||||
|
||||
const AlertStyled = styled(Alert)`
|
||||
& .c__button--tertiary:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-200);
|
||||
background-color: var(--c--theme--colors--gray-200);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { StyledLink } from '../Link';
|
||||
|
||||
const mockPush = jest.fn();
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyledLink', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a link with the correct href', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/test-path');
|
||||
});
|
||||
|
||||
it('should navigate using router.push on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
await user.click(link);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path');
|
||||
});
|
||||
|
||||
it('should call onClick prop when clicked', async () => {
|
||||
const handleClick = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StyledLink href="/test-path" onClick={handleClick}>
|
||||
Test Link
|
||||
</StyledLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
await user.click(link);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when meta key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { metaKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when ctrl key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { ctrlKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when shift key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { shiftKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when alt key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { altKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass additional props to the anchor element', () => {
|
||||
render(
|
||||
<StyledLink
|
||||
href="/test-path"
|
||||
data-testid="custom-link"
|
||||
className="custom"
|
||||
>
|
||||
Test Link
|
||||
</StyledLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('custom-link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -64,8 +64,8 @@ export const QuickSearch = ({
|
||||
bottom: -20px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--c--contextuals--background--surface--tertiary) 0%,
|
||||
transparent 100%
|
||||
rgba(var(--c--contextuals--background--surface--tertiary), 1),
|
||||
rgba(var(--c--contextuals--background--surface--tertiary), 0)
|
||||
);
|
||||
}
|
||||
${
|
||||
|
||||
@@ -101,6 +101,9 @@ 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 { colorsTokens, spacingsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ConfigResponse {
|
||||
MEDIA_BASE_URL?: string;
|
||||
POSTHOG_KEY?: PostHogConf;
|
||||
SENTRY_DSN?: string;
|
||||
FILE_UPLOAD_MODE?: string;
|
||||
theme_customization?: ThemeCustomization;
|
||||
chat_upload_accept?: string;
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
import { useCunninghamTheme } from '../useCunninghamTheme';
|
||||
|
||||
describe('<useCunninghamTheme />', () => {
|
||||
it('has the logo correctly set', () => {
|
||||
expect(useCunninghamTheme.getState().componentTokens.logo?.src).toBe('');
|
||||
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');
|
||||
|
||||
// Change theme
|
||||
useCunninghamTheme.getState().setTheme('dsfr');
|
||||
|
||||
const { componentTokens } = useCunninghamTheme.getState();
|
||||
const logo = componentTokens.logo;
|
||||
expect(logo?.src).toBe('/assets/logo-gouv.svg');
|
||||
expect(logo?.widthHeader).toBe('110px');
|
||||
expect(logo?.widthFooter).toBe('220px');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,6 +405,12 @@
|
||||
--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
|
||||
);
|
||||
@@ -1293,6 +1299,12 @@
|
||||
--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
|
||||
);
|
||||
@@ -1775,6 +1787,64 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -2103,58 +2173,6 @@
|
||||
--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
|
||||
);
|
||||
@@ -2637,6 +2655,64 @@
|
||||
}
|
||||
|
||||
.cunningham-theme--dsfr-dark {
|
||||
--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: #95abff;
|
||||
--c--globals--colors--logo-2: #e78087;
|
||||
--c--globals--colors--brand-050: #edf0ff;
|
||||
@@ -2965,58 +3041,6 @@
|
||||
--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
|
||||
);
|
||||
|
||||
@@ -422,6 +422,11 @@ export const tokens = {
|
||||
mobile: '768px',
|
||||
tablet: '1024px',
|
||||
},
|
||||
components: {
|
||||
'la-gaufre': false,
|
||||
'home-proconnect': false,
|
||||
logo: { src: '', alt: '', widthHeader: '', widthFooter: '' },
|
||||
},
|
||||
},
|
||||
contextuals: {
|
||||
background: {
|
||||
@@ -1095,6 +1100,11 @@ export const tokens = {
|
||||
mobile: '768px',
|
||||
tablet: '1024px',
|
||||
},
|
||||
components: {
|
||||
'la-gaufre': false,
|
||||
'home-proconnect': false,
|
||||
logo: { src: '', alt: '', widthHeader: '', widthFooter: '' },
|
||||
},
|
||||
},
|
||||
contextuals: {
|
||||
background: {
|
||||
@@ -1348,6 +1358,74 @@ 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',
|
||||
@@ -1678,64 +1756,6 @@ 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: {
|
||||
@@ -1989,6 +2009,74 @@ export const tokens = {
|
||||
},
|
||||
'dsfr-dark': {
|
||||
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': '#95ABFF',
|
||||
'logo-2': '#E78087',
|
||||
@@ -2319,64 +2407,6 @@ 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: {
|
||||
|
||||
@@ -9,7 +9,11 @@ type Tokens = typeof tokens.themes.default &
|
||||
type ColorsTokens = Tokens['globals']['colors'];
|
||||
type FontSizesTokens = Tokens['globals']['font']['sizes'];
|
||||
type SpacingsTokens = Tokens['globals']['spacings'];
|
||||
type ComponentTokens = Tokens['components'];
|
||||
type ComponentTokens = Partial<
|
||||
| (Tokens['components'] & Tokens['globals']['components'])
|
||||
| Record<string, unknown>
|
||||
> &
|
||||
Record<string, unknown>;
|
||||
type ContextualTokens = Tokens['contextuals'];
|
||||
export type Theme = keyof typeof tokens.themes;
|
||||
|
||||
@@ -31,12 +35,23 @@ 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);
|
||||
|
||||
const initialState: ThemeStore = {
|
||||
colorsTokens: defaultTokens.globals.colors,
|
||||
componentTokens: defaultTokens.components,
|
||||
componentTokens: getComponentTokens(defaultTokens),
|
||||
contextualTokens: defaultTokens.contextuals,
|
||||
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
|
||||
fontSizesTokens: defaultTokens.globals.font.sizes,
|
||||
@@ -57,7 +72,7 @@ export const useCunninghamTheme = create<ThemeStore>()(
|
||||
|
||||
set({
|
||||
colorsTokens: newTokens.globals.colors,
|
||||
componentTokens: newTokens.components,
|
||||
componentTokens: getComponentTokens(newTokens),
|
||||
contextualTokens: newTokens.contextuals,
|
||||
currentTokens: tokens.themes[theme] as Partial<Tokens>,
|
||||
fontSizesTokens: newTokens.globals.font.sizes,
|
||||
@@ -81,7 +96,7 @@ export const useCunninghamTheme = create<ThemeStore>()(
|
||||
|
||||
return {
|
||||
colorsTokens: newTokens.globals.colors,
|
||||
componentTokens: newTokens.components,
|
||||
componentTokens: getComponentTokens(newTokens),
|
||||
contextualTokens: newTokens.contextuals,
|
||||
currentTokens: tokens.themes[newTheme] as Partial<Tokens>,
|
||||
fontSizesTokens: newTokens.globals.font.sizes,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { baseApiUrl, fetchAPI, getCSRFToken } from '@/api';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { useCreateConversationAttachment } from '../api';
|
||||
|
||||
interface BackendUploadResponse {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file, using XHR so we can report on progress through a handler.
|
||||
* @param url The pre-signed URL to PUT the file to.
|
||||
* @param file The raw file to upload as the request body.
|
||||
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||
*/
|
||||
export const uploadFileToServer = (
|
||||
url: string,
|
||||
@@ -47,6 +52,71 @@ export const uploadFileToServer = (
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload a file to the backend (for backend_base64 and backend_temporary_url modes).
|
||||
* Uses XHR to track upload progress while respecting the project's API patterns.
|
||||
* @param conversationId The ID of the conversation.
|
||||
* @param file The file to upload.
|
||||
* @param progressHandler A handler that receives progress updates.
|
||||
*/
|
||||
export const uploadFileToBackend = (
|
||||
conversationId: string,
|
||||
file: File,
|
||||
progressHandler: (progress: number) => void,
|
||||
): Promise<BackendUploadResponse> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file_name', file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const csrfToken = getCSRFToken();
|
||||
|
||||
xhr.addEventListener('error', reject);
|
||||
xhr.addEventListener('abort', reject);
|
||||
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 201) {
|
||||
progressHandler(100);
|
||||
try {
|
||||
const response = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as BackendUploadResponse;
|
||||
return resolve(response);
|
||||
} catch {
|
||||
return reject(new Error('Failed to parse server response'));
|
||||
}
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to upload file to backend: ${xhr.status} ${xhr.statusText}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
progressHandler(
|
||||
Math.floor((progressEvent.loaded / progressEvent.total) * 100),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Use the project's baseApiUrl to construct the endpoint consistently
|
||||
const apiUrl = `${baseApiUrl('1.0')}chats/${conversationId}/attachments/backend-upload/`;
|
||||
xhr.open('POST', apiUrl);
|
||||
|
||||
// Add authentication headers following the project's pattern
|
||||
xhr.withCredentials = true;
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
export const useUploadFile = (conversationId: string) => {
|
||||
const {
|
||||
mutateAsync: createConversationAttachment,
|
||||
@@ -54,8 +124,25 @@ export const useUploadFile = (conversationId: string) => {
|
||||
error: errorAttachment,
|
||||
} = useCreateConversationAttachment();
|
||||
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File, progressHandler?: (progress: number) => void) => {
|
||||
// Backend mode backend_to_s3 file is sent to API backend
|
||||
if (conf?.FILE_UPLOAD_MODE === 'backend_to_s3') {
|
||||
// Upload file to backend (backend handles S3 storage, MIME detection, and malware scanning)
|
||||
const finalAttachment = await uploadFileToBackend(
|
||||
conversationId,
|
||||
file,
|
||||
(progress) => {
|
||||
progressHandler?.(progress);
|
||||
},
|
||||
);
|
||||
|
||||
return `/media-key/${finalAttachment.key}`;
|
||||
}
|
||||
|
||||
// Presigned URL mode (default): frontend uploads directly to S3
|
||||
const attachment = await createConversationAttachment({
|
||||
conversationId,
|
||||
content_type: file.type,
|
||||
@@ -83,7 +170,7 @@ export const useUploadFile = (conversationId: string) => {
|
||||
|
||||
return `/media-key/${attachment.key}`;
|
||||
},
|
||||
[createConversationAttachment, conversationId],
|
||||
[createConversationAttachment, conversationId, conf],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -232,13 +232,21 @@ export const ActivationPage = () => {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid ${error ? 'var(--c--theme--colors--danger-600)' : 'var(--c--theme--colors--greyscale-150)'};
|
||||
border: 1px solid ${
|
||||
error
|
||||
? 'var(--c--contextuals--border--semantic--error--primary)'
|
||||
: 'var(--c--contextuals--border--semantic--neutral--tertiary)'
|
||||
};
|
||||
border-radius: 4px;
|
||||
fontSize: 14px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
|
||||
|
||||
&:focus {
|
||||
border: 1px solid ${error ? 'var(--c--theme--colors--danger-600)' : 'var(--c--theme--colors--greyscale-150)'};
|
||||
border: 1px solid ${
|
||||
error
|
||||
? 'var(--c--contextuals--border--semantic--error--primary)'
|
||||
: 'var(--c--contextuals--border--semantic--neutral--tertiary)'
|
||||
};
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -258,8 +266,8 @@ export const ActivationPage = () => {
|
||||
{error && (
|
||||
<Text
|
||||
$size="xs"
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$theme="error"
|
||||
$variation="tertiary"
|
||||
$margin={{ top: '4px' }}
|
||||
>
|
||||
{error}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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) => {
|
||||
@@ -36,10 +39,46 @@ 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'>) {
|
||||
return useAiSdkChat({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const result = 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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--theme--colors--greyscale-050)"
|
||||
$background="var(--c--contextuals--background--semantic--neutral--tertiary)"
|
||||
$width="200px"
|
||||
$direction="row"
|
||||
$gap="8px"
|
||||
@@ -66,7 +66,7 @@ export const AttachmentList = ({
|
||||
>
|
||||
{/* Extension du fichier */}
|
||||
<Box
|
||||
$background="var(--c--theme--colors--greyscale-200)"
|
||||
$background="var(--c--contextuals--background--palette--gray--primary)"
|
||||
$width="22px"
|
||||
$height="22px"
|
||||
$direction="row"
|
||||
@@ -74,11 +74,11 @@ export const AttachmentList = ({
|
||||
$justify="center"
|
||||
$css={`
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$color="var(--c--globals--colors--gray-700)"
|
||||
$color="var(--c--contextuals--content--semantic--overlay--primary)"
|
||||
$weight="500"
|
||||
$css={`
|
||||
font-size: 7px;
|
||||
@@ -90,7 +90,7 @@ export const AttachmentList = ({
|
||||
<Text
|
||||
$size="xs"
|
||||
$variation="500"
|
||||
$color="var(--c--globals--colors--gray-850)"
|
||||
$color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
$css={`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -105,8 +105,11 @@ export const AttachmentList = ({
|
||||
{name}
|
||||
</Text>
|
||||
{!isReadOnly && onRemove && (
|
||||
<Box
|
||||
role="button"
|
||||
<Button
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
className="c__button--without-padding"
|
||||
tabIndex={0}
|
||||
aria-label={t('Remove attachment')}
|
||||
onClick={removeAttachment}
|
||||
@@ -116,22 +119,9 @@ 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" $theme="greyscale" $size="18px" />
|
||||
</Box>
|
||||
<Icon iconName="close" $size="18px" />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
Message,
|
||||
ReasoningUIPart,
|
||||
SourceUIPart,
|
||||
ToolInvocationUIPart,
|
||||
} from '@ai-sdk/ui-utils';
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
||||
import { 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, useRef, useState } from 'react';
|
||||
@@ -884,7 +879,7 @@ export const Chat = ({
|
||||
$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' : ''}`}
|
||||
className={`c__button c__button--brand c__button--brand--tertiary c__button--nano ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
|
||||
onClick={() => openSources(message.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
|
||||
@@ -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="#1C1B1F"
|
||||
fill="var(--c--contextuals--content--semantic--neutral--secondary)"
|
||||
/>
|
||||
</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="#1C1B1F"
|
||||
fill="var(--c--contextuals--content--semantic--neutral--secondary)"
|
||||
/>
|
||||
</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="#3E5DE7"
|
||||
fill="var(--c--contextuals--content--semantic--brand--tertiary)"
|
||||
/>
|
||||
</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="#3E5DE7"
|
||||
fill="var(--c--contextuals--content--semantic--brand--tertiary)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useToast } from '@/components/ToastProvider';
|
||||
import { FeatureFlagState, useConfig } from '@/core';
|
||||
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
|
||||
import { InputChatActions } from '@/features/chat/components/InputChatAction';
|
||||
import { SuggestionCarousel } from '@/features/chat/components/SuggestionCarousel';
|
||||
import { WelcomeMessage } from '@/features/chat/components/WelcomeMessage';
|
||||
import { useFileDragDrop } from '@/features/chat/hooks/useFileDragDrop';
|
||||
import { useFileUrls } from '@/features/chat/hooks/useFileUrls';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import FilesIcon from '../assets/files.svg';
|
||||
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { ScrollDown } from './ScrollDown';
|
||||
import { SendButton } from './SendButton';
|
||||
|
||||
interface InputChatProps {
|
||||
messagesLength: number;
|
||||
@@ -34,6 +42,76 @@ interface InputChatProps {
|
||||
isUploadingFiles?: boolean;
|
||||
}
|
||||
|
||||
const STYLES = {
|
||||
form: { width: '100%' },
|
||||
formPadding: { bottom: 'base' },
|
||||
formPaddingMobile: { bottom: '' },
|
||||
attachmentMargin: { horizontal: '0', bottom: 'xs', top: 'xs' },
|
||||
attachmentPadding: { horizontal: 'base' },
|
||||
horizontalPadding: { horizontal: 'base' },
|
||||
} as const;
|
||||
|
||||
const CONTAINER_CSS = `
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 750px;
|
||||
`;
|
||||
|
||||
const INPUT_BOX_CSS = `
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--c--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,
|
||||
@@ -55,14 +133,13 @@ export const InputChat = ({
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
const { isDesktop, isMobile } = useResponsiveStore();
|
||||
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
|
||||
|
||||
const { data: conf } = useConfig();
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isFileAccepted = useCallback(
|
||||
(file: File): boolean => {
|
||||
@@ -87,13 +164,6 @@ export const InputChat = ({
|
||||
[conf?.chat_upload_accept],
|
||||
);
|
||||
|
||||
const suggestions = [
|
||||
t('Ask a question'),
|
||||
t('Turn this list into bullet points'),
|
||||
t('Write a short product description'),
|
||||
t('Find recent news about...'),
|
||||
];
|
||||
|
||||
const showToastError = useCallback(() => {
|
||||
showToast(
|
||||
'error',
|
||||
@@ -131,32 +201,6 @@ export const InputChat = ({
|
||||
setFileUploadEnabled(isFeatureEnabled('document-upload'));
|
||||
}, [conf, isFeatureFlagActivated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesLength === 0) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSuggestionIndex((prev) => {
|
||||
if (prev === suggestions.length - 1) {
|
||||
return suggestions.length;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [messagesLength, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSuggestionIndex === suggestions.length) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsResetting(true);
|
||||
setCurrentSuggestionIndex(0);
|
||||
setTimeout(() => setIsResetting(false), 50);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [currentSuggestionIndex, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && messagesLength === 0 && status === 'ready') {
|
||||
textareaRef.current.focus();
|
||||
@@ -169,73 +213,118 @@ export const InputChat = ({
|
||||
}
|
||||
}, [status, input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
const handleFilesAccepted = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setIsDragActive(true);
|
||||
}
|
||||
};
|
||||
// Keep existing files
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f) => dt.items.add(f));
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only hide when leaving the window completely
|
||||
if (!e.relatedTarget) {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check for rejected files during drag over (does not work on Safari)
|
||||
if (e.dataTransfer?.items) {
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
items.some((item) => {
|
||||
if (item.kind === 'file') {
|
||||
// Check file type
|
||||
const type = item.type;
|
||||
const dummyFile = new File([], '', { type });
|
||||
return !isFileAccepted(dummyFile);
|
||||
// Add new files (avoiding duplicates)
|
||||
acceptedFiles.forEach((f) => {
|
||||
const isDuplicate = Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
);
|
||||
if (!isDuplicate) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return dt.files;
|
||||
});
|
||||
},
|
||||
[setFiles],
|
||||
);
|
||||
|
||||
const { isDragActive } = useFileDragDrop({
|
||||
enabled: fileUploadEnabled,
|
||||
isFileAccepted,
|
||||
onFilesAccepted: handleFilesAccepted,
|
||||
onFilesRejected: () => showToastError(),
|
||||
});
|
||||
|
||||
const isInputDisabled = status !== 'ready' || isUploadingFiles;
|
||||
|
||||
const containerCss = useMemo(
|
||||
() => `
|
||||
${CONTAINER_CSS}
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
`,
|
||||
[isDesktop],
|
||||
);
|
||||
|
||||
const textareaStyle = useMemo(
|
||||
() => ({
|
||||
...TEXTAREA_STYLE,
|
||||
opacity: status === 'error' ? '0.5' : '1',
|
||||
}),
|
||||
[status],
|
||||
);
|
||||
|
||||
const formPadding = isDesktop ? STYLES.formPadding : STYLES.formPaddingMobile;
|
||||
|
||||
// handlers
|
||||
const handleTextareaChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
},
|
||||
[handleInputChange],
|
||||
);
|
||||
|
||||
const handleTextareaKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
const handleAttachClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
if (!fileUploadEnabled) {
|
||||
const handleWebSearchToggle = useCallback(() => {
|
||||
onToggleWebSearch?.();
|
||||
textareaRef.current?.focus();
|
||||
}, [onToggleWebSearch]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
|
||||
Array.from(droppedFiles).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (acceptedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
@@ -256,67 +345,53 @@ export const InputChat = ({
|
||||
return dt.files;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
window.addEventListener('dragleave', handleDragLeave);
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
e.target.value = '';
|
||||
},
|
||||
[isFileAccepted, showToastError, setFiles],
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [
|
||||
fileUploadEnabled,
|
||||
setFiles,
|
||||
showToastError,
|
||||
conf?.chat_upload_accept,
|
||||
isFileAccepted,
|
||||
]);
|
||||
const handleAttachmentRemove = useCallback(
|
||||
(index: number) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInputDisabled = status !== 'ready' || isUploadingFiles;
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
},
|
||||
[files, setFiles],
|
||||
);
|
||||
|
||||
const fileUrlMap = useFileUrls(files);
|
||||
|
||||
const attachments = useMemo(() => {
|
||||
if (!files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(files).map((file) => {
|
||||
const key = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
return {
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: fileUrlMap.get(key) || '',
|
||||
};
|
||||
});
|
||||
}, [files, fileUrlMap]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
$position="fixed"
|
||||
$css={`
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
animation: fadeIn 0.3s;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
pointer-events: all;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
$css={`
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
{isDragActive && <Box $position="fixed" $css={DRAG_FADE_CSS} />}
|
||||
<Box $css={containerCss}>
|
||||
{/* Bouton de scroll vers le bas */}
|
||||
{messagesLength > 1 && containerRef && onScrollToBottom && (
|
||||
<Box
|
||||
$css={`
|
||||
position: relative;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
<Box $css={SCROLL_DOWN_WRAPPER_CSS}>
|
||||
<ScrollDown
|
||||
onClick={onScrollToBottom}
|
||||
containerRef={containerRef}
|
||||
@@ -324,48 +399,16 @@ export const InputChat = ({
|
||||
</Box>
|
||||
)}
|
||||
{/* Message de bienvenue */}
|
||||
{messagesLength === 0 && (
|
||||
<Box
|
||||
$padding={{ all: 'base', bottom: 'md' }}
|
||||
$align="center"
|
||||
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
|
||||
>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{messagesLength === 0 && <WelcomeMessage />}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(fileUploadEnabled);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
// File handling is now done by global handler
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
|
||||
<form onSubmit={handleSubmit} style={STYLES.form}>
|
||||
<Box $padding={formPadding}>
|
||||
<Box
|
||||
$flex={1}
|
||||
$radius="12px"
|
||||
$position="relative"
|
||||
$background="white"
|
||||
$css={`
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
position: relative;
|
||||
background: var(--c--contextuals--background--surface--primary,);
|
||||
transition: all 0.2s ease;
|
||||
`}
|
||||
$css={INPUT_BOX_CSS}
|
||||
>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
@@ -374,16 +417,7 @@ export const InputChat = ({
|
||||
$direction="row"
|
||||
$justify="center"
|
||||
$gap="1rem"
|
||||
$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);
|
||||
`}
|
||||
$css={FILE_DROP_CSS}
|
||||
>
|
||||
<FilesIcon />
|
||||
<Box>
|
||||
@@ -407,88 +441,14 @@ export const InputChat = ({
|
||||
aria-label={t('Enter your message or a question')}
|
||||
value={input ?? ''}
|
||||
name="inputchat-textarea"
|
||||
onChange={(e) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.focus();
|
||||
}}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
disabled={isInputDisabled}
|
||||
rows={1}
|
||||
style={{
|
||||
padding: '1rem 1.5rem',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
opacity: status === 'error' ? '0.5' : '1',
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '64px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--c--theme--colors--greyscale-800)',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
|
||||
{!input && (
|
||||
<Box
|
||||
$css={`
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--c--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 && <SuggestionCarousel messagesLength={messagesLength} />}
|
||||
|
||||
<input
|
||||
accept={conf?.chat_upload_accept}
|
||||
@@ -496,231 +456,37 @@ export const InputChat = ({
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
}
|
||||
|
||||
if (acceptedFiles.length === 0) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f: File) => dt.items.add(f));
|
||||
}
|
||||
acceptedFiles.forEach((f: File) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
return dt.files;
|
||||
});
|
||||
|
||||
e.target.value = '';
|
||||
}}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/*Aperçu des fichiers*/}
|
||||
{files && files.length > 0 && (
|
||||
<Box
|
||||
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={STYLES.attachmentMargin}
|
||||
$padding={STYLES.attachmentPadding}
|
||||
>
|
||||
<AttachmentList
|
||||
attachments={Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
}))}
|
||||
onRemove={(index) => {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
}}
|
||||
attachments={attachments}
|
||||
onRemove={handleAttachmentRemove}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="sm"
|
||||
$padding={{ bottom: 'base' }}
|
||||
$align="space-between"
|
||||
$css={`
|
||||
opacity: ${status === 'error' ? '0.5' : '1'};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$flex="1"
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Button
|
||||
size="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>
|
||||
<InputChatActions
|
||||
fileUploadEnabled={fileUploadEnabled}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
isUploadingFiles={isUploadingFiles}
|
||||
isMobile={isMobile}
|
||||
forceWebSearch={forceWebSearch}
|
||||
onAttachClick={handleAttachClick}
|
||||
onWebSearchToggle={
|
||||
onToggleWebSearch ? handleWebSearchToggle : undefined
|
||||
}
|
||||
onModelSelect={onModelSelect}
|
||||
selectedModel={selectedModel || null}
|
||||
status={status}
|
||||
inputHasContent={Boolean(input?.trim())}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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';
|
||||
@@ -39,6 +39,7 @@ export const SendButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
className="c__button--send"
|
||||
aria-label={t('Send')}
|
||||
disabled={disabled || status === 'error'}
|
||||
variant="primary"
|
||||
@@ -46,15 +47,15 @@ export const SendButton = ({
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
size="nano"
|
||||
type="button"
|
||||
aria-label={t('Stop')}
|
||||
disabled={false}
|
||||
onClick={onClick}
|
||||
className="c__button--stop"
|
||||
className="c__button--stop bg-semantic-neutral-primary"
|
||||
icon={<StopIcon />}
|
||||
>
|
||||
<Text $theme="gray" $variation="000">
|
||||
<Text $theme="neutral" $variation="on-neutral">
|
||||
{t('Stop')}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, StyledLink } from '@/components';
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
title: {
|
||||
color: '#222631',
|
||||
color: 'var(--c--contextuals--content--semantic--neutral--primary)',
|
||||
fontWeight: '500',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
@@ -173,10 +173,10 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
|
||||
text-overflow: ellipsis;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
&:hover {
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
background-color: #EEF1F4;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
background-color: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
import { Box } from '@/components';
|
||||
import { SourceItem } from '@/features/chat/components/SourceItem';
|
||||
|
||||
interface SourceMetadata {
|
||||
export interface SourceMetadata {
|
||||
title: string | null;
|
||||
favicon: string | null;
|
||||
loading: boolean;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
const SUGGESTION_KEYS = [
|
||||
'Ask a question',
|
||||
'Turn this list into bullet points',
|
||||
'Write a short product description',
|
||||
'Find recent news about...',
|
||||
] as const;
|
||||
const SUGGESTIONS_COUNT = SUGGESTION_KEYS.length;
|
||||
|
||||
const WRAPPER_CSS = `position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;`;
|
||||
|
||||
const ITEM_CSS = `
|
||||
height: calc(100% / ${SUGGESTIONS_COUNT + 1});
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
interface SuggestionCarouselProps {
|
||||
messagesLength: number;
|
||||
}
|
||||
|
||||
export const SuggestionCarousel = ({
|
||||
messagesLength,
|
||||
}: SuggestionCarouselProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTION_KEYS.map((key) => t(key)), [t]);
|
||||
const carouselSuggestions = useMemo(
|
||||
() => [...suggestions, suggestions[0]],
|
||||
[suggestions],
|
||||
);
|
||||
|
||||
const carrouselCss = useMemo(
|
||||
() => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: ${(SUGGESTIONS_COUNT + 1) * 100}%;
|
||||
transform: translateY(-${currentSuggestionIndex * (100 / (SUGGESTIONS_COUNT + 1))}%);
|
||||
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
|
||||
`,
|
||||
[currentSuggestionIndex, isResetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesLength === 0) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSuggestionIndex((prev) => {
|
||||
if (prev === SUGGESTIONS_COUNT - 1) {
|
||||
return SUGGESTIONS_COUNT;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [messagesLength]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSuggestionIndex === suggestions.length) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsResetting(true);
|
||||
setCurrentSuggestionIndex(0);
|
||||
setTimeout(() => setIsResetting(false), 50);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [currentSuggestionIndex, suggestions.length]);
|
||||
|
||||
return (
|
||||
<Box $css={WRAPPER_CSS}>
|
||||
<Box $css={carrouselCss}>
|
||||
{carouselSuggestions.map((suggestion, index) => (
|
||||
<Box key={index} $css={ITEM_CSS}>
|
||||
{suggestion}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
SuggestionCarousel.displayName = 'SuggestionCarousel';
|
||||
+19
-12
@@ -18,7 +18,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (toolInvocation.toolName === 'document_parsing') {
|
||||
if (toolInvocation.state === 'partial-call') {
|
||||
if (
|
||||
toolInvocation.state === 'partial-call' ||
|
||||
toolInvocation.state === 'result'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,24 +35,28 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
)
|
||||
? documents.map((doc) => doc.identifier)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="pre"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="6px"
|
||||
key={toolInvocation.toolCallId}
|
||||
$background="var(--c--theme--colors--greyscale-100)"
|
||||
$color="var(--c--theme--colors--greyscale-500)"
|
||||
$width="100%"
|
||||
$maxWidth="750px"
|
||||
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
|
||||
$background="var(--c--contextuals--background--surface--tertiary)"
|
||||
$color="var(--c--contextuals--content--semantic--neutral--secondary)"
|
||||
$padding={{ all: 'sm' }}
|
||||
$radius="8px"
|
||||
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
|
||||
>
|
||||
{toolInvocation.state === 'result' ? (
|
||||
<Text>{`Parsing done: ${documentIdentifiers.join(', ')}`}</Text>
|
||||
) : (
|
||||
<Box $direction="row" $gap="1rem" $align="center">
|
||||
<Loader />
|
||||
<Text>{`Parsing documents: ${documentIdentifiers.join(', ')} ...`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Loader />
|
||||
<Text $variation="600" $size="md">
|
||||
{t('Extracting documents: {{documents}} ...', {
|
||||
documents: documentIdentifiers.join(', '),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
|
||||
const WELCOME_MARGIN = {
|
||||
horizontal: 'base',
|
||||
bottom: 'md',
|
||||
top: '-105px',
|
||||
} as const;
|
||||
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
|
||||
|
||||
export const WelcomeMessage = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
WelcomeMessage.displayName = 'WelcomeMessage';
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { InputChat } from '../InputChat';
|
||||
|
||||
// Mock stores and hooks
|
||||
jest.mock('@/stores', () => ({
|
||||
useResponsiveStore: () => ({
|
||||
isDesktop: true,
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/core', () => ({
|
||||
useConfig: () => ({
|
||||
data: {
|
||||
FEATURE_FLAGS: {
|
||||
'web-search': 'enabled',
|
||||
'document-upload': 'enabled',
|
||||
},
|
||||
chat_upload_accept: '.pdf,.txt',
|
||||
},
|
||||
}),
|
||||
FeatureFlagState: {
|
||||
ENABLED: 'enabled',
|
||||
DISABLED: 'disabled',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/libs', () => ({
|
||||
useAnalytics: () => ({
|
||||
isFeatureFlagActivated: jest.fn(() => true),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ToastProvider', () => ({
|
||||
useToast: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({
|
||||
useFileDragDrop: () => ({
|
||||
isDragActive: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/hooks/useFileUrls', () => ({
|
||||
useFileUrls: () => new Map(),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
jest.mock('../InputChatAction', () => ({
|
||||
InputChatActions: () => <div data-testid="input-chat-actions">Actions</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../SuggestionCarousel', () => ({
|
||||
SuggestionCarousel: () => (
|
||||
<div data-testid="suggestion-carousel">Suggestions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../WelcomeMessage', () => ({
|
||||
WelcomeMessage: () => <div data-testid="welcome-message">Welcome</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../AttachmentList', () => ({
|
||||
AttachmentList: () => <div data-testid="attachment-list">Attachments</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../ScrollDown', () => ({
|
||||
ScrollDown: () => <div data-testid="scroll-down">Scroll Down</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../../assets/files.svg', () => () => (
|
||||
<svg data-testid="files-icon" />
|
||||
));
|
||||
|
||||
const defaultProps = {
|
||||
messagesLength: 0,
|
||||
input: '',
|
||||
handleInputChange: jest.fn(),
|
||||
handleSubmit: jest.fn(),
|
||||
status: 'ready' as const,
|
||||
files: null,
|
||||
setFiles: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InputChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the textarea', () => {
|
||||
render(<InputChat {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render welcome message when messagesLength is 0', () => {
|
||||
render(<InputChat {...defaultProps} messagesLength={0} />);
|
||||
|
||||
expect(screen.getByTestId('welcome-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render welcome message when messagesLength > 0', () => {
|
||||
render(<InputChat {...defaultProps} messagesLength={1} />);
|
||||
|
||||
expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render suggestion carousel when input is empty', () => {
|
||||
render(<InputChat {...defaultProps} input="" />);
|
||||
|
||||
expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render suggestion carousel when input has content', () => {
|
||||
render(<InputChat {...defaultProps} input="Hello" />);
|
||||
|
||||
expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render input chat actions', () => {
|
||||
render(<InputChat {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleInputChange when typing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleInputChange = jest.fn();
|
||||
render(
|
||||
<InputChat {...defaultProps} handleInputChange={handleInputChange} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, 'Hello');
|
||||
|
||||
expect(handleInputChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable textarea when status is not ready', () => {
|
||||
render(<InputChat {...defaultProps} status="streaming" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable textarea when isUploadingFiles is true', () => {
|
||||
render(<InputChat {...defaultProps} isUploadingFiles={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should submit form when pressing Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = jest.fn((e) => e.preventDefault());
|
||||
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, '{Enter}');
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not submit form when pressing Shift+Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = jest.fn((e) => e.preventDefault());
|
||||
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, '{Shift>}{Enter}{/Shift}');
|
||||
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { InputChatActions } from '../InputChatAction';
|
||||
|
||||
jest.mock('../ModelSelector', () => ({
|
||||
ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => (
|
||||
<button onClick={onModelSelect} data-testid="model-selector">
|
||||
Model Selector
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../SendButton', () => ({
|
||||
SendButton: ({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-testid="send-button">
|
||||
Send
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
fileUploadEnabled: true,
|
||||
webSearchEnabled: true,
|
||||
isUploadingFiles: false,
|
||||
isMobile: false,
|
||||
forceWebSearch: false,
|
||||
onAttachClick: jest.fn(),
|
||||
selectedModel: null,
|
||||
status: null,
|
||||
inputHasContent: true,
|
||||
};
|
||||
|
||||
describe('InputChatActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render attach file button', () => {
|
||||
render(<InputChatActions {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Attach file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onAttachClick when attach button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAttachClick = jest.fn();
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onAttachClick={onAttachClick} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add attach file' }));
|
||||
|
||||
expect(onAttachClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable attach button when fileUploadEnabled is false', () => {
|
||||
render(<InputChatActions {...defaultProps} fileUploadEnabled={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable attach button when isUploadingFiles is true', () => {
|
||||
render(<InputChatActions {...defaultProps} isUploadingFiles={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not show attach text on mobile', () => {
|
||||
render(<InputChatActions {...defaultProps} isMobile={true} />);
|
||||
|
||||
expect(screen.queryByText('Attach file')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render web search button when onWebSearchToggle is provided', () => {
|
||||
const onWebSearchToggle = jest.fn();
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
onWebSearchToggle={onWebSearchToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render web search button when onWebSearchToggle is undefined', () => {
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onWebSearchToggle={undefined} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Research on the web' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onWebSearchToggle when web search button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWebSearchToggle = jest.fn();
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
onWebSearchToggle={onWebSearchToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
);
|
||||
|
||||
expect(onWebSearchToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable web search button when webSearchEnabled is false', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
webSearchEnabled={false}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render model selector when onModelSelect is provided', () => {
|
||||
const onModelSelect = jest.fn();
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onModelSelect={onModelSelect} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render model selector when onModelSelect is undefined', () => {
|
||||
render(<InputChatActions {...defaultProps} onModelSelect={undefined} />);
|
||||
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render send button', () => {
|
||||
render(<InputChatActions {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Web" text on mobile when forceWebSearch is active', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
isMobile={true}
|
||||
forceWebSearch={true}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Web')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Research on the web" text on desktop when forceWebSearch is active', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
isMobile={false}
|
||||
forceWebSearch={true}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Research on the web')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Web')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { SuggestionCarousel } from '../SuggestionCarousel';
|
||||
|
||||
describe('SuggestionCarousel', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render all suggestions', () => {
|
||||
render(<SuggestionCarousel messagesLength={0} />);
|
||||
|
||||
// First suggestion appears twice (duplicated for looping animation)
|
||||
expect(screen.getAllByText('Ask a question')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText('Turn this list into bullet points'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Write a short product description'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Find recent news about...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should start the interval when messagesLength is 0', () => {
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval');
|
||||
|
||||
render(<SuggestionCarousel messagesLength={0} />);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not start interval when messagesLength is greater than 0', () => {
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval');
|
||||
|
||||
render(<SuggestionCarousel messagesLength={1} />);
|
||||
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear interval on unmount', () => {
|
||||
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
|
||||
|
||||
const { unmount } = render(<SuggestionCarousel messagesLength={0} />);
|
||||
unmount();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { WelcomeMessage } from '../WelcomeMessage';
|
||||
|
||||
describe('WelcomeMessage', () => {
|
||||
it('should render the welcome message', () => {
|
||||
render(<WelcomeMessage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: 'What is on your mind?' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFileDragDrop } from '../useFileDragDrop';
|
||||
|
||||
const createFile = (name: string, type: string): File => {
|
||||
return new File(['content'], name, { type });
|
||||
};
|
||||
|
||||
const createDragEvent = (
|
||||
type: string,
|
||||
files?: File[],
|
||||
relatedTarget?: EventTarget | null,
|
||||
): DragEvent => {
|
||||
const event = new Event(type, { bubbles: true }) as DragEvent;
|
||||
|
||||
const dataTransfer = {
|
||||
types: files ? ['Files'] : [],
|
||||
files: files
|
||||
? {
|
||||
length: files.length,
|
||||
item: (i: number) => files[i],
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const file of files) {
|
||||
yield file;
|
||||
}
|
||||
},
|
||||
}
|
||||
: { length: 0 },
|
||||
} as unknown as DataTransfer;
|
||||
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
|
||||
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget });
|
||||
Object.defineProperty(event, 'preventDefault', { value: jest.fn() });
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('useFileDragDrop', () => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
isFileAccepted: jest.fn(() => true),
|
||||
onFilesAccepted: jest.fn(),
|
||||
onFilesRejected: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with isDragActive as false', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isDragActive to true on dragenter with files', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set isDragActive on dragenter without files', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragenter'));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isDragActive to false on dragleave when leaving window', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragleave', [], null));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should not set isDragActive to false on dragleave when moving between elements', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragleave', [], document.body));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isDragActive to false on drop', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onFilesAccepted with accepted files on drop', () => {
|
||||
const onFilesAccepted = jest.fn();
|
||||
const file = createFile('test.txt', 'text/plain');
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
onFilesAccepted,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('drop', [file]));
|
||||
});
|
||||
|
||||
expect(onFilesAccepted).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it('should call onFilesRejected with rejected file names', () => {
|
||||
const onFilesRejected = jest.fn();
|
||||
const isFileAccepted = jest.fn(() => false);
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
isFileAccepted,
|
||||
onFilesRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [createFile('test.exe', 'application/exe')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onFilesRejected).toHaveBeenCalledWith(['test.exe']);
|
||||
});
|
||||
|
||||
it('should separate accepted and rejected files', () => {
|
||||
const onFilesAccepted = jest.fn();
|
||||
const onFilesRejected = jest.fn();
|
||||
const isFileAccepted = jest.fn((file: File) => file.type === 'text/plain');
|
||||
|
||||
const acceptedFile = createFile('good.txt', 'text/plain');
|
||||
const rejectedFile = createFile('bad.exe', 'application/exe');
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
isFileAccepted,
|
||||
onFilesAccepted,
|
||||
onFilesRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [acceptedFile, rejectedFile]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onFilesAccepted).toHaveBeenCalledWith([acceptedFile]);
|
||||
expect(onFilesRejected).toHaveBeenCalledWith(['bad.exe']);
|
||||
});
|
||||
|
||||
it('should ignore drag events when disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset isDragActive when disabled changes to false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useFileDragDrop({ ...defaultProps, enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragenter',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragleave',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragover',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'drop',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFileUrls } from '../useFileUrls';
|
||||
|
||||
const createFile = (
|
||||
name: string,
|
||||
size: number = 100,
|
||||
lastModified: number = 1234567890,
|
||||
): File => {
|
||||
const file = new File(['x'.repeat(size)], name, { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: size });
|
||||
Object.defineProperty(file, 'lastModified', { value: lastModified });
|
||||
return file;
|
||||
};
|
||||
|
||||
const createFileList = (files: File[]): FileList => {
|
||||
return {
|
||||
length: files.length,
|
||||
item: (index: number) => files[index] || null,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const file of files) {
|
||||
yield file;
|
||||
}
|
||||
},
|
||||
...files.reduce(
|
||||
(acc, file, index) => {
|
||||
acc[index] = file;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, File>,
|
||||
),
|
||||
} as FileList;
|
||||
};
|
||||
|
||||
describe('useFileUrls', () => {
|
||||
let urlCounter: number;
|
||||
const mockCreateObjectURL = jest.fn();
|
||||
const mockRevokeObjectURL = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
urlCounter = 0;
|
||||
mockCreateObjectURL.mockImplementation(
|
||||
() => `blob:mock-url-${++urlCounter}`,
|
||||
);
|
||||
mockRevokeObjectURL.mockImplementation(() => {});
|
||||
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return an empty Map when files is null', () => {
|
||||
const { result } = renderHook(() => useFileUrls(null));
|
||||
|
||||
expect(result.current).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('should create URLs for files', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList = createFileList([file]);
|
||||
|
||||
const { result } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.get('test.txt-100-1234567890')).toBe(
|
||||
'blob:mock-url-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create URLs for multiple files', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
const fileList = createFileList([file1, file2]);
|
||||
|
||||
const { result } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.size).toBe(2);
|
||||
expect(result.current.get('file1.txt-100-1000')).toBe('blob:mock-url-1');
|
||||
expect(result.current.get('file2.txt-200-2000')).toBe('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should reuse URLs for unchanged files', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList1 = createFileList([file]);
|
||||
const fileList2 = createFileList([file]);
|
||||
|
||||
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: fileList1 },
|
||||
});
|
||||
|
||||
const initialUrl = result.current.get('test.txt-100-1234567890');
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ files: fileList2 });
|
||||
|
||||
expect(result.current.get('test.txt-100-1234567890')).toBe(initialUrl);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should revoke URLs when files are removed', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1, file2]) },
|
||||
});
|
||||
|
||||
rerender({ files: createFileList([file1]) });
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should revoke all URLs when files becomes null', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { rerender } = renderHook<
|
||||
Map<string, string>,
|
||||
{ files: FileList | null }
|
||||
>(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1, file2]) },
|
||||
});
|
||||
|
||||
rerender({ files: null });
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should clean up all URLs on unmount', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList = createFileList([file]);
|
||||
|
||||
const { unmount } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
});
|
||||
|
||||
it('should create new URL when adding a file', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1]) },
|
||||
});
|
||||
|
||||
expect(result.current.size).toBe(1);
|
||||
|
||||
rerender({ files: createFileList([file1, file2]) });
|
||||
|
||||
expect(result.current.size).toBe(2);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseFileDragDropOptions {
|
||||
/**
|
||||
* Whether file upload feature is enabled.
|
||||
* When false, drag/drop events are ignored.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Callback to validate if a file type is accepted.
|
||||
* @param file - File to validate
|
||||
* @returns true if file should be accepted
|
||||
*/
|
||||
isFileAccepted: (file: File) => boolean;
|
||||
|
||||
/**
|
||||
* Callback when files are dropped and validated.
|
||||
* Receives only the accepted files.
|
||||
*/
|
||||
onFilesAccepted: (files: File[]) => void;
|
||||
|
||||
/**
|
||||
* Callback when some files are rejected due to type.
|
||||
* Useful for showing error toasts.
|
||||
*/
|
||||
onFilesRejected?: (fileNames: string[]) => void;
|
||||
}
|
||||
|
||||
interface UseFileDragDropReturn {
|
||||
/**
|
||||
* Whether a drag operation is currently active over the window.
|
||||
* Use this to show a drop overlay.
|
||||
*/
|
||||
isDragActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle file drag and drop functionality.
|
||||
*
|
||||
* Purpose:
|
||||
* - Manages window-level drag/drop events for file uploads
|
||||
* - Tracks drag state to show/hide drop overlay
|
||||
* - Validates files against accepted types
|
||||
* - Separates accepted and rejected files
|
||||
* - Cleans up event listeners on unmount
|
||||
*
|
||||
* Why window-level events:
|
||||
* - Allows dropping files anywhere on the page
|
||||
* - Better UX than requiring precise drop zone targeting
|
||||
* - Handles edge cases like dragging over child elements
|
||||
*
|
||||
* @example
|
||||
* const { isDragActive } = useFileDragDrop({
|
||||
* enabled: fileUploadEnabled,
|
||||
* isFileAccepted: (file) => file.type.startsWith('image/'),
|
||||
* onFilesAccepted: (files) => setFiles(files),
|
||||
* onFilesRejected: (names) => showError(`Rejected: ${names.join(', ')}`),
|
||||
* });
|
||||
*/
|
||||
export const useFileDragDrop = ({
|
||||
enabled,
|
||||
isFileAccepted,
|
||||
onFilesAccepted,
|
||||
onFilesRejected,
|
||||
}: UseFileDragDropOptions): UseFileDragDropReturn => {
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
// Process dropped files: separate accepted from rejected
|
||||
const processFiles = useCallback(
|
||||
(fileList: FileList) => {
|
||||
const accepted: File[] = [];
|
||||
const rejectedNames: string[] = [];
|
||||
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
accepted.push(file);
|
||||
} else {
|
||||
rejectedNames.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Notify about rejected files first (for error toasts)
|
||||
if (rejectedNames.length > 0 && onFilesRejected) {
|
||||
onFilesRejected(rejectedNames);
|
||||
}
|
||||
|
||||
// Then handle accepted files
|
||||
if (accepted.length > 0) {
|
||||
onFilesAccepted(accepted);
|
||||
}
|
||||
},
|
||||
[isFileAccepted, onFilesAccepted, onFilesRejected],
|
||||
);
|
||||
|
||||
// Window-level drag/drop handlers
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsDragActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only activate for file drags (not text selections, etc.)
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setIsDragActive(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only deactivate when leaving the window entirely
|
||||
// relatedTarget is null when cursor leaves the document
|
||||
if (!e.relatedTarget) {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
// Required to allow drop
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
processFiles(droppedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach to window for full-page drag/drop support
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
window.addEventListener('dragleave', handleDragLeave);
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [enabled, processFiles]);
|
||||
|
||||
return {
|
||||
isDragActive,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Manages object URLs for file previews with proper memory cleanup.
|
||||
*
|
||||
* - Reuses URLs for unchanged files across re-renders
|
||||
* - Revokes URLs when files are removed
|
||||
* - Cleans up all URLs on unmount
|
||||
*
|
||||
* @param files - FileList from input or drag/drop, or null
|
||||
* @returns Map of file keys (`${name}-${size}-${lastModified}`) to object URLs
|
||||
*/
|
||||
export const useFileUrls = (files: FileList | null) => {
|
||||
const [urlMap, setUrlMap] = useState<Map<string, string>>(new Map());
|
||||
const urlMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Keep ref in sync with latest state
|
||||
useEffect(() => {
|
||||
urlMapRef.current = urlMap;
|
||||
});
|
||||
|
||||
// Handle URLs when files change
|
||||
useEffect(() => {
|
||||
const prevMap = urlMapRef.current;
|
||||
|
||||
if (!files) {
|
||||
prevMap.forEach((url) => URL.revokeObjectURL(url));
|
||||
setUrlMap(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const newMap = new Map<string, string>();
|
||||
const currentFileKeys = new Set(
|
||||
Array.from(files).map((f) => getFileKey(f)),
|
||||
);
|
||||
|
||||
// Reuse or revoke inline
|
||||
prevMap.forEach((url, key) => {
|
||||
if (currentFileKeys.has(key)) {
|
||||
newMap.set(key, url);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Create URLs for new files
|
||||
Array.from(files).forEach((file) => {
|
||||
const key = getFileKey(file);
|
||||
if (!newMap.has(key)) {
|
||||
newMap.set(key, URL.createObjectURL(file));
|
||||
}
|
||||
});
|
||||
|
||||
setUrlMap(newMap);
|
||||
}, [files]);
|
||||
|
||||
// Unmount-only cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
urlMapRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return urlMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a unique key for a file based on its properties.
|
||||
* Used to track file identity across renders.
|
||||
*/
|
||||
const getFileKey = (file: File): string => {
|
||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import styled, { css } from 'styled-components';
|
||||
|
||||
import { Box, StyledLink, Text } from '@/components/';
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Title } from '../header';
|
||||
|
||||
@@ -24,8 +25,13 @@ export const Footer = () => {
|
||||
const footerJson = config?.theme_customization?.footer;
|
||||
const { i18n, t } = useTranslation();
|
||||
const resolvedLanguage = i18n.resolvedLanguage;
|
||||
const { componentTokens } = useCunninghamTheme();
|
||||
const [content, setContent] = useState<ContentType>();
|
||||
|
||||
const themeLogo = (componentTokens as Record<string, unknown>).logo as
|
||||
| { src: string; alt: string; widthHeader: string; widthFooter: string }
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!footerJson) {
|
||||
return;
|
||||
@@ -34,7 +40,15 @@ export const Footer = () => {
|
||||
const langData = footerJson[resolvedLanguage as keyof typeof footerJson];
|
||||
const innerContent: ContentType = {};
|
||||
|
||||
innerContent.logo = langData?.logo || footerJson?.default?.logo;
|
||||
// Use theme logo if available (for DSFR theme), otherwise use config logo
|
||||
innerContent.logo = themeLogo?.src
|
||||
? {
|
||||
src: themeLogo.src,
|
||||
alt: themeLogo.alt,
|
||||
width: themeLogo.widthFooter,
|
||||
withTitle: false,
|
||||
}
|
||||
: langData?.logo || footerJson?.default?.logo;
|
||||
innerContent.legalLinks =
|
||||
langData?.legalLinks || footerJson?.default?.legalLinks;
|
||||
innerContent.externalLinks =
|
||||
@@ -47,7 +61,7 @@ export const Footer = () => {
|
||||
: footerJson?.default?.bottomInformation;
|
||||
|
||||
setContent(innerContent);
|
||||
}, [footerJson, resolvedLanguage]);
|
||||
}, [footerJson, resolvedLanguage, themeLogo]);
|
||||
|
||||
const { logo, legalLinks, externalLinks, bottomInformation } = content || {};
|
||||
|
||||
@@ -151,8 +165,9 @@ export const Footer = () => {
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$variation="600"
|
||||
$variation="secondary"
|
||||
$size="m"
|
||||
$theme="neutral"
|
||||
$transition="box-shadow 0.3s"
|
||||
$css={css`
|
||||
&:hover {
|
||||
@@ -171,7 +186,8 @@ export const Footer = () => {
|
||||
as="p"
|
||||
$size="m"
|
||||
$margin={{ top: 'big' }}
|
||||
$variation="600"
|
||||
$variation="secondary"
|
||||
$theme="neutral"
|
||||
$display="inline"
|
||||
className="--docs--footer-licence"
|
||||
>
|
||||
@@ -187,7 +203,9 @@ export const Footer = () => {
|
||||
gap: 0.2rem;
|
||||
`}
|
||||
>
|
||||
<Text $variation="600">{bottomInformation.link.label}</Text>
|
||||
<Text $variation="tertiary" $theme="neutral">
|
||||
{bottomInformation.link.label}
|
||||
</Text>
|
||||
<IconLink width={14} />
|
||||
</StyledLink>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user