Compare commits

...

19 Commits

Author SHA1 Message Date
qbey 5475bcd04e 🌐(i18n) update translated strings
Update translated files with new translations
2025-12-16 13:15:08 +01:00
Quentin BEY b1533c016a 🔖(minor) bump release to 0.0.10
Added

- (front) add retry button

Fixed

- 🐛(front) fix long user messages
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
- 🐛(front) fix parsing documents display
- 🐛(front) fix opacity input in error
- 🐛(front) resolve React hydration errors
- 🚑️(user) allow longer short names #182
2025-12-15 14:19:30 +01:00
renovate[bot] 9329ee8c90 ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-05 17:18:44 +01:00
Quentin BEY 39bf8f0c2d 🔒️(trivy) run trivy scan on sources (not in docker)
Inside the docker images, trivy does not detect frontend issues,
we run trivy here to have the lock files.
This does not work well for python packages.
2025-12-05 16:52:57 +01:00
Eléonore Voisin a1ed561204 🐛(front) fix long user messages
delete MarkDown for user message
2025-12-04 20:56:54 +01:00
Eléonore Voisin 6dfb9b7328 🐛(front) fix Maximum update depth exceeded - error in Chat component
Fix infinite render loop
2025-12-04 14:55:04 +01:00
Eléonore Voisin 38ae97aa31 🐛(front) fix parsing documents display
fix width display parsing + fix width doc title'
2025-12-02 17:22:39 +01:00
Eléonore Voisin 19cf3b2663 🐛(front) fix opacity input in error
fix opacity input in error
2025-12-02 14:40:27 +01:00
Eléonore Voisin 83904d8878 🐛(front) resolve React hydration errors
resolve React hydration errors and infinite update loop

🐛(front) resolve React hydration errors

resolve React hydration errors and infinite update loop
2025-12-02 10:35:48 +01:00
Eléonore Voisin d3922b7448 (front) add retry button
on chat error add retry button
2025-12-01 19:21:34 +01:00
Quentin BEY cdac7cad3b 🚑️(user) allow longer short names
I don't know why it was so short, but a legit user is blocked.
2025-12-01 09:43:36 +01:00
qbey 93ee3cd10d 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-17 17:39:03 +01:00
Quentin BEY 91e1c73ccf 🔖(patch) bump release to 0.0.9
Added
- (front) add code copy button
- (RAG) add generic collection RAG tools #159

Fixed

- 🔊(langfuse) enable tracing with redacted content #162
2025-11-17 17:36:24 +01:00
Quentin BEY 0493badb12 (pydantic-ai) update tests after version bump
The new version adds the `run_id` to messages, we don't need to
test this, so we simply get it from the tested objects.
2025-11-14 19:22:22 +01:00
renovate[bot] c6283bd8c8 ⬆️(dependencies) update python dependencies 2025-11-14 19:22:22 +01:00
Eléonore Voisin e823d21418 (front) add code copy button
add code copy button in code block
2025-11-14 17:20:19 +01:00
Quentin BEY 22ce90488c (e2e) add first chat end-to-end test
This is a very simple test, but it paves the way for more of them.
2025-11-14 15:09:54 +01:00
Quentin BEY 8f5419e6ca 🔊(langfuse) enable tracing with redacted content
We use langfuse to know the model use for product analysis
and token consumption. Before this commit if the user does
not want to share their conversations, we would not know
their token use. Now we send a trace with redacted content:
the input/output is redacted and the tool call arguments
are removed from the trace.
2025-11-14 15:05:03 +01:00
Quentin BEY dcec57719f (RAG) add generic collection RAG tools
This allows to deploy generic RAG tools with predefined
collections to allow specific document database for some
users.
2025-11-13 10:42:02 +01:00
54 changed files with 1843 additions and 343 deletions
+19
View File
@@ -200,3 +200,22 @@ jobs:
- name: Run tests
run: ~/.local/bin/pytest -n 2
security-trivy-critical:
permissions:
contents: read
security-events: write
runs-on: ubuntu-latest
steps:
- 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
security-trivy:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- 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
+29 -1
View File
@@ -8,6 +8,32 @@ and this project adheres to
## [Unreleased]
## [0.0.10] - 2025-12-15
### Added
- ✨(front) add retry button
### Fixed
- 🐛(front) fix long user messages
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
- 🐛(front) fix parsing documents display
- 🐛(front) fix opacity input in error
- 🐛(front) resolve React hydration errors
- 🚑️(user) allow longer short names #182
## [0.0.9] - 2025-11-17
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
### Fixed
- 🔊(langfuse) enable tracing with redacted content #162
## [0.0.8] - 2025-11-10
### Fixed
@@ -142,7 +168,9 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.8...main
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
+2 -2
View File
@@ -126,7 +126,7 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build frontend openmockllm-mistral $(cache)
.PHONY: build-e2e
down: ## stop and remove containers, networks, images, and volumes
@@ -158,7 +158,7 @@ create-compose-with-models: ## override the docker-compose file with models
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d frontend openmockllm-mistral
.PHONY: run-e2e
status: ## an alias for "docker compose ps"
+19
View File
@@ -11,3 +11,22 @@ services:
image: conversations:frontend-production
ports:
- "3000:3000"
openmockllm-mistral:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/OpenMockLLM/Dockerfile
image: conversations:openmockllm-mistral
command:
- openmockllm
- --host
- "0.0.0.0"
- --port
- "8000"
- --backend
- mistral
- --model-name
- mistral-mock
ports:
- "8900:8000"
+3
View File
@@ -2,6 +2,9 @@
BURST_THROTTLE_RATES="200/minute"
SUSTAINED_THROTTLE_RATES="200/hour"
# LLM
LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.json
# Features
FEATURE_FLAG_WEB_SEARCH=ENABLED
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.13.3-alpine
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools lorem-text
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apk add --no-cache git
# Install the package
RUN pip install git+https://github.com/etalab-ia/openmockllm.git
# Expose the default port
EXPOSE 8000
# Set default command
CMD ["openmockllm", "--host", "0.0.0.0", "--port", "8000"]
+19
View File
@@ -0,0 +1,19 @@
[OpenMockLLM](https://github.com/etalab-ia/OpenMockLLM) is a FastAPI-based mock LLM API server that simulates
several Large Language Model API providers.
This is a simple docker image to run the server for testing and development purposes (E2E tests mainly).
It's a bit overkill to have a dedicated image for that, but it allows simple E2E stack with docker-compose since
our code is also run in Docker containers.
## Build and Run manually
```bash
docker build -t openmockllm .
docker run -p 8000:8000 openmockllm
```
## Next steps
- Add more chat completion behaviors (specific text streaming, function calling, etc.)
- Pin a specific OpenMockLLM version in the Dockerfile
@@ -3,7 +3,7 @@
import json
import logging
from io import BytesIO
from typing import Optional
from typing import List, Optional
from urllib.parse import urljoin
from django.conf import settings
@@ -33,9 +33,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
- Perform a search operation using the Albert API.
"""
def __init__(self, collection_id: Optional[str] = None):
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)
super().__init__(collection_id, read_only_collection_id)
self._base_url = settings.ALBERT_API_URL
self._headers = {
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
@@ -220,11 +224,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Returns:
RAGWebResults: The search results.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
response = requests.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [int(self.collection_id)],
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
@@ -261,12 +267,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Returns:
RAGWebResults: The search results.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [int(self.collection_id)],
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
@@ -3,7 +3,7 @@
import logging
from contextlib import asynccontextmanager, contextmanager
from io import BytesIO
from typing import Optional
from typing import List, Optional
from asgiref.sync import sync_to_async
@@ -15,11 +15,51 @@ logger = logging.getLogger(__name__)
class BaseRagBackend:
"""Base class for RAG backends."""
def __init__(self, collection_id: Optional[str] = None):
"""Backend settings."""
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
"""
Backend settings.
Collection ID is required for RAG operations, where you want to manage the collection
lifecycle (create/delete).
Read-only collection IDs can be used to access existing collections
without managing their lifecycle.
Collection ID and read-only collection IDs are separated in the implementation to prevent
unwanted actions.
Args:
collection_id (Optional[str]): The collection ID for managing the collection lifecycle.
read_only_collection_id (Optional[List[str]]): List of read-only collection IDs.
"""
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"
def get_all_collection_ids(self) -> List[str]:
"""
Get all collection IDs, including the main collection ID and read-only collection IDs.
Returns:
List[str]: List of all collection IDs.
Raises:
RuntimeError: If neither collection_id nor read_only_collection_id is provided.
"""
if not self.collection_id and not self.read_only_collection_id:
raise RuntimeError("The RAG backend requires collection_id or read_only_collection_id")
collection_ids = []
if self.collection_id:
collection_ids.append(int(self.collection_id))
if self.read_only_collection_id:
collection_ids.extend(
[int(collection_id) for collection_id in self.read_only_collection_id]
)
return collection_ids
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
+23 -11
View File
@@ -26,7 +26,7 @@ from django.utils.module_loading import import_string
from asgiref.sync import sync_to_async
from langfuse import get_client
from pydantic_ai import Agent, RunContext
from pydantic_ai import Agent, InstrumentationSettings, RunContext
from pydantic_ai.messages import (
BinaryContent,
DocumentUrl,
@@ -72,6 +72,7 @@ from chat.clients.pydantic_ui_message_converter import (
ui_message_to_user_content,
)
from chat.mcp_servers import get_mcp_servers
from chat.tools.document_generic_search_rag import add_document_rag_search_tool_from_setting
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
@@ -116,7 +117,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.language = language # might be None
self._last_stop_check = 0
self._store_analytics = settings.LANGFUSE_ENABLED and user.allow_conversation_analytics
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._support_streaming = True
@@ -137,9 +139,15 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation_agent = ConversationAgent(
model_hrid=self.model_hrid,
language=self.language,
instrument=self._store_analytics,
instrument=InstrumentationSettings(
include_binary_content=self._store_analytics,
include_content=self._store_analytics,
)
if self._langfuse_available
else False,
deps_type=ContextDeps,
)
add_document_rag_search_tool_from_setting(self.conversation_agent, self.user)
@property
def _stop_cache_key(self):
@@ -174,7 +182,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Return only the assistant text deltas (legacy text mode)."""
await self._clean()
with ExitStack() as stack:
if self._store_analytics:
if self._langfuse_available:
span = stack.enter_context(get_client().start_as_current_span(name="conversation"))
span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk))
@@ -186,7 +194,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Return Vercel-AI-SDK formatted events."""
await self._clean()
with ExitStack() as stack:
if self._store_analytics:
if self._langfuse_available:
span = stack.enter_context(get_client().start_as_current_span(name="conversation"))
span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk))
async for event in self._run_agent(messages, force_web_search):
@@ -353,7 +361,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
return
# Langfuse settings
if self._store_analytics:
if self._langfuse_available:
langfuse = get_client()
langfuse.update_current_trace(
session_id=str(self.conversation.pk),
@@ -377,8 +385,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation, input_images, updated_url=image_key_mapping
)
if self._store_analytics:
langfuse.update_current_trace(input=user_prompt)
if self._langfuse_available:
langfuse.update_current_trace(
input=user_prompt if self._store_analytics else "REDACTED"
)
usage = {"promptTokens": 0, "completionTokens": 0}
@@ -693,7 +703,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
logger.error("_model_response_message_id already set")
_model_response_message_id = (
str(uuid.uuid4())
if not self._store_analytics
if not self._langfuse_available
else f"trace-{langfuse.get_current_trace_id()}"
)
yield events_v4.StartStepPart(
@@ -717,8 +727,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
image_key_mapping=image_key_mapping or None,
)
if self._store_analytics:
langfuse.update_current_trace(output=run.result.output)
if self._langfuse_available:
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
)
# Vercel finish message
yield events_v4.FinishMessagePart(
@@ -0,0 +1,66 @@
"""Unit tests for add_document_rag_search_tool_from_setting integration with AIAgentService."""
import pytest
from core.factories import UserFactory
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
from chat.llm_configuration import LLModel, LLMProvider
pytestmark = pytest.mark.django_db()
def test_ai_agent_service_adds_rag_tools_from_settings(settings):
"""Test that AIAgentService adds RAG tools from SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.LLM_CONFIGURATIONS = {
"default-model": LLModel(
hrid="default-model",
model_name="amazing-llm",
human_readable_name="Amazing LLM",
is_active=True,
icon=None,
system_prompt="You are an amazing assistant.",
tools=[],
provider=LLMProvider(hrid="unused", base_url="https://example.com", api_key="key"),
),
}
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services."
),
},
}
user = UserFactory()
conversation = ChatConversationFactory(owner=user)
# Create the service
service = AIAgentService(conversation, user=user)
# Check that tools were added to the conversation_agent
agent_tools = service.conversation_agent._function_toolset.tools # pylint: disable=protected-access
assert "legal_documents" in agent_tools
assert "french_public_services" in agent_tools
# Verify tool names and descriptions
assert agent_tools["legal_documents"].name == "legal_documents"
assert (
agent_tools["legal_documents"].description
== "Use this tool to search legal documents and laws."
)
assert agent_tools["french_public_services"].name == "french_public_services"
assert (
agent_tools["french_public_services"].description
== "Use this tool when the user asks for information about French public services."
)
@@ -0,0 +1,270 @@
"""Unit tests for Langfuse tracing in AIAgentService."""
import pytest
import responses
from asgiref.sync import sync_to_async
from langfuse import Langfuse
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.factories import UserFactory
from chat.ai_sdk_types import TextUIPart, UIMessage
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
pytestmark = pytest.mark.django_db()
@pytest.fixture(name="langfuse_client", scope="function")
def langfuse_client_fixture():
"""Fixture to init langfuse for tests."""
langfuse_client = Langfuse(
public_key="pk-test-key",
secret_key="sk-test-key",
host="https://langfuse.example.com",
environment="test",
debug=True,
)
yield langfuse_client
langfuse_client._resources.prompt_cache._task_manager.shutdown() # pylint: disable=protected-access
langfuse_client.shutdown()
@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-123"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
settings.AI_AGENT_TOOLS = []
@pytest.fixture(name="ui_messages")
def ui_messages_fixture():
"""Fixture for test UI messages."""
return [
UIMessage(
id="msg-1",
role="user",
content="Hello, how are you?",
parts=[TextUIPart(type="text", text="Hello, how are you?")],
)
]
@pytest.fixture(name="agent_model")
def agent_model_fixture():
"""Fixture for agent model function."""
async def _agent_model(_messages: list[ModelMessage], _info: AgentInfo):
"""Simple agent model that returns a fixed response."""
yield "Hello! I'm doing well, thank you for asking."
return FunctionModel(stream_function=_agent_model)
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_allowed(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created when enabled and user allows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics enabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_disabled(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created even when user disallows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics disabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." not in responses.calls[0].request.body
assert b"REDACTED" in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_no_langfuse_span_when_disabled(agent_model, ui_messages, settings, langfuse_client):
"""Test Langfuse span is not created when Langfuse is disabled."""
settings.LANGFUSE_ENABLED = False
# Mock Langfuse HTTP endpoints (should not be called)
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/ingestion",
json={"success": True},
status=200,
)
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify NO Langfuse HTTP calls were made
assert len(responses.calls) == 0
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_enabled(settings):
"""Test service correctly sets flags when Langfuse and analytics are enabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is True
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_disabled(settings):
"""Test service correctly sets flags when Langfuse enabled but analytics disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_disabled_when_langfuse_disabled(settings):
"""Test service correctly sets flags when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is False
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
def test_store_analytics_flag_when_langfuse_enabled_and_user_allows(settings):
"""Test _store_analytics is True when Langfuse enabled and user allows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is True
def test_store_analytics_flag_when_langfuse_enabled_and_user_disallows(settings):
"""Test _store_analytics is False when Langfuse enabled but user disallows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=False)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is False
def test_store_analytics_flag_when_langfuse_disabled(settings):
"""Test _store_analytics is False when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is False
assert service._store_analytics is False
@@ -0,0 +1,403 @@
"""
Unit tests for document generic search RAG tool functionality.
"""
import json
import logging
import httpx
import pytest
import responses
import respx
from asgiref.sync import sync_to_async
from pydantic_ai import Agent, RunContext, RunUsage
from core.factories import UserFactory
from chat.tools.document_generic_search_rag import (
add_document_rag_search_tool_from_setting,
get_specific_rag_search_tool_config,
)
pytestmark = pytest.mark.django_db()
def test_get_specific_rag_search_tool_config_with_disabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "disabled",
"tool_description": "Use this tool to search French legal documents and laws.",
"rag_backend_name": "chat.tests.tools.test_document_generic_search_rag.MockRagBackend",
},
}
# The fixture tools are disabled by default
assert get_specific_rag_search_tool_config(user) == {}
def test_get_specific_rag_search_tool_config_with_enabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": "Use this tool when the user "
"asks for information about "
"French public services, the "
"French labor market, "
"employment laws, social "
"benefits, or assistance with "
"administrative procedures.",
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
@responses.activate
def test_get_specific_rag_search_tool_config_with_dynamic_features(settings, posthog):
"""Test get_specific_rag_search_tool_config with dynamic features."""
user = UserFactory()
responses.post(
f"{posthog.host}/flags/?v=2",
json={"flags": {"legal-documents": {"enabled": True}}},
status=200,
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "dynamic",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
}
}
def test_add_document_rag_search_tool_from_setting_adds_tools(settings):
"""Test that add_document_rag_search_tool_from_setting adds tools to the agent."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
add_document_rag_search_tool_from_setting(agent, user)
# Check that tools were added
assert len(agent._function_toolset.tools) == 1 # pylint: disable=protected-access
assert agent._function_toolset.tools["legal_documents"].name == "legal_documents" # pylint: disable=protected-access
assert (
agent._function_toolset.tools["legal_documents"].description # pylint: disable=protected-access
== "Use this tool to search legal documents and laws."
)
assert agent._function_toolset.tools["legal_documents"].function_schema.json_schema == { # pylint: disable=protected-access
"additionalProperties": False,
"properties": {
"query": {"description": "The query to search information about.", "type": "string"}
},
"required": ["query"],
"type": "object",
}
def test_add_document_rag_search_tool_with_invalid_backend(settings, caplog):
"""Test that invalid backend import is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"rag_backend_name": "non.existent.Backend",
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "Could not import RAG backend non.existent.Backend" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_collection_ids(settings, caplog):
"""Test that missing collection_ids is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No collection IDs provided for tool legal_documents" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_tool_description(settings, caplog):
"""Test that missing tool_description is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No tool description provided for tool legal_documents" in caplog.records[0].message
@respx.mock
def test_document_search_rag_tool_execution(settings):
"""Test that the generated RAG tool executes correctly."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"legal_documents_2": {
"collection_ids": [200],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent(model="test")
add_document_rag_search_tool_from_setting(agent, user)
result = agent.run_sync("What information can you find about French services?")
# Verify the result
assert json.loads(result.output) == {
"legal_documents": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
"legal_documents_2": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
}
assert len(search_mock.calls) == 2
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
assert json.loads(search_mock.calls[1].request.content) == {
"collections": [200],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
def test_get_specific_rag_search_tool_config_with_empty_settings(settings):
"""Test get_specific_rag_search_tool_config with empty SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {}
user = UserFactory()
config = get_specific_rag_search_tool_config(user)
assert config == {}
@pytest.mark.asyncio
@respx.mock
async def test_add_document_rag_search_tool_function_call(settings):
"""Test the function behavior."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
assert result.return_value == {
"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}
}
assert result.metadata == {"sources": {"doc1.txt"}}
assert len(search_mock.calls) == 1
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "Find information about French laws.",
"score_threshold": 0.6,
}
@pytest.mark.asyncio
@respx.mock
async def test_document_search_rag_http_status_error(settings, caplog):
"""Test that HTTPStatusError is properly handled and logged."""
caplog.set_level(logging.ERROR, logger="chat.tools.document_generic_search_rag")
# Mock the API to return a 500 error
respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=500,
json={"error": "Internal server error"},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Call the tool function and expect a ModelRetry to be raised and caught
tool_result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
# Verify the exception message
assert tool_result == (
"Document search service is currently unavailable: Server error '500 Internal "
"Server Error' for url 'https://albert.api.etalab.gouv.fr/v1/search'\n"
"For more information check: "
"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 You must "
"explain this to the user and not try to answer based on your knowledge."
)
# Verify that error was logged
assert "RAG document search failed for tool legal_documents" in caplog.records[0].message
assert "Document search service is currently unavailable" in caplog.records[1].message
@@ -169,6 +169,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -198,6 +199,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -218,6 +220,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -292,6 +295,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -321,6 +325,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -341,6 +346,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -489,6 +495,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -530,6 +537,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -550,6 +558,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -666,6 +675,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -695,6 +705,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -723,6 +734,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
@@ -737,6 +749,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -759,6 +772,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -874,6 +888,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -903,6 +918,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -931,6 +947,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
@@ -944,6 +961,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -966,6 +984,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -1192,6 +1211,7 @@ def test_post_conversation_data_protocol_no_stream(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -1221,6 +1241,7 @@ def test_post_conversation_data_protocol_no_stream(
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -1248,6 +1269,7 @@ def test_post_conversation_data_protocol_no_stream(
"output_audio_tokens": 0,
"output_tokens": 135,
},
"run_id": _run_id,
},
]
@@ -1344,6 +1366,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -1373,6 +1396,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -1393,5 +1417,6 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -351,6 +351,8 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
@@ -404,6 +406,7 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -432,6 +435,7 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"output_audio_tokens": 0,
"output_tokens": 8,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
@@ -461,6 +465,7 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"tool_name": "document_search_rag",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -487,6 +492,7 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"output_audio_tokens": 0,
"output_tokens": 12,
},
"run_id": _run_id,
}
@@ -696,6 +702,8 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
@@ -749,6 +757,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -777,6 +786,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 1,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
@@ -800,6 +810,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"tool_name": "summarize",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -822,4 +833,5 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 6,
},
"run_id": _run_id,
}
@@ -145,7 +145,8 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is a document about a single pixel."
@@ -217,6 +218,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -256,6 +258,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -282,6 +285,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -583,13 +587,15 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
),
ModelResponse(
parts=[TextPart(content="This is a document about a single pixel.")],
usage=RequestUsage(input_tokens=50, output_tokens=9),
model_name="function::agent_model",
timestamp=timezone.now(),
run_id=messages[1].run_id,
),
ModelRequest(
parts=[
@@ -599,7 +605,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
)
]
],
run_id=messages[2].run_id,
),
]
yield "This is a document of square, very small and nice."
@@ -695,6 +702,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -734,6 +742,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
# no run_id here
},
{
"finish_reason": None,
@@ -760,6 +769,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 9,
},
# no run_id here
},
{
"instructions": None,
@@ -771,6 +781,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -797,6 +808,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -913,6 +925,7 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
run_id=messages[0].run_id,
)
]
yield "This is a document about you."
@@ -982,6 +995,7 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": (
@@ -1040,6 +1054,7 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -1066,5 +1081,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
"output_audio_tokens": 0,
"output_tokens": 7,
},
"run_id": _run_id,
},
]
@@ -1373,6 +1373,8 @@ def test_post_conversation_with_existing_tool_history(
# The pydantic_messages should include both the original tool calls and the new ones
assert len(history_conversation_with_tool.pydantic_messages) == 12 # Original 8 + 4 new ones
_run_id = history_conversation_with_tool.pydantic_messages[8]["run_id"]
# Verify the new tool call request is included
assert history_conversation_with_tool.pydantic_messages[8] == {
"instructions": None,
@@ -1384,6 +1386,7 @@ def test_post_conversation_with_existing_tool_history(
"timestamp": "2025-07-25T10:36:35.297675Z",
}
],
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[9] == {
@@ -1413,6 +1416,7 @@ def test_post_conversation_with_existing_tool_history(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[10] == {
@@ -1428,6 +1432,7 @@ def test_post_conversation_with_existing_tool_history(
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[11] == {
@@ -1451,6 +1456,7 @@ def test_post_conversation_with_existing_tool_history(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
}
@@ -114,7 +114,8 @@ def test_post_conversation_with_local_image_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -180,6 +181,7 @@ def test_post_conversation_with_local_image_url(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -219,6 +221,7 @@ def test_post_conversation_with_local_image_url(
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -241,6 +244,7 @@ def test_post_conversation_with_local_image_url(
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -298,7 +302,8 @@ def test_post_conversation_with_local_image_wrong_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "cannot read image." # IRL a 400 error would be raised by the LLM
@@ -385,7 +390,8 @@ def test_post_conversation_with_remote_image_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -629,7 +635,8 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
)
]
],
run_id=messages[2].run_id,
),
]
yield "This is an image of square, very small and nice."
@@ -725,6 +732,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -797,6 +805,7 @@ def test_post_conversation_with_local_image_url_in_history(
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -823,5 +832,6 @@ def test_post_conversation_with_local_image_url_in_history(
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -0,0 +1,137 @@
"""
Helpers to add RAG document search tools to an agent based on settings.
The purpose is to provide a generic way to add multiple RAG document search tools
to an agent based on configuration in settings. Each tool can target specific
document collections and have its own description.
Our use case implies that different users might have access to different document collections,
so the tools added to the agent are also user-specific.
"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.module_loading import import_string
from httpx import HTTPStatusError
from pydantic_ai import Agent, ModelRetry, RunContext, RunUsage
from pydantic_ai.messages import ToolReturn
from core.feature_flags.helpers import is_feature_enabled
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
User = get_user_model()
def get_specific_rag_search_tool_config(user: User) -> dict:
"""
Get the specific RAG search tool configuration from settings.
Settings example:
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
}
"""
return {
tool_name: tool_config
for tool_name, tool_config in settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS.items()
if is_feature_enabled(user, tool_name)
}
def _create_document_search_rag(agent, name, description, backend, ids):
"""Factory function to create a document search RAG tool."""
@agent.tool(
name=name,
retries=1,
require_parameter_descriptions=True,
description=description,
)
@last_model_retry_soft_fail
async def document_search_rag(ctx: RunContext, query: str) -> ToolReturn:
"""
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search information about.
"""
document_store = backend(read_only_collection_id=ids)
try:
rag_results = await document_store.asearch(query)
except HTTPStatusError as exc:
logger.error(
"RAG document search failed for tool %s with error: %s", name, exc, exc_info=True
)
raise ModelRetry(f"Document search service is currently unavailable: {exc}") from exc
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
return ToolReturn(
return_value={
str(idx): {
"url": result.url,
"snippets": result.content,
}
for idx, result in enumerate(rag_results.data)
},
metadata={"sources": {result.url for result in rag_results.data}},
)
return document_search_rag
def add_document_rag_search_tool_from_setting(agent: Agent, user: User) -> None:
"""
This function takes a configuration setting and generates specific search RAG tools and add
it to the agent.
Args:
agent (Agent): The agent to which the tool will be added.
user (User): The user for whom the tool is being added.
"""
for tool_name, tool_config in get_specific_rag_search_tool_config(user).items():
document_store_backend_name = tool_config.get(
"rag_backend_name", settings.RAG_DOCUMENT_SEARCH_BACKEND
)
try:
document_store_backend = import_string(document_store_backend_name)
except ImportError as exc:
logger.warning(
"Could not import RAG backend %s: %s",
document_store_backend_name,
exc,
exc_info=True,
)
continue # Skip if the backend is not available
collection_ids = tool_config.get("collection_ids", [])
if not collection_ids:
logger.warning("No collection IDs provided for tool %s, skipping.", tool_name)
continue # Skip if no collection IDs are provided
tool_description = tool_config.get("tool_description")
if not tool_description:
logger.warning("No tool description provided for tool %s, skipping.", tool_name)
continue # Skip if no tool description is provided
_create_document_search_rag(
agent, tool_name, tool_description, document_store_backend, collection_ids
)
+15
View File
@@ -1,5 +1,6 @@
"""Global fixtures for the backend tests."""
import posthog
import pytest
from rest_framework.test import APIClient
from urllib3.connectionpool import HTTPConnectionPool
@@ -41,3 +42,17 @@ def feature_flags_fixture(settings):
"""
settings.FEATURE_FLAGS = settings.FEATURE_FLAGS.model_copy(deep=True)
yield settings.FEATURE_FLAGS
@pytest.fixture(name="posthog", scope="function")
def posthog_fixture(settings):
"""Mock PostHog in tests to avoid real network calls."""
settings.POSTHOG_KEY = {"id": "132456", "host": "https://eu.i.posthog-test.com"}
posthog.api_key = settings.POSTHOG_KEY["id"]
posthog.host = settings.POSTHOG_KEY["host"]
yield posthog
posthog.api_key = None
posthog.host = None
@@ -0,0 +1,43 @@
{
"models": [
{
"hrid": "default-model",
"model_name": "mistral-mock",
"human_readable_name": "Default Model",
"provider_name": "default-provider",
"profile": null,
"settings": {},
"is_active": true,
"icon": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAz",
"iABUAGIWbpsTwq7HhAAAAI4dle7DrdX4AJohRaaboXWj7+/zn6On5//9NZaT29vfoWmVHYKDoUl/k5OUAIYddc6vpbHYCM47Y3+v53+LiFCUA",
"HIWnsckYPJHi6PL77O7jJjW3wdf1w8jre4QgQ5TZ2txwg7Pr3+I8WZ6OnsTuoamClL7tlZ5xz5y8AAAAzUlEQVR4AZ3RRQKDQBBEUSTu7h5c4",
"vc/W6Yp3KG2Dz4ynDdeEBvOmq12xx2E1u0B+4NOEocj4DgNJ1PgLAvni8WyBq5Yc71ubFJx23C2q4P7dRYejg1xzvCUgvz5guz11k7gXYKF/1",
"8oyiYuvHAYeVkhXCzolVStHcGDjiQzNmMQxsMI5rEJRdQSPZvbpE2E8aY6gC6Z+2Hg4dFA0Yb4YedNL/v4Fk8WJuwiGhrChJNXI210rnib9Fs",
"JlXRUC/HwTscPIXf/iklq/tjb/gHAdxkCUjAg2QAAAABJRU5ErkJggg=="
],
"system_prompt": "You are a helpful AI assistant.",
"tools": []
},
{
"hrid": "default-summarization-model",
"model_name": "mistral-mock",
"human_readable_name": "Default Summarization Model",
"provider_name": "default-provider",
"profile": null,
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a helpful AI assistant specialized in summarization.",
"tools": []
}
],
"providers": [
{
"hrid": "default-provider",
"base_url": "http://host.docker.internal:8900",
"api_key": "openmockllm-api-key",
"kind": "mistral"
}
]
}
+5
View File
@@ -717,6 +717,11 @@ class Base(BraveSettings, Configuration):
environ_name="RAG_DOCUMENT_SEARCH_BACKEND",
environ_prefix=None,
)
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = values.DictValue(
default={},
environ_name="SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS",
environ_prefix=None,
)
# Web search
RAG_WEB_SEARCH_PROMPT_UPDATE = values.Value(
+7
View File
@@ -2,6 +2,7 @@
from enum import StrEnum
from django.conf import settings
from django.utils.text import slugify
from pydantic import BaseModel, ConfigDict
@@ -43,3 +44,9 @@ class FeatureFlags(BaseModel):
# features
web_search: FeatureToggle = FeatureToggle.DISABLED
document_upload: FeatureToggle = FeatureToggle.DISABLED
def __getattr__(self, name: str):
"""Dynamically get specific RAG document search tool feature flags from settings."""
if config := settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS.get(name):
return FeatureToggle[config.get("feature_flag_value", "DISABLED").upper()]
return super().__getattr__(name)
@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-12-01 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_user_allow_conversation_analytics"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(blank=True, max_length=50, null=True, verbose_name="short name"),
),
]
+1 -1
View File
@@ -114,7 +114,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=50, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
@@ -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-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-10 12:20\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
+13 -13
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.8"
version = "0.0.10"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,19 +27,19 @@ requires-python = ">=3.12"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
"boto3==1.40.67",
"boto3==1.40.73",
"Brotli==1.2.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-countries==8.1.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.17",
"django-lasuite[all]==0.0.18",
"django-parler==2.3",
"django-pydantic-field==0.3.13",
"django-pydantic-field==0.4.0",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.8",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -47,22 +47,22 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.9.0",
"langfuse==3.10.0",
"lxml==5.4.0",
"markdown==3.10",
"markitdown==0.0.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"posthog==6.8.0",
"posthog==7.0.0",
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.11.0",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.17.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"semchunk==3.2.5",
"sentry-sdk==2.43.0",
"sentry-sdk==2.44.0",
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
@@ -87,15 +87,15 @@ dev = [
"pylint-django==2.6.1",
"pylint==3.3.9",
"pylint-pydantic==0.4.1",
"pytest-asyncio==1.2.0",
"pytest-asyncio==1.3.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.2",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"respx==0.22.0",
"ruff==0.14.3",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.8",
"version": "0.0.10",
"private": true,
"scripts": {
"dev": "next dev",
@@ -36,6 +36,7 @@
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"lottie-react": "^2.4.1",
"luxon": "3.6.1",
"micromark-extension-llm-math": "3.1.1-20250610",
"next": "15.3.3",
@@ -45,7 +46,6 @@
"react-dom": "18.3.1",
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-lottie": "^1.2.10",
"react-markdown": "10.1.0",
"react-select": "5.10.1",
"rehype-katex": "7.0.1",
@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -35,8 +35,16 @@ export const DropdownMenu = ({
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
const blockButtonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Mettre à jour la largeur uniquement côté client
if (blockButtonRef.current) {
setButtonWidth(blockButtonRef.current.clientWidth);
}
}, [isOpen]);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
@@ -76,7 +84,7 @@ export const DropdownMenu = ({
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
$minWidth={buttonWidth ? `${buttonWidth}px` : undefined}
role="menu"
>
{topMessage && (
@@ -1,20 +1,17 @@
import Lottie from 'react-lottie';
import dynamic from 'next/dynamic';
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
import searchingAnimation from '@/assets/lotties/searching';
export const Loader = () => {
const LoaderOptions = {
loop: true,
autoplay: true,
animationData: searchingAnimation,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
} as const;
export function Loader() {
return (
<div>
<Lottie options={LoaderOptions} height={24} width={24} />
<div role="status">
<Lottie
animationData={searchingAnimation}
loop
autoplay
style={{ width: 24, height: 24 }}
/>
</div>
);
};
}
@@ -3,6 +3,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { createPortal } from 'react-dom';
@@ -47,6 +48,11 @@ interface ToastProviderProps {
export const ToastProvider = ({ children }: ToastProviderProps) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const showToast = useCallback(
(
@@ -83,7 +89,9 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
return (
<ToastContext.Provider value={value}>
{children}
{typeof window !== 'undefined' &&
{isMounted &&
typeof document !== 'undefined' &&
document.body &&
createPortal(
<Box
aria-live="polite"
@@ -1,74 +1,64 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { ToastProvider } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
import { useResponsiveStore } from '@/stores/';
import { useResponsiveStore } from '@/stores';
import { ConfigProvider } from './config/';
import { ConfigProvider } from './config';
// Client-only providers
const ToastProviderNoSSR = dynamic(
() => import('@/components').then((mod) => ({ default: mod.ToastProvider })),
{ ssr: false, loading: () => null },
);
const CunninghamProviderNoSSR = dynamic(
() =>
import('@openfun/cunningham-react').then((mod) => ({
default: mod.CunninghamProvider,
})),
{ ssr: false },
);
/**
* QueryClient:
* - defaultOptions:
* - staleTime:
* - global cache duration - we decided 3 minutes
* - It can be overridden to each query
*/
const defaultOptions = {
queries: {
staleTime: 1000 * 60 * 3,
retry: 1,
},
};
const queryClient = new QueryClient({
defaultOptions,
defaultOptions: {
queries: { staleTime: 1000 * 60 * 3, retry: 1 },
mutations: {
onError: (error) => {
if (
error instanceof Error &&
'status' in error &&
error.status === 401
) {
void queryClient.resetQueries({ queryKey: [KEY_AUTH] });
setAuthUrl();
if (typeof window !== 'undefined') {
window.location.href = '/401';
}
}
},
},
},
});
export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { replace } = useRouter();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
);
const theme = useCunninghamTheme((state) => state.theme);
useEffect(() => {
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {
queryClient.setDefaultOptions({
...defaultOptions,
mutations: {
onError: (error) => {
if (
error instanceof Error &&
'status' in error &&
error.status === 401
) {
void queryClient.resetQueries({
queryKey: [KEY_AUTH],
});
setAuthUrl();
void replace(`/401`);
}
},
},
});
}, [replace]);
return useResponsiveStore.getState().initializeResizeListener();
}, []);
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<CunninghamProviderNoSSR theme={theme}>
<ConfigProvider>
<ToastProvider>
<ToastProviderNoSSR>
<Auth>{children}</Auth>
</ToastProvider>
</ToastProviderNoSSR>
</ConfigProvider>
</CunninghamProvider>
</CunninghamProviderNoSSR>
</QueryClientProvider>
);
}
@@ -55,7 +55,7 @@ export const AttachmentList = ({
>
<Box
$background="var(--c--theme--colors--greyscale-050)"
$minWidth="200px"
$width="200px"
$direction="row"
$gap="8px"
$align="center"
@@ -8,6 +8,7 @@ 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';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
@@ -16,7 +17,7 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Box, Icon, Loader, StyledLink, Text } from '@/components';
import { Box, Icon, Loader, Text } from '@/components';
import { useUploadFile } from '@/features/attachments/hooks/useUploadFile';
import { useChat } from '@/features/chat/api/useChat';
import { getConversation } from '@/features/chat/api/useConversation';
@@ -26,6 +27,8 @@ import {
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { ChatError } from '@/features/chat/components/ChatError';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import { InputChat } from '@/features/chat/components/InputChat';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
@@ -101,19 +104,14 @@ export const Chat = ({
if (modelToSelect) {
setSelectedModel(modelToSelect);
setSelectedModelHrid(modelToSelect.hrid);
}
}
}, [llmConfig, selectedModel, selectedModelHrid]);
// Update store when model selection changes
useEffect(() => {
if (selectedModel?.hrid !== selectedModelHrid) {
setSelectedModelHrid(selectedModel?.hrid || null);
}
}, [selectedModel, selectedModelHrid, setSelectedModelHrid]);
}, [llmConfig, selectedModel, selectedModelHrid, setSelectedModelHrid]);
const handleModelSelect = (model: LLMModel) => {
setSelectedModel(model);
setSelectedModelHrid(model.hrid);
};
const router = useRouter();
@@ -153,17 +151,26 @@ export const Chat = ({
const [initialConversationMessages, setInitialConversationMessages] =
useState<Message[] | undefined>(undefined);
const [pendingFirstMessage, setPendingFirstMessage] = useState<{
event: React.FormEvent<HTMLFormElement>;
event: FormEvent<HTMLFormElement>;
attachments?: Attachment[];
forceWebSearch?: boolean;
} | null>(null);
const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false);
const [shouldRetry, setShouldRetry] = useState(false);
const retryOriginalInputRef = useRef<string>('');
const retryOriginalFilesRef = useRef<FileList | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const [streamingMessageHeight, setStreamingMessageHeight] = useState<
number | null
>(null);
const lastUserMessageIdRef = useRef<string | null>(null);
const hasScrolledToBottomOnLoadRef = useRef(false);
const lastSubmissionRef = useRef<{
input: string;
files: FileList | null;
event: FormEvent<HTMLFormElement>;
options?: Record<string, unknown>;
} | null>(null);
const { mutate: createChatConversation } = useCreateChatConversation();
@@ -212,6 +219,7 @@ export const Chat = ({
handleInputChange,
status,
stop: stopChat,
setMessages,
} = useChat({
id: conversationId,
initialMessages: initialConversationMessages,
@@ -244,10 +252,33 @@ export const Chat = ({
void stopGeneration();
};
const handleSubmitWrapper = (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmitWrapper = (event: FormEvent<HTMLFormElement>) => {
void handleSubmit(event);
};
const handleRetry = () => {
if (!lastSubmissionRef.current || !setMessages) {
return;
}
const { input: lastInput, files: lastFiles } = lastSubmissionRef.current;
const lastAssistantIndex = messages.findLastIndex(
(msg) => msg.role === 'assistant',
);
if (lastAssistantIndex !== -1) {
setMessages(messages.filter((_, index) => index !== lastAssistantIndex));
}
retryOriginalInputRef.current = input;
retryOriginalFilesRef.current = files;
handleInputChange({
target: { value: lastInput },
} as ChangeEvent<HTMLTextAreaElement>);
setFiles(lastFiles);
setShouldRetry(true);
};
// Précharger les métadonnées des sources dès que les messages arrivent
useEffect(() => {
messages.forEach((message) => {
@@ -260,7 +291,8 @@ export const Chat = ({
});
}
});
}, [messages, prefetchMetadata]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages]);
const openSources = (messageId: string) => {
if (isSourceOpen === messageId) {
@@ -303,16 +335,23 @@ export const Chat = ({
const availableHeight = containerHeight - userMessageHeight - 38;
if (streamingMessageHeight !== availableHeight) {
setStreamingMessageHeight(availableHeight);
}
setStreamingMessageHeight((prev) => {
if (prev === null || Math.abs(prev - availableHeight) > 10) {
return availableHeight;
}
return prev;
});
}
}
}
}, [messages, streamingMessageHeight]);
}, [messages]);
// Détecter l'arrivée d'un nouveau message user et retirer la hauteur de l'ancien
useEffect(() => {
if (status === 'streaming') {
return;
}
const userMessages = messages.filter((msg) => msg.role === 'user');
const lastUserMessage = userMessages[userMessages.length - 1];
@@ -325,14 +364,14 @@ export const Chat = ({
}
lastUserMessageIdRef.current = lastUserMessage.id;
}
}, [messages]);
}, [messages, status]);
// Calculer la hauteur pendant submitted/streaming
useEffect(() => {
if (status === 'submitted' || status === 'streaming') {
calculateStreamingHeight();
}
}, [status, calculateStreamingHeight]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
// Scroller vers la question au moment du submit
useEffect(() => {
@@ -352,7 +391,8 @@ export const Chat = ({
messageElement?.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
}, [status, messages]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
// Synchronize conversationId state with prop when it changes (e.g., after navigation)
useEffect(() => {
@@ -361,10 +401,11 @@ export const Chat = ({
if (initialConversationId !== conversationId) {
handleInputChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>);
} as ChangeEvent<HTMLTextAreaElement>);
setHasInitialized(false); // Réinitialiser pour permettre le scroll au prochain chargement
}
}, [initialConversationId, conversationId, handleInputChange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialConversationId, conversationId]);
// On mount, if there is pending input/files, initialize state and set flag
useEffect(() => {
@@ -375,7 +416,7 @@ export const Chat = ({
if (pendingInput) {
const syntheticEvent = {
target: { value: pendingInput },
} as React.ChangeEvent<HTMLInputElement>;
} as ChangeEvent<HTMLInputElement>;
handleInputChange(syntheticEvent);
}
if (pendingFiles) {
@@ -397,13 +438,32 @@ export const Chat = ({
const syntheticFormEvent = {
preventDefault: () => {},
target: form,
} as unknown as React.FormEvent<HTMLFormElement>;
} as unknown as FormEvent<HTMLFormElement>;
void handleSubmit(syntheticFormEvent);
setShouldAutoSubmit(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldAutoSubmit, input, files]);
useEffect(() => {
if (
shouldRetry &&
lastSubmissionRef.current &&
input === lastSubmissionRef.current.input
) {
const { event } = lastSubmissionRef.current;
void handleSubmit(event);
handleInputChange({
target: { value: retryOriginalInputRef.current },
} as ChangeEvent<HTMLTextAreaElement>);
setFiles(retryOriginalFilesRef.current);
setShouldRetry(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldRetry, input, files]);
// Fetch initial conversation messages if initialConversationId is provided and no pending input
useEffect(() => {
hasScrolledToBottomOnLoadRef.current = false; // Réinitialiser au début du chargement
@@ -457,7 +517,7 @@ export const Chat = ({
}, [hasInitialized, messages.length]);
// Custom handleSubmit to include attachments and handle chat creation
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Upload files to server and get URLs
@@ -536,6 +596,13 @@ export const Chat = ({
options.experimental_attachments = attachments;
}
lastSubmissionRef.current = {
input,
files,
event,
options: Object.keys(options).length > 0 ? options : undefined,
};
if (Object.keys(options).length > 0) {
baseHandleSubmit(event, options);
} else {
@@ -649,38 +716,53 @@ export const Chat = ({
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
[
rehypePrettyCode,
{
theme: 'github-dark-dimmed',
},
],
rehypeKatex,
]}
components={{
// Custom components for Markdown rendering
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
}}
>
{message.content}
</MarkdownHooks>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
[
rehypePrettyCode,
{
theme: 'github-dark-dimmed',
},
],
rehypeKatex,
]}
components={{
// Custom components for Markdown rendering
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
</MarkdownHooks>
)}
</Box>
)}
@@ -913,27 +995,11 @@ export const Chat = ({
</Text>
</Box>
) : null}
{status === 'error' && (
<Box
$direction={isMobile ? 'column' : 'row'}
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
$padding={{ left: '13px' }}
>
<Text>{t('Sorry, an error occurred. Please try again.')}</Text>
<StyledLink
href="/"
rel="noopener noreferrer"
$css={`
color: var(--c--theme--colors--greyscale-900);
text-decoration: underline;
`}
>
{t('Start a new conversation.')}
</StyledLink>
</Box>
{status === 'error' && !isUploadingFiles && (
<ChatError
hasLastSubmission={!!lastSubmissionRef.current}
onRetry={handleRetry}
/>
)}
</Box>
<Box
@@ -941,6 +1007,7 @@ export const Chat = ({
position: relative;
bottom: ${isMobile ? '8px' : '20px'};
margin: auto;
background-color: white;
z-index: 1000;
`}
$gap="6px"
@@ -0,0 +1,80 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
interface ChatErrorProps {
hasLastSubmission: boolean;
onRetry: () => void;
}
export const ChatError = ({ hasLastSubmission, onRetry }: ChatErrorProps) => {
const { t } = useTranslation();
const router = useRouter();
return (
<Box
$direction="column"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
$padding={{ left: '13px' }}
>
<Text $variation="550" $theme="greyscale">
{t('Sorry, an error occurred. Please try again.')}
</Text>
<Box
$direction="row"
$gap="6px"
$align="center"
$margin={{ top: '10px' }}
>
{hasLastSubmission ? (
<Button
size="small"
color="tertiary"
onClick={onRetry}
className="retry-button"
style={{
color: 'var(--c--theme--colors--greyscale-550)',
borderColor: 'var(--c--theme--colors--greyscale-300)',
}}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="15"
viewBox="0 0 11 15"
fill="none"
>
<path
d="M0.733333 10.0333C0.488889 9.61111 0.305556 9.17778 0.183333 8.73333C0.0611111 8.28889 0 7.83333 0 7.36667C0 5.87778 0.516667 4.61111 1.55 3.56667C2.58333 2.52222 3.84444 2 5.33333 2H5.45L4.38333 0.933333L5.31667 0L7.98333 2.66667L5.31667 5.33333L4.38333 4.4L5.45 3.33333H5.33333C4.22222 3.33333 3.27778 3.725 2.5 4.50833C1.72222 5.29167 1.33333 6.24444 1.33333 7.36667C1.33333 7.65556 1.36667 7.93889 1.43333 8.21667C1.5 8.49444 1.6 8.76667 1.73333 9.03333L0.733333 10.0333ZM5.35 14.6667L2.68333 12L5.35 9.33333L6.28333 10.2667L5.21667 11.3333H5.33333C6.44444 11.3333 7.38889 10.9417 8.16667 10.1583C8.94444 9.375 9.33333 8.42222 9.33333 7.3C9.33333 7.01111 9.3 6.72778 9.23333 6.45C9.16667 6.17222 9.06667 5.9 8.93333 5.63333L9.93333 4.63333C10.1778 5.05556 10.3611 5.48889 10.4833 5.93333C10.6056 6.37778 10.6667 6.83333 10.6667 7.3C10.6667 8.78889 10.15 10.0556 9.11667 11.1C8.08333 12.1444 6.82222 12.6667 5.33333 12.6667H5.21667L6.28333 13.7333L5.35 14.6667Z"
fill="currentColor"
/>
</svg>
}
>
{t('Retry')}
</Button>
) : (
<Button
size="small"
color="tertiary"
style={{
color: 'var(--c--theme--colors--greyscale-550)',
borderColor: 'var(--c--theme--colors--greyscale-300)',
}}
onClick={() => {
void router.push('/');
}}
icon={<Icon iconName="add" $color="greyscale" />}
>
{t('Start a new conversation')}
</Button>
)}
</Box>
</Box>
);
};
@@ -0,0 +1,80 @@
import { useRef } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useClipboard } from '@/hook';
interface CopyCodeButtonProps {
onCopy: () => void;
}
const CopyCodeButton = ({ onCopy }: CopyCodeButtonProps) => {
const { t } = useTranslation();
return (
<Box
as="button"
onClick={onCopy}
$css={`
position: absolute;
top: 8px;
right: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #fff;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
width: fit-content;
&:hover {
background:rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.20);
}
`}
>
<Icon
iconName="content_copy"
$size="14px"
$theme="greyscale"
$variation="200"
/>
<Text $size="xs" $theme="greyscale" $variation="200">
{t('Copy code')}
</Text>
</Box>
);
};
interface CodeBlockProps {
children: ReactNode;
[key: string]: unknown;
}
export const CodeBlock = ({ children, ...props }: CodeBlockProps) => {
const preRef = useRef<HTMLPreElement>(null);
const copyToClipboard = useClipboard();
const handleCopy = () => {
const code = preRef.current?.querySelector('code');
copyToClipboard(code?.textContent || '');
};
return (
<>
<CopyCodeButton onCopy={handleCopy} />
<Box ref={preRef} $position="relative" as="pre" {...props}>
{children}
</Box>
</>
);
};
@@ -300,7 +300,6 @@ export const InputChat = ({
$css={`
display: block;
position: relative;
opacity: ${status === 'error' ? '0.5' : '1'};
margin: auto;
width: 100%;
padding: ${isDesktop ? '0' : '0 10px'};
@@ -308,26 +307,22 @@ export const InputChat = ({
`}
>
{/* Bouton de scroll vers le bas */}
{messagesLength > 1 &&
status !== 'streaming' &&
status !== 'submitted' &&
containerRef &&
onScrollToBottom && (
<Box
$css={`
{messagesLength > 1 && containerRef && onScrollToBottom && (
<Box
$css={`
position: relative;
height: 0;
width: 100%;
margin: auto;
max-width: 750px;
`}
>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
/>
</Box>
)}
>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
/>
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && (
<Box
@@ -423,6 +418,7 @@ export const InputChat = ({
fontSize: '1rem',
border: 'none',
resize: 'none',
opacity: status === 'error' ? '0.5' : '1',
fontFamily: 'inherit',
minHeight: '64px',
maxHeight: '200px',
@@ -573,6 +569,9 @@ export const InputChat = ({
$gap="sm"
$padding={{ bottom: 'base' }}
$align="space-between"
$css={`
opacity: ${status === 'error' ? '0.5' : '1'};
`}
>
<Box
$flex="1"
@@ -40,7 +40,7 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
$color="var(--c--theme--colors--greyscale-500)"
$padding={{ all: 'sm' }}
$radius="8px"
$css="font-size: 0.9em;"
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
>
{toolInvocation.state === 'result' ? (
<Text>{`Parsing done: ${documentIdentifiers.join(', ')}`}</Text>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useRef, useState } from 'react';
interface SourceMetadata {
title: string | null;
@@ -9,109 +9,124 @@ interface SourceMetadata {
// Cache global pour éviter de refetch les mêmes URLs
const metadataCache = new Map<string, SourceMetadata>();
const fetchingUrls = new Set<string>();
export const useSourceMetadataCache = () => {
const [cache, setCache] =
useState<Map<string, SourceMetadata>>(metadataCache);
const [, forceUpdate] = useState({});
const updateCountRef = useRef(0);
const prefetchMetadata = async (url: string) => {
// Si déjà en cache, ne rien faire
if (metadataCache.has(url)) {
return;
const triggerUpdate = useCallback(() => {
updateCountRef.current++;
if (updateCountRef.current % 5 === 0) {
forceUpdate({});
}
}, []);
// Marquer comme en cours de chargement
metadataCache.set(url, {
title: null,
favicon: null,
loading: true,
error: false,
});
setCache(new Map(metadataCache));
try {
if (!url.startsWith('http')) {
metadataCache.set(url, {
title: url,
favicon: '📄',
loading: false,
error: false,
});
setCache(new Map(metadataCache));
const prefetchMetadata = useCallback(
async (url: string) => {
if (metadataCache.has(url) || fetchingUrls.has(url)) {
return;
}
const parser = new DOMParser();
fetchingUrls.add(url);
metadataCache.set(url, {
title: null,
favicon: null,
loading: true,
error: false,
});
triggerUpdate();
let response;
try {
response = await fetch(url, {
mode: 'cors',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ChatBot/1.0)',
},
if (!url.startsWith('http')) {
metadataCache.set(url, {
title: url,
favicon: '📄',
loading: false,
error: false,
});
fetchingUrls.delete(url);
triggerUpdate();
return;
}
const parser = new DOMParser();
let response;
try {
response = await fetch(url, {
mode: 'cors',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ChatBot/1.0)',
},
});
} catch {
// Si CORS échoue, utiliser juste le hostname
metadataCache.set(url, {
title: new URL(url).hostname,
favicon: null,
loading: false,
error: false,
});
fetchingUrls.delete(url);
triggerUpdate();
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const doc = parser.parseFromString(html, 'text/html');
// Récupérer le titre
const pageTitle =
doc.querySelector('title')?.textContent || new URL(url).hostname;
// Récupérer le favicon
let faviconUrl =
doc.querySelector('link[rel="icon"]')?.getAttribute('href') ||
doc.querySelector('link[rel="shortcut icon"]')?.getAttribute('href');
if (!faviconUrl) {
const urlObj = new URL(url);
faviconUrl = `${urlObj.origin}/favicon.ico`;
}
// Convertir les URLs relatives en absolues
if (faviconUrl && !faviconUrl.startsWith('http')) {
const urlObj = new URL(url);
faviconUrl = new URL(faviconUrl, urlObj.origin).href;
}
metadataCache.set(url, {
title: pageTitle,
favicon: faviconUrl || null,
loading: false,
error: false,
});
} catch {
// Si CORS échoue, utiliser juste le hostname
fetchingUrls.delete(url);
triggerUpdate();
} catch (err) {
console.log('Error fetching metadata for:', url, err);
metadataCache.set(url, {
title: new URL(url).hostname,
favicon: null,
loading: false,
error: false,
error: true,
});
setCache(new Map(metadataCache));
return;
fetchingUrls.delete(url);
triggerUpdate();
}
},
[triggerUpdate],
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const getMetadata = useCallback((url: string): SourceMetadata | undefined => {
return metadataCache.get(url);
}, []);
const html = await response.text();
const doc = parser.parseFromString(html, 'text/html');
// Récupérer le titre
const pageTitle =
doc.querySelector('title')?.textContent || new URL(url).hostname;
// Récupérer le favicon
let faviconUrl =
doc.querySelector('link[rel="icon"]')?.getAttribute('href') ||
doc.querySelector('link[rel="shortcut icon"]')?.getAttribute('href');
if (!faviconUrl) {
const urlObj = new URL(url);
faviconUrl = `${urlObj.origin}/favicon.ico`;
}
// Convertir les URLs relatives en absolues
if (faviconUrl && !faviconUrl.startsWith('http')) {
const urlObj = new URL(url);
faviconUrl = new URL(faviconUrl, urlObj.origin).href;
}
metadataCache.set(url, {
title: pageTitle,
favicon: faviconUrl || null,
loading: false,
error: false,
});
setCache(new Map(metadataCache));
} catch (err) {
console.log('Error fetching metadata for:', url, err);
metadataCache.set(url, {
title: new URL(url).hostname,
favicon: null,
loading: false,
error: true,
});
setCache(new Map(metadataCache));
}
};
const getMetadata = (url: string): SourceMetadata | undefined => {
return cache.get(url);
};
return { prefetchMetadata, getMetadata, cache };
return { prefetchMetadata, getMetadata };
};
@@ -35,6 +35,7 @@
"Conversation analysis enabled": "Analyse de la conversation activée",
"Copied": "Copié",
"Copy": "Copier",
"Copy code": "Copier le code",
"Default": "Par défaut",
"Delete": "Supprimer",
"Delete a conversation": "Supprimer une conversation",
@@ -59,7 +60,7 @@
"Give feedback": "Faire un retour",
"History": "Historique",
"Home": "Accueil",
"If enabled, this allows us to analyse your exchanges to improve the Assistant. If disabled, all conversations remain confidential and are not used in any way. ": "Si cette option est activée, cela nous permet d'analyser vos conversations afin d'améliorer l'Assistant. Si elle est désactivée, toutes les conversations restent confidentielles et ne sont utilisées d'aucune manière ",
"If enabled, this allows us to analyse your exchanges to improve the Assistant. If disabled, all conversations remain confidential and are not used in any way. ": "Si cette option est activée, cela nous permet d'analyser vos conversations afin d'améliorer l'Assistant. Si elle est désactivée, toutes les conversations restent confidentielles et ne sont utilisées d'aucune manière. ",
"Illustration": "Image",
"Image 401": "Image 401",
"Image 403": "Image 403",
@@ -166,6 +167,7 @@
"Conversation analysis enabled": "Gespreksanalyse ingeschakeld",
"Copied": "Gekopieerd",
"Copy": "Kopiëren",
"Copy code": "Kopieer code",
"Default": "Standaard",
"Delete": "Verwijderen",
"Delete a conversation": "Een gesprek verwijderen",
@@ -297,6 +299,7 @@
"Conversation analysis enabled": "Анализ бесед включён",
"Copied": "Скопировано",
"Copy": "Копировать",
"Copy code": "Скопировать код",
"Default": "По-умолчанию",
"Delete": "Удалить",
"Delete a conversation": "Удалить беседу",
@@ -428,6 +431,7 @@
"Conversation analysis enabled": "Аналіз розмов увімкнено",
"Copied": "Скопійовано",
"Copy": "Копіювати",
"Copy code": "Скопіювати код",
"Default": "За замовчуванням",
"Delete": "Видалити",
"Delete a conversation": "Видалити розмову",
@@ -235,6 +235,7 @@ ul a:hover {
figure[data-rehype-pretty-code-figure] {
margin-left: 0;
margin-right: 0;
position: relative;
}
figure[data-rehype-pretty-code-figure] > pre {
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/home/');
});
test.describe('Chat page', () => {
test('it checks the page is displayed properly', async ({ page }) => {
await page.goto('/');
const newChatButton = page.getByRole('button', { name: 'New chat' });
await expect(newChatButton).toBeVisible();
const chatInput = page.getByRole('textbox', {
name: 'Enter your message or a',
});
await expect(chatInput).toBeVisible();
const attachmentButton = page.getByRole('button', {
name: 'Add attach file',
});
await expect(attachmentButton).toBeVisible();
const websearchButton = page.getByRole('button', {
name: 'Research on the web',
});
await expect(websearchButton).toBeVisible();
const sendMessageButton = page.getByRole('button', { name: 'Send' });
await expect(sendMessageButton).toBeVisible();
});
test('the user can chat with LLM (simple)', async ({ page }) => {
await page.goto('/');
const newChatButton = page.getByRole('button', { name: 'New chat' });
await expect(newChatButton).toBeVisible();
const chatInput = page.getByRole('textbox', {
name: 'Enter your message or a',
});
await chatInput.click();
await chatInput.fill('Hello, how are you?');
const sendMessageButton = page.getByRole('button', { name: 'Send' });
await expect(sendMessageButton).toBeEnabled();
await page.keyboard.press('Enter');
// Wait for the response to appear
await page
.getByRole('button', { name: 'See more' })
.waitFor({ timeout: 10000 });
const copyButton = page.getByRole('button', { name: 'Copy' });
await expect(copyButton).toBeVisible();
const messageContent = page.getByText('Lorem ipsum dolor sit amet');
await expect(messageContent).toBeVisible();
// Check history
const chatHistoryLink = page
.getByRole('link', { name: 'Simple chat icon Hello, how' })
.first();
await expect(chatHistoryLink).toBeVisible();
await newChatButton.click();
await page
.getByRole('heading', { name: 'What is on your mind?' })
.isVisible();
await chatHistoryLink.click();
await expect(messageContent).toBeVisible();
});
});
@@ -24,9 +24,7 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(
h2.getByText('Your sovereign AI assistant'),
).toBeVisible();
await expect(h2.getByText('Your sovereign AI assistant')).toBeVisible();
await expect(footer).toBeVisible();
});
@@ -74,9 +72,7 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(
h2.getByText('Your sovereign AI assistant'),
).toBeVisible();
await expect(h2.getByText('Your sovereign AI assistant')).toBeVisible();
await expect(footer).toBeVisible();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "0.0.8",
"version": "0.0.10",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "conversations",
"version": "0.0.8",
"version": "0.0.10",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-conversations",
"version": "0.0.8",
"version": "0.0.10",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "0.0.8",
"version": "0.0.10",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:conversations",
+9 -29
View File
@@ -6451,14 +6451,6 @@ babel-preset-jest@^29.6.3:
babel-plugin-jest-hoist "^29.6.3"
babel-preset-current-node-syntax "^1.0.0"
babel-runtime@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
dependencies:
core-js "^2.4.0"
regenerator-runtime "^0.11.0"
bail@^2.0.0:
version "2.0.2"
resolved "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz"
@@ -7004,11 +6996,6 @@ core-js-compat@^3.40.0:
dependencies:
browserslist "^4.24.4"
core-js@^2.4.0:
version "2.6.12"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
core-js@^3.0.0, core-js@^3.38.1:
version "3.42.0"
resolved "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz"
@@ -10097,7 +10084,14 @@ loose-envify@^1.0.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lottie-web@^5.12.2:
lottie-react@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/lottie-react/-/lottie-react-2.4.1.tgz#4bd3f2a8a5e48edbd43c05ca5080fdd50f049d31"
integrity sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==
dependencies:
lottie-web "^5.10.2"
lottie-web@^5.10.2:
version "5.13.0"
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.13.0.tgz#441d3df217cc8ba302338c3f168e1a3af0f221d3"
integrity sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==
@@ -11428,7 +11422,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -11825,15 +11819,6 @@ react-lifecycles-compat@^3.0.0:
resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-lottie@^1.2.10:
version "1.2.10"
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
integrity sha512-x0eWX3Z6zSx1XM5QSjnLupc6D22LlMCB0PH06O/N/epR2hsLaj1Vxd9RtMnbbEHjJ/qlsgHJ6bpN3vnZI92hjw==
dependencies:
babel-runtime "^6.26.0"
lottie-web "^5.12.2"
prop-types "^15.6.1"
react-markdown@10.1.0:
version "10.1.0"
resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz"
@@ -12101,11 +12086,6 @@ regenerate@^1.4.2:
resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz"
integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
regenerator-runtime@^0.11.0:
version "0.11.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regex-recursion@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-6.0.2.tgz#a0b1977a74c87f073377b938dbedfab2ea582b33"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.0.8",
"version": "0.0.10",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {