Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f52a27f218 | |||
| 3e467cacf2 | |||
| 120b204729 | |||
| d9078e75e5 | |||
| 09b003856b | |||
| 0b5317a773 | |||
| abf61a9556 | |||
| 3e8c5c77d5 | |||
| ddfc86a88f | |||
| e7d76e4477 | |||
| fd3399dd66 | |||
| 13c6499c66 | |||
| a0b31e1e61 | |||
| daf90cf110 | |||
| 29f76fe040 |
@@ -44,9 +44,6 @@ env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# Configuration
|
||||
**/conversations/configuration/llm/dev.json
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
+17
-3
@@ -8,19 +8,32 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Fixed
|
||||
|
||||
- ✨(backend) add FindRagBackend
|
||||
- ⚡️(front) performance improvements on chat input
|
||||
- 💄(front) i18n and standardize pdf parsing display
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) consider PDF documents as other kind of documents #234
|
||||
|
||||
## [0.0.11] - 2026-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(e2e) fix test-e2e-chromium
|
||||
- 🐛(back) fix system prompt compatibility with self-hosted models #200
|
||||
- ⚰️(back) remove dead code and unused files
|
||||
- 🐛(back) prevent tool call timeouts
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) remove thinking part from frontend #227
|
||||
|
||||
## [0.0.10] - 2025-12-15
|
||||
|
||||
@@ -176,7 +189,8 @@ and this project adheres to
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.11...main
|
||||
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
|
||||
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
|
||||
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
|
||||
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
-20
@@ -16,13 +16,6 @@ services:
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
docling-serve:
|
||||
image: quay.io/docling-project/docling-serve:latest
|
||||
ports:
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- DOCLING_SERVE_ARTIFACTS_PATH=""
|
||||
|
||||
maildev:
|
||||
image: maildev/maildev:latest
|
||||
ports:
|
||||
@@ -78,9 +71,6 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -94,16 +84,11 @@ services:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
docling-serve:
|
||||
condition: service_started
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -192,8 +177,3 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
+1
-5
@@ -95,11 +95,7 @@ These are the environment variables you can set for the `conversations-backend`
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | conversations |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/conversations/configuration/theme/default.json | BASE_DIR/conversations/configuration/theme/default.json |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| FIND_API_KEY | API key of Find | |
|
||||
| FIND_API_URL | URL of Find | `https://app-find/api` |
|
||||
| FIND_API_TIMEOUT | Find API timeout | 30 |
|
||||
| DOCLING_SERVE_URL | URL of Docling Serve | `http://docling-serve:5001` |
|
||||
| DOCLING_API_TIMEOUT | Docling API timeout | 60 |
|
||||
|
||||
|
||||
## conversations-frontend image
|
||||
|
||||
|
||||
@@ -244,9 +244,9 @@ For Mistral AI models using the Etalab platform:
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "mistral-medium",
|
||||
"model_name": "mistral-medium-2508",
|
||||
"human_readable_name": "Mistral Medium (Etalab)",
|
||||
"hrid": "mistral-large",
|
||||
"model_name": "mistral-large-latest",
|
||||
"human_readable_name": "Mistral Large (Etalab)",
|
||||
"provider_name": "mistral-etalab",
|
||||
"profile": null,
|
||||
"settings": {
|
||||
|
||||
@@ -357,7 +357,6 @@ The RAG backend performs semantic search to find the most relevant content:
|
||||
rag_results = document_store.search(
|
||||
query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
**kwargs, # Additional search parameters like session with access_token
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.j
|
||||
# Features
|
||||
FEATURE_FLAG_WEB_SEARCH=ENABLED
|
||||
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES=3
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Document parsers for RAG backends."""
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseParser:
|
||||
"""Base class for document parsers."""
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for storage.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
|
||||
class DoclingServeParser(BaseParser):
|
||||
"""Document parser using Docling Serve API."""
|
||||
|
||||
def __init__(self):
|
||||
self.endpoint = urljoin(settings.DOCLING_SERVE_URL, "/v1/convert/file")
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse document using Docling Serve API."""
|
||||
timeout = settings.DOCLING_SERVE_TIMEOUT
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
files={
|
||||
"files": content,
|
||||
},
|
||||
data={
|
||||
"image_export_mode": "placeholder",
|
||||
"md_page_break_placeholder": "\n\n",
|
||||
"do_picture_description": "true",
|
||||
"document_timeout": timeout,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()["document"]["md_content"]
|
||||
|
||||
|
||||
class AlbertParser(BaseParser):
|
||||
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
|
||||
|
||||
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse PDF document using Albert API."""
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
|
||||
},
|
||||
files={
|
||||
"file": (name, content, content_type),
|
||||
"output_format": (None, "markdown"),
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse document based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
@@ -13,7 +13,7 @@ import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import DoclingServeParser
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +26,9 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
|
||||
It provides methods to:
|
||||
- Create a collection for the search operation.
|
||||
- Parse documents and convert them to Markdown format:
|
||||
+ Handle PDF parsing using the Albert API.
|
||||
+ Use the DocumentConverter (markitdown) for other formats.
|
||||
- Store parsed documents in the Albert collection.
|
||||
- Perform a search operation using the Albert API.
|
||||
"""
|
||||
@@ -43,9 +46,10 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
}
|
||||
self._collections_endpoint = urljoin(self._base_url, "/v1/collections")
|
||||
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
|
||||
self._pdf_parser_endpoint = urljoin(self._base_url, "/v1/parse-beta")
|
||||
self._search_endpoint = urljoin(self._base_url, "/v1/search")
|
||||
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser = DoclingServeParser()
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -110,7 +114,59 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
"""
|
||||
Parse the PDF document content and return the text content.
|
||||
This method should handle the logic to convert the PDF into
|
||||
a format suitable for the Albert API.
|
||||
"""
|
||||
response = requests.post(
|
||||
self._pdf_parser_endpoint,
|
||||
headers=self._headers,
|
||||
files={
|
||||
"file": (
|
||||
name,
|
||||
content,
|
||||
content_type,
|
||||
), # Use the name as the filename in the request
|
||||
"output_format": (None, "markdown"), # Specify the output format as Markdown,
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for the Albert API.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
# Implement the parsing logic here
|
||||
if content_type == "application/pdf":
|
||||
# Handle PDF parsing
|
||||
markdown_content = self.parse_pdf_document(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
else:
|
||||
markdown_content = DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
return markdown_content
|
||||
|
||||
def store_document(self, name: str, content: str) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -118,7 +174,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
response = requests.post(
|
||||
urljoin(self._base_url, self._documents_endpoint),
|
||||
@@ -133,7 +188,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
async def astore_document(self, name: str, content: str) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -141,7 +196,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
@@ -159,14 +213,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
**kwargs: Additional arguments.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
@@ -203,14 +256,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
),
|
||||
)
|
||||
|
||||
async def asearch(self, query, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Perform an asynchronous search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
**kwargs: Additional arguments.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import List, Optional
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from chat.agent_rag.constants import RAGWebResults
|
||||
from chat.agent_rag.document_converter.parser import BaseParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +38,6 @@ class BaseRagBackend:
|
||||
self.collection_id = collection_id
|
||||
self.read_only_collection_id = read_only_collection_id or []
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser: BaseParser = BaseParser()
|
||||
|
||||
def get_all_collection_ids(self) -> List[str]:
|
||||
"""
|
||||
@@ -55,7 +53,7 @@ class BaseRagBackend:
|
||||
|
||||
collection_ids = []
|
||||
if self.collection_id:
|
||||
collection_ids.append(self.collection_id)
|
||||
collection_ids.append(int(self.collection_id))
|
||||
if self.read_only_collection_id:
|
||||
collection_ids.extend(
|
||||
[int(collection_id) for collection_id in self.read_only_collection_id]
|
||||
@@ -90,9 +88,9 @@ class BaseRagBackend:
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
return self.parser.parse_document(name, content_type, content)
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
def store_document(self, name: str, content: str) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -100,11 +98,10 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
async def astore_document(self, name: str, content: str) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -112,13 +109,10 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
return await sync_to_async(self.store_document)(name=name, content=content, **kwargs)
|
||||
return await sync_to_async(self.store_document)(name=name, content=content)
|
||||
|
||||
def parse_and_store_document(
|
||||
self, name: str, content_type: str, content: BytesIO, **kwargs
|
||||
) -> str:
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
"""
|
||||
Parse the document and store it in the Albert collection.
|
||||
|
||||
@@ -126,13 +120,12 @@ class BaseRagBackend:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
if not self.collection_id:
|
||||
raise RuntimeError("The RAG backend requires collection_id")
|
||||
|
||||
document_content = self.parse_document(name, content_type, content)
|
||||
self.store_document(name, document_content, **kwargs)
|
||||
self.store_document(name, document_content)
|
||||
return document_content
|
||||
|
||||
def delete_collection(self) -> None:
|
||||
@@ -149,27 +142,17 @@ class BaseRagBackend:
|
||||
"""
|
||||
return await sync_to_async(self.delete_collection)()
|
||||
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def asearch(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query asynchronously.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
|
||||
Search the collection for the given query.
|
||||
"""
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count, **kwargs)
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count)
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Implementation of the Find API for RAG document search."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import DoclingServeParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
from utils.oidc import with_fresh_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SUPPORTED_LANGUAGE_CODES = ["en", "fr", "de", "nl"]
|
||||
|
||||
|
||||
class FindRagBackend(BaseRagBackend):
|
||||
"""
|
||||
This class is a placeholder for the Find API implementation.
|
||||
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
|
||||
|
||||
It provides methods to:
|
||||
- Store parsed documents in the Find index.
|
||||
- Perform a search operation using the Find API.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collection_id: Optional[str] = None,
|
||||
read_only_collection_id: Optional[List[str]] = None,
|
||||
):
|
||||
# Initialize any necessary parameters or configurations here
|
||||
super().__init__(collection_id, read_only_collection_id)
|
||||
self.api_key = settings.FIND_API_KEY
|
||||
self.search_endpoint = "api/v1.0/documents/search/"
|
||||
self.indexing_endpoint = "api/v1.0/documents/index/"
|
||||
self.parser = DoclingServeParser()
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
init collection_id
|
||||
"""
|
||||
self.collection_id = self.collection_id or str(uuid.uuid4())
|
||||
return self.collection_id
|
||||
|
||||
def delete_collection(self) -> None:
|
||||
"""
|
||||
Deletion not available
|
||||
"""
|
||||
logger.warning("deletion of collections is not yet supported in FindRagBackend")
|
||||
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
index document in Find
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
user_sub (str): The user subject identifier for access control.
|
||||
"""
|
||||
logger.debug("index document '%s' in Find", name)
|
||||
|
||||
user_sub = kwargs.get("user_sub")
|
||||
if not user_sub:
|
||||
raise ValueError("user_sub is required to store document in FindRagBackend")
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.indexing_endpoint),
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"id": str(uuid4()),
|
||||
"title": str(name) or "",
|
||||
"depth": 0,
|
||||
"path": str(name) or "",
|
||||
"numchild": 0,
|
||||
"content": content or "",
|
||||
"created_at": timezone.now().isoformat(),
|
||||
"updated_at": timezone.now().isoformat(),
|
||||
"tags": [f"collection-{self.collection_id}"],
|
||||
"size": len(content.encode("utf-8")),
|
||||
"users": [user_sub],
|
||||
"groups": [],
|
||||
"reach": "authenticated",
|
||||
"is_active": True,
|
||||
},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@with_fresh_access_token
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Find API.
|
||||
Uses the user's OIDC token from the request session.
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. Expected: 'session' containing OIDC tokens,
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
"""
|
||||
logger.debug("search documents in Find with query '%s'", query)
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.search_endpoint),
|
||||
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
|
||||
json={
|
||||
"q": query,
|
||||
"tags": [
|
||||
f"collection-{collection_id}" for collection_id in self.get_all_collection_ids()
|
||||
],
|
||||
"k": results_count,
|
||||
},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return RAGWebResults(
|
||||
data=[
|
||||
RAGWebResult(
|
||||
url=get_language_value(result["_source"], "title"),
|
||||
content=get_language_value(result["_source"], "content"),
|
||||
score=result["_score"],
|
||||
)
|
||||
for result in response.json()
|
||||
],
|
||||
usage=RAGWebUsage(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_language_value(source, language_field):
|
||||
"""
|
||||
extract the value of the language field with the correct language_code extension.
|
||||
"title" and "content" have extensions like "title.en" or "title.fr".
|
||||
get_language_value will return the value regardless of the extension.
|
||||
"""
|
||||
for language_code in SUPPORTED_LANGUAGE_CODES:
|
||||
if f"{language_field}.{language_code}" in source:
|
||||
return source[f"{language_field}.{language_code}"]
|
||||
raise ValueError(f"No '{language_field}' field with any supported language code in object")
|
||||
@@ -11,7 +11,7 @@ import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import DoclingServeParser
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.models import ChatConversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -105,6 +105,32 @@ class AlbertRagDocumentSearch:
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for the Albert API.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
# Implement the parsing logic here
|
||||
if content_type == "application/pdf":
|
||||
# Handle PDF parsing
|
||||
markdown_content = self._parse_pdf_document(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
else:
|
||||
markdown_content = DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
return markdown_content
|
||||
|
||||
def _store_document(self, name: str, content: str):
|
||||
"""
|
||||
@@ -130,16 +156,16 @@ class AlbertRagDocumentSearch:
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: bytes):
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO):
|
||||
"""
|
||||
Parse the document and store it in the Albert collection.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (bytes): The content of the document as a BytesIO stream.
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
"""
|
||||
document_content = DoclingServeParser().parse_document(name, content_type, content)
|
||||
document_content = self.parse_document(name, content_type, content)
|
||||
self._store_document(name, document_content)
|
||||
return document_content
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import httpx
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models import get_user_agent
|
||||
from pydantic_ai.profiles import ModelProfile
|
||||
from pydantic_ai.toolsets import FunctionToolset
|
||||
|
||||
from chat.tools import get_pydantic_tools_by_name
|
||||
|
||||
@@ -174,20 +173,18 @@ class BaseAgent(Agent):
|
||||
# and pydantic_ai.models.infer_model()
|
||||
_model_instance = self.configuration.model_name
|
||||
|
||||
_system_prompt = self.configuration.system_prompt
|
||||
_base_toolset = (
|
||||
[
|
||||
FunctionToolset(
|
||||
tools=[
|
||||
get_pydantic_tools_by_name(tool_name)
|
||||
for tool_name in self.configuration.tools
|
||||
]
|
||||
)
|
||||
]
|
||||
if self.configuration.tools
|
||||
else None
|
||||
)
|
||||
_system_prompt = self.get_system_prompt()
|
||||
|
||||
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
_tools = self.get_tools()
|
||||
|
||||
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
|
||||
|
||||
def get_system_prompt(self) -> str | None:
|
||||
"""Override this method to customize the system prompt."""
|
||||
return self.configuration.system_prompt
|
||||
|
||||
def get_tools(self) -> list | None:
|
||||
"""Override this method to customize tools."""
|
||||
if not self.configuration.tools:
|
||||
return []
|
||||
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
|
||||
@@ -131,3 +131,25 @@ class ConversationAgent(BaseAgent):
|
||||
if tool.name.startswith("web_search_"):
|
||||
return tool.name
|
||||
return None
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class TitleGenerationAgent(BaseAgent):
|
||||
"""Agent that generates concise, descriptive titles for conversations."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
|
||||
output_type=str,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_tools(self):
|
||||
return []
|
||||
|
||||
def get_system_prompt(self):
|
||||
return (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ implementation while keeping the *exact* same public API so that no
|
||||
changes are needed in views.py or tests.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
@@ -52,10 +53,9 @@ from pydantic_ai.messages import (
|
||||
)
|
||||
|
||||
from core.feature_flags.helpers import is_feature_enabled
|
||||
from core.file_upload.utils import generate_retrieve_policy
|
||||
|
||||
from chat import models
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.agents.conversation import ConversationAgent, TitleGenerationAgent
|
||||
from chat.agents.local_media_url_processors import (
|
||||
update_history_local_urls,
|
||||
update_local_urls,
|
||||
@@ -76,7 +76,7 @@ from chat.tools.document_generic_search_rag import add_document_rag_search_tool_
|
||||
from chat.tools.document_search_rag import add_document_rag_search_tool
|
||||
from chat.tools.document_summarize import document_summarize
|
||||
from chat.vercel_ai_sdk.core import events_v4, events_v5
|
||||
from chat.vercel_ai_sdk.encoder import EventEncoder
|
||||
from chat.vercel_ai_sdk.encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder
|
||||
|
||||
# Keep at the top of the file to avoid mocking issues
|
||||
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
|
||||
@@ -92,7 +92,6 @@ class ContextDeps:
|
||||
|
||||
conversation: models.ChatConversation
|
||||
user: User
|
||||
session: Optional[Dict] = None
|
||||
web_search_enabled: bool = False
|
||||
|
||||
|
||||
@@ -107,14 +106,7 @@ def get_model_configuration(model_hrid: str):
|
||||
class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
"""Service class for AI-related operations (Pydantic-AI edition)."""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self,
|
||||
conversation: models.ChatConversation,
|
||||
user,
|
||||
session=None,
|
||||
model_hrid=None,
|
||||
language=None,
|
||||
):
|
||||
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None):
|
||||
"""
|
||||
Initialize the AI agent service.
|
||||
|
||||
@@ -130,7 +122,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
self._langfuse_available = settings.LANGFUSE_ENABLED
|
||||
self._store_analytics = self._langfuse_available and user.allow_conversation_analytics
|
||||
self.event_encoder = EventEncoder("v4") # Always use v4 for now
|
||||
self.event_encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION) # We use v4 for now
|
||||
|
||||
self._support_streaming = True
|
||||
if (streaming := get_model_configuration(self.model_hrid).supports_streaming) is not None:
|
||||
@@ -144,7 +136,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
self._context_deps = ContextDeps(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
session=session,
|
||||
web_search_enabled=self._is_web_search_enabled,
|
||||
)
|
||||
|
||||
@@ -283,21 +274,23 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
# Retrieve the document data
|
||||
with default_storage.open(key, "rb") as file:
|
||||
document_data = file.read()
|
||||
parsed_content = document_store.parse_and_store_document(
|
||||
# Run in thread to avoid blocking the event loop during parsing
|
||||
parsed_content = await asyncio.to_thread(
|
||||
document_store.parse_and_store_document,
|
||||
name=document.identifier,
|
||||
content_type=document.media_type,
|
||||
content=document_data,
|
||||
user_sub=self.user.sub,
|
||||
)
|
||||
else:
|
||||
# Remote URL
|
||||
raise ValueError("External document URL are not accepted yet.")
|
||||
else:
|
||||
parsed_content = document_store.parse_and_store_document(
|
||||
# Run in thread to avoid blocking the event loop during parsing
|
||||
parsed_content = await asyncio.to_thread(
|
||||
document_store.parse_and_store_document,
|
||||
name=document.identifier,
|
||||
content_type=document.media_type,
|
||||
content=document.data,
|
||||
user_sub=self.user.sub,
|
||||
)
|
||||
|
||||
if not document.media_type.startswith("text/"):
|
||||
@@ -482,27 +475,19 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
_tool_is_streaming = False
|
||||
_model_response_message_id = None
|
||||
|
||||
# Check for existing non-PDF documents in the conversation:
|
||||
# - if no document at all: do nothing
|
||||
# - if only PDFs: prepare document URLs for the agent
|
||||
# - if other document types: add the RAG search tool
|
||||
# to allow searching in all kinds of documents
|
||||
has_not_pdf_docs = await (
|
||||
# Check for existing documents (any non-image attachment for this conversation)
|
||||
has_documents = await (
|
||||
models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(
|
||||
Q(content_type__startswith="image/") | Q(content_type="application/pdf"),
|
||||
)
|
||||
.exclude(content_type__startswith="image/")
|
||||
.aexists()
|
||||
)
|
||||
|
||||
document_urls = []
|
||||
if not conversation_has_documents and not has_not_pdf_docs:
|
||||
# No documents to process
|
||||
pass
|
||||
elif has_not_pdf_docs:
|
||||
should_enable_rag = conversation_has_documents or has_documents
|
||||
|
||||
if should_enable_rag:
|
||||
add_document_rag_search_tool(self.conversation_agent)
|
||||
|
||||
@self.conversation_agent.instructions
|
||||
@@ -532,30 +517,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
async def summarize(ctx: RunContext, *args, **kwargs) -> ToolReturn:
|
||||
"""Wrap the document_summarize tool to provide context and add the tool."""
|
||||
return await document_summarize(ctx, *args, **kwargs)
|
||||
else:
|
||||
conversation_documents = [
|
||||
cd
|
||||
async for cd in models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(
|
||||
content_type__startswith="image/",
|
||||
)
|
||||
.values_list("key", "content_type")
|
||||
]
|
||||
|
||||
for doc_key, doc_content_type in conversation_documents:
|
||||
if doc_content_type == "application/pdf":
|
||||
_presigned_url = generate_retrieve_policy(doc_key)
|
||||
document_urls.append(
|
||||
DocumentUrl(
|
||||
url=_presigned_url,
|
||||
identifier=doc_key.split("/")[-1],
|
||||
media_type="application/pdf",
|
||||
)
|
||||
)
|
||||
image_key_mapping[_presigned_url] = f"/media-key/{doc_key}"
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# MCP servers (if any) can be initialized here
|
||||
@@ -570,7 +531,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
history.append(ModelResponse(parts=[TextPart(content="ok")], kind="response"))
|
||||
|
||||
async with self.conversation_agent.iter(
|
||||
[user_prompt] + input_images + document_urls,
|
||||
[user_prompt] + input_images,
|
||||
message_history=history, # history will pass through agent's history_processors
|
||||
deps=self._context_deps,
|
||||
toolsets=mcp_servers,
|
||||
@@ -731,8 +692,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
await self._agent_stop_streaming(force_cache_check=True)
|
||||
|
||||
# Persist conversation
|
||||
await sync_to_async(self._update_conversation)(
|
||||
# Prepare conversation update (save deferred until after potential title generation)
|
||||
await sync_to_async(self._prepare_update_conversation)(
|
||||
final_output=run.result.new_messages(),
|
||||
usage=usage,
|
||||
final_output_from_tool=_final_output_from_tool,
|
||||
@@ -741,6 +702,35 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
image_key_mapping=image_key_mapping or None,
|
||||
)
|
||||
|
||||
generated_title = None
|
||||
|
||||
# Auto-generate title after N user messages if not manually set
|
||||
user_messages_count = sum(1 for msg in self.conversation.messages if msg.role == "user")
|
||||
|
||||
should_generate_title = (
|
||||
user_messages_count == settings.AUTO_TITLE_AFTER_USER_MESSAGES
|
||||
and not self.conversation.title_set_by_user_at
|
||||
)
|
||||
|
||||
if should_generate_title:
|
||||
if generated_title := await self._generate_title():
|
||||
self.conversation.title = generated_title
|
||||
|
||||
# Persist conversation (including any generated title)
|
||||
await sync_to_async(self.conversation.save)()
|
||||
|
||||
# Notify frontend about the title update
|
||||
if generated_title:
|
||||
yield events_v4.DataPart(
|
||||
data=[
|
||||
{
|
||||
"type": "conversation_metadata",
|
||||
"conversationId": str(self.conversation.pk),
|
||||
"title": generated_title,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if self._langfuse_available:
|
||||
langfuse.update_current_trace(
|
||||
output=run.result.output if self._store_analytics else "REDACTED"
|
||||
@@ -754,7 +744,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
),
|
||||
)
|
||||
|
||||
def _update_conversation( # noqa: PLR0913
|
||||
def _prepare_update_conversation( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
final_output: List[ModelRequest | ModelMessage],
|
||||
@@ -821,4 +811,39 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
ModelMessagesTypeAdapter.dump_json(final_output).decode("utf-8")
|
||||
)
|
||||
|
||||
self.conversation.save()
|
||||
async def _generate_title(self) -> str | None:
|
||||
"""Generate a title for the conversation using LLM based on first messages."""
|
||||
|
||||
# Build context from messages
|
||||
# Note: We intentionally use only msg.content for title generation.
|
||||
# Parts containing tool invocations or reasoning are excluded as they
|
||||
# don't contribute to a meaningful context here
|
||||
context = "\n".join(
|
||||
f"{msg.role}: {(msg.content or '')[:300]}" # Limit content length per message
|
||||
for msg in self.conversation.messages
|
||||
if msg.content
|
||||
)
|
||||
|
||||
language = self.language or settings.LANGUAGE_CODE
|
||||
prompt = (
|
||||
"Generate a concise title (3-5 words, max 100 characters) for this conversation.\n\n"
|
||||
"Requirements:\n"
|
||||
"- Capture the main topic or user intent\n"
|
||||
"- The title must be a simple string, no markdown\n"
|
||||
"- Help the user quickly identify the conversation\n"
|
||||
f"- Match the language of the user messages (default: {language})\n"
|
||||
"- Avoid the word 'summary' unless explicitly requested\n\n"
|
||||
"Output: Title text only, no quotes, labels, or explanation.\n\n"
|
||||
f"Conversation:\n{context}"
|
||||
)
|
||||
try:
|
||||
agent = TitleGenerationAgent()
|
||||
result = await agent.run(prompt)
|
||||
title = (result.output or "").strip()[:100] # Enforce max length (conversation.title)
|
||||
logger.info("Generated title for conversation %s: %s", self.conversation.pk, title)
|
||||
return title if title else None
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to generate title for conversation %s: %s", self.conversation.pk, exc
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Helpers to prevent proxy timeouts during long-running stream operations.
|
||||
|
||||
This module provides utilities to wrap synchronous and asynchronous iterators
|
||||
with keepalive messages. When a stream pauses for longer than the specified
|
||||
interval, keepalive messages are injected to prevent proxy/gateway
|
||||
timeouts while waiting for the stream data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .vercel_ai_sdk.core.events_v4 import DataPart as V4DataPart
|
||||
from .vercel_ai_sdk.core.events_v5 import DataPart as V5DataPart
|
||||
from .vercel_ai_sdk.encoder import (
|
||||
CURRENT_EVENT_ENCODER_VERSION,
|
||||
EventEncoder,
|
||||
EventEncoderVersion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_keepalive_message() -> str:
|
||||
"""Generate a keepalive message based on encoder/SDK version."""
|
||||
if CURRENT_EVENT_ENCODER_VERSION == EventEncoderVersion.V4:
|
||||
event = V4DataPart(data=[{"status": "WAITING"}])
|
||||
else:
|
||||
event = V5DataPart(data={"status": "WAITING"})
|
||||
encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION)
|
||||
return encoder.encode(event)
|
||||
|
||||
|
||||
async def stream_with_keepalive_async(
|
||||
stream: AsyncIterator[str],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Wrap an async iterator to emit keepalive during long pauses.
|
||||
|
||||
Args:
|
||||
stream: The async iterator to wrap
|
||||
Yields:
|
||||
Items from the original stream, plus keepalive messages during pauses
|
||||
Raises:
|
||||
Any exception raised by the original stream
|
||||
"""
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
finished = asyncio.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
|
||||
async def producer():
|
||||
"""Background task that consumes the original stream into a queue."""
|
||||
|
||||
try:
|
||||
async for stream_item in stream:
|
||||
await q.put(stream_item)
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
# Pass exceptions through the queue so the consumer can re-raise them.
|
||||
# This ensures errors aren't silently swallowed.
|
||||
await q.put(exc)
|
||||
finally:
|
||||
finished.set()
|
||||
await q.put(None) # Sentinel to signal completion
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(q.get(), timeout=settings.KEEPALIVE_INTERVAL)
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
except asyncio.TimeoutError:
|
||||
# No data received within interval
|
||||
if finished.is_set():
|
||||
# Producer is done, queue is empty (else we would not have timed out)
|
||||
break
|
||||
|
||||
logger.debug("Send keepalive")
|
||||
yield keepalive_message
|
||||
finally:
|
||||
# Cleanup
|
||||
producer_task.cancel()
|
||||
try:
|
||||
await producer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def get_current_time() -> float:
|
||||
"""Get current monotonic time, avoiding freezegun interferences.
|
||||
|
||||
Returns time.monotonic() which:
|
||||
- Is NOT affected by freezegun's @freeze_time decorator (unlike time.time())
|
||||
- Prevents issues where frozen time in main thread differs from real time in
|
||||
spawned threads, causing incorrect keepalive interval computation
|
||||
- Is the best clock for measuring time intervals
|
||||
|
||||
Wrapped in a function to ease mocking in tests.
|
||||
|
||||
Returns:
|
||||
float: Monotonic time in seconds since an arbitrary reference point
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def stream_with_keepalive_sync(stream: Iterator[str]) -> Iterator[str]:
|
||||
"""Wraps a synchronous stream with keepalive messages."""
|
||||
|
||||
q: queue.Queue = queue.Queue()
|
||||
stream_done = threading.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
# Mutable container so threads can read/write shared timestamp
|
||||
last_yield_time = [get_current_time()]
|
||||
|
||||
def consume_stream():
|
||||
"""Read from source stream and forward chunks to queue."""
|
||||
try:
|
||||
for chunk in stream:
|
||||
if stream_done.is_set():
|
||||
return # early exit
|
||||
q.put(chunk, timeout=1) # Arbitrary timeout prevents blocking forever
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception("Error in stream consumption")
|
||||
q.put(e)
|
||||
finally:
|
||||
stream_done.set()
|
||||
|
||||
def send_keepalives():
|
||||
"""Inject keepalive messages when idle too long.
|
||||
|
||||
Uses get_current_time() (time.monotonic) instead of time.time()
|
||||
to avoid issues with freezegun in tests.
|
||||
"""
|
||||
while not stream_done.is_set():
|
||||
# Sleep before checking to give main loop time to process and update timestamp
|
||||
time.sleep(0.5) # let main loop process first, empiric value
|
||||
if get_current_time() - last_yield_time[0] >= settings.KEEPALIVE_INTERVAL:
|
||||
try:
|
||||
q.put(keepalive_message, timeout=0.1)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
for target in (consume_stream, send_keepalives):
|
||||
threading.Thread(target=target, daemon=True).start()
|
||||
|
||||
try:
|
||||
# Continue while stream is active or queue has still items
|
||||
while not stream_done.is_set() or not q.empty():
|
||||
try:
|
||||
item = q.get(timeout=1) # short timeout, avoid blocking and stay responsive
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# Re-raise from consume_stream
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
|
||||
yield item
|
||||
last_yield_time[0] = get_current_time()
|
||||
finally:
|
||||
# Signal threads to stop
|
||||
stream_done.set()
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-30 09:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("chat", "0004_chatconversationattachment_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="chatconversation",
|
||||
name="title_set_by_user_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic title generation.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -44,7 +44,12 @@ class ChatConversation(BaseModel):
|
||||
null=True,
|
||||
help_text="Title of the chat conversation",
|
||||
)
|
||||
|
||||
title_set_by_user_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic "
|
||||
"title generation.",
|
||||
)
|
||||
ui_messages = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField # pylint: disable=no-name-in-module
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
@@ -27,6 +28,12 @@ class ChatConversationSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "messages"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# If title is being changed, mark it as user-set
|
||||
if "title" in validated_data and validated_data["title"] != instance.title:
|
||||
instance.title_set_by_user_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ChatConversationInputSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Test cases for the TitleGenerationAgent class."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def base_settings(settings):
|
||||
"""Set up base settings for the tests."""
|
||||
settings.AI_BASE_URL = "https://api.llm.com/v1/"
|
||||
settings.AI_API_KEY = "test-key"
|
||||
settings.AI_MODEL = "model-XYZ"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
|
||||
settings.AI_AGENT_TOOLS = []
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_uses_default_model_hrid(settings):
|
||||
"""Test that TitleGenerationAgent uses LLM_DEFAULT_MODEL_HRID from settings."""
|
||||
settings.AI_MODEL = "custom-llm-model"
|
||||
settings.AI_BASE_URL = "https://custom.api.com/v1/"
|
||||
settings.AI_API_KEY = "custom-key"
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert settings.LLM_CONFIGURATIONS["default-model"].model_name == "custom-llm-model"
|
||||
assert agent._model.model_name == "custom-llm-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_model_configuration():
|
||||
"""Test that the agent model is properly configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert agent._model.model_name == "model-XYZ"
|
||||
assert str(agent._model.client.base_url) == "https://api.llm.com/v1/"
|
||||
assert agent._model.client.api_key == "test-key"
|
||||
|
||||
|
||||
def test_title_generation_agent_has_no_tools():
|
||||
"""Test that TitleGenerationAgent has no tools configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert agent._function_toolset.tools == {}
|
||||
assert not agent.get_tools()
|
||||
|
||||
|
||||
def test_title_generation_agent_instructions():
|
||||
"""Test that the agent instructions contain the system prompt."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
# The agent should have the title generation system prompt as instructions
|
||||
instructions = agent._instructions
|
||||
assert len(instructions) == 1
|
||||
expected = (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
assert instructions[0] == expected
|
||||
@@ -38,6 +38,9 @@ def brave_settings(settings):
|
||||
settings.BRAVE_SEARCH_EXTRA_SNIPPETS = True
|
||||
settings.BRAVE_SUMMARIZATION_ENABLED = False
|
||||
settings.BRAVE_CACHE_TTL = 3600
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
|
||||
)
|
||||
settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = 5
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Common test fixtures for chat views tests."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_process_request():
|
||||
"""
|
||||
Mock process_request to bypass OIDC authentication in tests.
|
||||
"""
|
||||
with mock.patch(
|
||||
"lasuite.oidc_login.decorators.RefreshOIDCAccessToken.process_request"
|
||||
) as mocked_process_request:
|
||||
mocked_process_request.return_value = None
|
||||
yield mocked_process_request
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Common test fixtures for chat conversation endpoint tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
@@ -10,15 +11,9 @@ import respx
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
openai_stream = (
|
||||
def _create_openai_stream_data():
|
||||
"""Helper to create OpenAI stream data."""
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
@@ -59,12 +54,111 @@ def fixture_mock_openai_stream():
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_openai_route(with_delays: bool = False, delay_seconds: float = 1.0):
|
||||
"""Create a mock OpenAI stream route with optional delays."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
lines = openai_stream.splitlines(keepends=True)
|
||||
for i, line in enumerate(lines):
|
||||
yield line.encode()
|
||||
if with_delays and i == 1:
|
||||
# Delay after second line to trigger keepalive during streaming
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
return respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response (no delays).
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=False)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_slow")
|
||||
def fixture_mock_openai_stream_slow():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response with delays to trigger keepalives.
|
||||
|
||||
No @freeze_time decorator because asyncio.sleep() needs real time to work properly.
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=True, delay_seconds=1.0)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_with_title_generation")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream_with_title_generation():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
|
||||
This fixture handles two different types of API calls made during a single request:
|
||||
|
||||
1. **Conversation (streaming)**: The main chat uses `stream=True` to get real-time
|
||||
token-by-token responses. The API returns chunked data like:
|
||||
`data: {"choices": [{"delta": {"content": "Hello"}}]}`
|
||||
|
||||
2. **Title generation (non-streaming)**: After the conversation, the backend calls
|
||||
the API again with `stream=False` to generate a title. This returns a standard
|
||||
JSON response with the complete message.
|
||||
|
||||
The `handle_request` function inspects each incoming request's body to determine
|
||||
which type of response to return:
|
||||
- `{"stream": true, ...}` → SSE streaming response
|
||||
- `{"stream": false, ...}` → JSON response with generated title
|
||||
Each call gets a new generator instance (avoiding generator exhaustion)
|
||||
"""
|
||||
|
||||
def create_stream_response():
|
||||
"""Create a fresh streaming response for each call."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
yield line.encode()
|
||||
|
||||
return httpx.Response(200, stream=mock_stream())
|
||||
|
||||
def create_non_stream_response():
|
||||
"""Create a non-streaming response for title generation."""
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-title",
|
||||
"object": "chat.completion",
|
||||
"created": int(timezone.make_naive(timezone.now()).timestamp()),
|
||||
"model": "test-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "GENERATED TITLE",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 50, "completion_tokens": 5, "total_tokens": 55},
|
||||
},
|
||||
)
|
||||
|
||||
def handle_request(request):
|
||||
"""Route to streaming or non-streaming response based on request."""
|
||||
body = json.loads(request.content)
|
||||
if body.get("stream", False):
|
||||
return create_stream_response()
|
||||
return create_non_stream_response()
|
||||
|
||||
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
side_effect=handle_request
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -221,6 +222,133 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.keepalive.get_current_time")
|
||||
def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
mock_time, api_client, mock_openai_stream
|
||||
):
|
||||
"""Test streaming response contains keepalive messages"""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
mock_time.side_effect = [float(i * 60) for i in range(10)]
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream.called
|
||||
|
||||
chat_conversation.refresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
@@ -1344,3 +1472,143 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z", tick=True)
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_conversation_async_triggers_keepalive(
|
||||
api_client, mock_openai_stream_slow, monkeypatch, caplog, settings
|
||||
):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
|
||||
|
||||
settings.KEEPALIVE_INTERVAL = 1 # s
|
||||
|
||||
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
await api_client.aforce_login(chat_conversation.owner)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(level=logging.DEBUG, logger="chat.views")
|
||||
|
||||
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
assert "Using ASYNC streaming for chat conversation" in caplog.text
|
||||
|
||||
# Wait for the streaming content to be fully received => async iterator -> list
|
||||
# This fails it the streaming is not an async generator
|
||||
response_content = b"".join([content async for content in response.streaming_content]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream_slow.called
|
||||
|
||||
await chat_conversation.arefresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[0].createdAt, # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[1].createdAt, # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
|
||||
# using ANY because time is not frozen in this api mock
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"timestamp": ANY,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
+20
-72
@@ -8,7 +8,6 @@ import logging
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sessions.backends.cache import SessionStore
|
||||
from django.utils import formats, timezone
|
||||
|
||||
import httpx
|
||||
@@ -42,49 +41,28 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
autouse=True,
|
||||
params=[
|
||||
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
|
||||
],
|
||||
)
|
||||
def ai_settings(request, settings):
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
|
||||
# enable on rag document search tool
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
|
||||
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
|
||||
"Based on the following document contents:\n\n{search_results}\n\n"
|
||||
"Please answer the user's question: {user_prompt}"
|
||||
)
|
||||
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
# Albert API settings
|
||||
# Enable Albert API for document search
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
|
||||
)
|
||||
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
|
||||
settings.ALBERT_API_KEY = "albert-api-key"
|
||||
|
||||
# Find API settings
|
||||
settings.FIND_API_URL = "https://find.api.example.com"
|
||||
settings.FIND_API_KEY = "find-api-key"
|
||||
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
|
||||
"Based on the following document contents:\n\n{search_results}\n\n"
|
||||
"Please answer the user's question: {user_prompt}"
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_refresh_access_token():
|
||||
"""Mock refresh_access_token to bypass token refresh in tests."""
|
||||
with mock.patch("utils.oidc.refresh_access_token") as mocked_refresh_access_token:
|
||||
session = SessionStore()
|
||||
session["oidc_access_token"] = "mocked-access-token"
|
||||
mocked_refresh_access_token.return_value = session
|
||||
yield mocked_refresh_access_token
|
||||
|
||||
|
||||
@pytest.fixture(name="sample_pdf_content")
|
||||
def fixture_sample_pdf_content():
|
||||
"""Create a dummy PDF content as BytesIO."""
|
||||
@@ -103,18 +81,10 @@ def fixture_sample_pdf_content():
|
||||
return BytesIO(pdf_data)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_document_api")
|
||||
def fixture_mock_document_api():
|
||||
@pytest.fixture(name="mock_albert_api")
|
||||
def fixture_mock_albert_api():
|
||||
"""Fixture to mock the Albert API endpoints."""
|
||||
# Mock collection creation
|
||||
|
||||
document_name = "sample.pdf"
|
||||
document_content = "This is the content of the PDF."
|
||||
prompt_tokens = 10
|
||||
completion_tokens = 20
|
||||
search_method = "semantic"
|
||||
search_score = 0.9
|
||||
|
||||
responses.post(
|
||||
"https://albert.api.etalab.gouv.fr/v1/collections",
|
||||
json={"id": "123", "name": "test-collection"},
|
||||
@@ -131,7 +101,7 @@ def fixture_mock_document_api():
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -149,42 +119,20 @@ def fixture_mock_document_api():
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"method": search_method,
|
||||
"method": "semantic",
|
||||
"chunk": {
|
||||
"id": 123,
|
||||
"content": document_content,
|
||||
"metadata": {"document_name": document_name},
|
||||
"content": "This is the content of the PDF.",
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
},
|
||||
"score": search_score,
|
||||
"score": 0.9,
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Mock document indexing (Find API)
|
||||
responses.post(
|
||||
"https://find.api.example.com/api/v1.0/documents/index/",
|
||||
json={"id": "456", "status": "indexed"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Mock document search (Find API)
|
||||
responses.post(
|
||||
"https://find.api.example.com/api/v1.0/documents/search/",
|
||||
json=[
|
||||
{
|
||||
"_source": {
|
||||
"title.fr": document_name,
|
||||
"content.fr": document_content,
|
||||
},
|
||||
"_score": search_score,
|
||||
}
|
||||
],
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_summarization_agent")
|
||||
def fixture_mock_summarization_agent():
|
||||
@@ -271,7 +219,7 @@ def fixture_mock_openai_stream():
|
||||
def test_post_conversation_with_document_upload(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_ai_agent_service,
|
||||
@@ -600,7 +548,7 @@ def test_post_conversation_with_document_upload_feature_disabled(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
|
||||
api_client,
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_ai_agent_service,
|
||||
|
||||
+39
-49
@@ -37,19 +37,11 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
autouse=True,
|
||||
params=[
|
||||
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
|
||||
],
|
||||
)
|
||||
def ai_settings(request, settings):
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.FIND_API_KEY = "find-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
return settings
|
||||
@@ -93,10 +85,6 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
json={"id": "document_id", "object": "document"},
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
"https://app-find/api/v1.0/documents/index/",
|
||||
status=200,
|
||||
)
|
||||
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
@@ -132,29 +120,28 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
)
|
||||
|
||||
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
|
||||
presigned_url = messages[0].parts[0].content[1].url
|
||||
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
|
||||
assert presigned_url.find("X-Amz-Signature=") != -1
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
UserPromptPart(
|
||||
content=[
|
||||
"What is in this document?",
|
||||
DocumentUrl(
|
||||
url=presigned_url, # presigned URL for this conversation
|
||||
media_type="application/pdf",
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
|
||||
],
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from attached "
|
||||
"documents. Do NOT use it to summarize; for summaries, call the summarize "
|
||||
"tool instead.\n\nWhen you receive a result from the summarization tool, "
|
||||
"you MUST return it directly to the user without any modification, "
|
||||
"paraphrasing, or additional summarization.The tool already produces "
|
||||
"optimized summaries that should be presented verbatim.You may translate "
|
||||
"the summary if required, but you MUST preserve all the information from "
|
||||
"the original summary.You may add a follow-up question after the summary "
|
||||
"if needed.\n\n"
|
||||
"[Internal context] User documents are attached to this conversation. "
|
||||
"Do not request re-upload of documents; consider them already available "
|
||||
"via the internal store."
|
||||
),
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
]
|
||||
@@ -198,9 +185,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
experimental_attachments=[
|
||||
Attachment(name="sample.pdf", contentType="application/pdf", url=document_url)
|
||||
],
|
||||
experimental_attachments=None, # We should fix this, but for now document appears in source
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
@@ -232,20 +217,29 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
{
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.",
|
||||
"Answer in english.\n"
|
||||
"\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages "
|
||||
"from attached documents. Do NOT use it to summarize; for "
|
||||
"summaries, call the summarize tool instead.\n"
|
||||
"\n"
|
||||
"When you receive a result from the summarization tool, you "
|
||||
"MUST return it directly to the user without any "
|
||||
"modification, paraphrasing, or additional summarization.The "
|
||||
"tool already produces optimized summaries that should be "
|
||||
"presented verbatim.You may translate the summary if "
|
||||
"required, but you MUST preserve all the information from "
|
||||
"the original summary.You may add a follow-up question after "
|
||||
"the summary if needed.\n"
|
||||
"\n"
|
||||
"[Internal context] User documents are attached to this "
|
||||
"conversation. Do not request re-upload of documents; "
|
||||
"consider them already available via the internal store.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
"What is in this document?",
|
||||
{
|
||||
"force_download": False,
|
||||
"identifier": "sample.pdf",
|
||||
"kind": "document-url",
|
||||
"media_type": "application/pdf",
|
||||
"url": document_url,
|
||||
"vendor_metadata": None,
|
||||
},
|
||||
],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": timestamp,
|
||||
@@ -807,10 +801,6 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
json={"id": "document_id", "object": "document"},
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
"https://app-find/api/v1.0/documents/index/",
|
||||
status=200,
|
||||
)
|
||||
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -11,6 +12,7 @@ from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
from chat.ai_sdk_types import (
|
||||
Attachment,
|
||||
TextUIPart,
|
||||
@@ -35,6 +37,7 @@ def ai_settings(settings):
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = None # disable auto title generation
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1573,3 +1576,307 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.clients.pydantic_ai.TitleGenerationAgent", wraps=TitleGenerationAgent)
|
||||
def test_post_conversation_triggers_automatic_title_generation_after_first_message(
|
||||
mock_title_agent, api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that posting the first user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
|
||||
The conversation is a new one. Posting the first message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
conversation = ChatConversationFactory()
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
conversation.title = "initial title"
|
||||
conversation.save()
|
||||
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
# Verify TitleGenerationAgent was called
|
||||
mock_title_agent.assert_called_once()
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_triggers_automatic_title_generation_at_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_regenerate_title_when_user_set(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that title is NOT regenerated if the user has manually set a title.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Simulate user having set a custom title
|
||||
history_conversation.title = "My Custom Title"
|
||||
history_conversation.title_set_by_user_at = timezone.now()
|
||||
history_conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream since title wasn't generated
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT changed
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "My Custom Title"
|
||||
assert history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_before_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that title is NOT generated before reaching the message threshold.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Create a conversation with only 1 user message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
conversation = ChatConversationFactory(title="initial title")
|
||||
|
||||
conversation.messages = [
|
||||
UIMessage(
|
||||
id="prev-user-msg-1",
|
||||
createdAt=history_timestamp,
|
||||
content="Hello!",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello!")],
|
||||
),
|
||||
UIMessage(
|
||||
id="prev-assistant-msg-1",
|
||||
createdAt=history_timestamp.replace(minute=31),
|
||||
content="Hi there! How can I help you?",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hi there! How can I help you?")],
|
||||
),
|
||||
]
|
||||
conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "second-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "What's machine learning?", "type": "text"}],
|
||||
"content": "What's machine learning?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream (only 2 user messages)
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was not updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_after_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message does not trigger automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should not trigger title generation.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is not in the stream
|
||||
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT updated (past threshold)
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
# title not updated
|
||||
assert history_conversation.title == "initial title"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
@@ -28,6 +28,7 @@ def test_create_conversation(api_client):
|
||||
conversation = ChatConversation.objects.get(id=response.data["id"])
|
||||
assert conversation.owner == user
|
||||
assert conversation.title == "New Conversation"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_create_conversation_other_owner(api_client):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
@@ -26,6 +27,34 @@ def test_update_conversation(api_client):
|
||||
# Verify in database
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Updated Title"
|
||||
assert conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_limit_title_length(api_client):
|
||||
"""Test that updating a conversation with a title exceeding 100 characters fails validation."""
|
||||
chat_conversation = ChatConversationFactory(title="Initial title")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
# Create a 101-character title to exceed the 100-character maximum limit
|
||||
new_title = "X" * 101
|
||||
data = {"title": new_title}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
assert response.data == {
|
||||
"title": [
|
||||
ErrorDetail(
|
||||
string="Ensure this field has no more than 100 characters.", code="max_length"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
# Verify in database (title should remain unchanged)
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_anonymous(api_client):
|
||||
|
||||
@@ -26,7 +26,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
|
||||
|
||||
document_store = document_store_backend(ctx.deps.conversation.collection_id)
|
||||
|
||||
rag_results = document_store.search(query, session=ctx.deps.session)
|
||||
rag_results = document_store.search(query)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -127,7 +127,7 @@ async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[st
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
|
||||
async def _fetch_and_store_async(url: str, document_store) -> None:
|
||||
"""Fetch, extract and store text content from the URL in the document store."""
|
||||
|
||||
try:
|
||||
@@ -136,7 +136,7 @@ async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
|
||||
logger.debug("Fetched document: %s", document)
|
||||
|
||||
if document:
|
||||
await document_store.astore_document(url, document, **kwargs)
|
||||
await document_store.astore_document(url, document)
|
||||
except DocumentFetchError as e:
|
||||
logger.warning("Failed to fetch and store %s: %s", url, e)
|
||||
# Continue with other documents
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
This module contains the EventEncoder class.
|
||||
"""
|
||||
|
||||
from .encoder import EventEncoder
|
||||
from .encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder, EventEncoderVersion
|
||||
|
||||
__all__ = ["EventEncoder"]
|
||||
__all__ = ["EventEncoder", "CURRENT_EVENT_ENCODER_VERSION", "EventEncoderVersion"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Event Encoder for Vercel AI SDK"""
|
||||
|
||||
from typing import Literal, Union
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
from ..core.events_v4 import BaseEvent as V4BaseEvent
|
||||
from ..core.events_v4 import TextPart
|
||||
@@ -8,16 +9,26 @@ from ..core.events_v5 import BaseEvent as V5BaseEvent
|
||||
from ..core.events_v5 import TextDeltaEvent
|
||||
|
||||
|
||||
class EventEncoderVersion(str, Enum):
|
||||
"""Enumeration of supported event encoder versions."""
|
||||
|
||||
V4 = "v4"
|
||||
V5 = "v5"
|
||||
|
||||
|
||||
CURRENT_EVENT_ENCODER_VERSION = EventEncoderVersion.V4 # used encoder version
|
||||
|
||||
|
||||
class EventEncoder:
|
||||
"""
|
||||
Encodes events for the Vercel AI SDK based on the specified version.
|
||||
"""
|
||||
|
||||
def __init__(self, version: Literal["v4", "v5"] = None):
|
||||
def __init__(self, version: EventEncoderVersion):
|
||||
"""
|
||||
Initializes the EventEncoder with the specified version.
|
||||
"""
|
||||
if version not in ["v4", "v5"]:
|
||||
if version not in [EventEncoderVersion.V4, EventEncoderVersion.V5]:
|
||||
raise ValueError("Unsupported version. Supported versions are 'v4' and 'v5'.")
|
||||
|
||||
self.version = version
|
||||
@@ -28,7 +39,7 @@ class EventEncoder:
|
||||
"""
|
||||
return "text/event-stream"
|
||||
|
||||
def encode(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -38,15 +49,15 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == "v4" and isinstance(event, V4BaseEvent):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, V4BaseEvent):
|
||||
return self._encode_v4_streaming(event)
|
||||
|
||||
if self.version == "v5" and isinstance(event, V5BaseEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, V5BaseEvent):
|
||||
return self._encode_sse(event)
|
||||
|
||||
return None
|
||||
|
||||
def encode_text(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -56,10 +67,10 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == "v4" and isinstance(event, TextPart):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
|
||||
return event.text
|
||||
|
||||
if self.version == "v5" and isinstance(event, TextDeltaEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, TextDeltaEvent):
|
||||
return event.delta
|
||||
|
||||
return None
|
||||
@@ -70,7 +81,7 @@ class EventEncoder:
|
||||
"""
|
||||
return f"{event.type}:{event.model_dump_json(by_alias=True, exclude={'type'})}\n"
|
||||
|
||||
def _encode_sse(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str:
|
||||
def _encode_sse(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str:
|
||||
"""
|
||||
Encodes an event into an SSE string.
|
||||
"""
|
||||
|
||||
@@ -7,13 +7,11 @@ from uuid import uuid4
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
import langfuse
|
||||
import magic
|
||||
import posthog
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
@@ -28,6 +26,7 @@ from core.filters import remove_accents
|
||||
from activation_codes.permissions import IsActivatedUser
|
||||
from chat import models, serializers
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.keepalive import stream_with_keepalive_async, stream_with_keepalive_sync
|
||||
from chat.serializers import ChatConversationRequestSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -124,7 +123,6 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
self.permission_classes = []
|
||||
return super().get_permissions()
|
||||
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
@decorators.action(
|
||||
methods=["post"],
|
||||
detail=True,
|
||||
@@ -176,7 +174,6 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
ai_service = AIAgentService(
|
||||
conversation=conversation,
|
||||
user=self.request.user,
|
||||
session=request.session,
|
||||
model_hrid=model_hrid,
|
||||
language=(
|
||||
self.request.user.language
|
||||
@@ -192,29 +189,28 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
if is_async_mode:
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
base_stream = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
base_stream = ai_service.stream_text_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
streaming_content = stream_with_keepalive_async(base_stream)
|
||||
else:
|
||||
logger.debug("Using SYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = ai_service.stream_text(messages, force_web_search=force_web_search)
|
||||
|
||||
streaming_content = stream_with_keepalive_sync(base_stream)
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content,
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
|
||||
"X-Accel-Buffering": "no", # Prevent nginx buffering
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -375,7 +371,6 @@ class ChatConversationAttachmentViewSet(
|
||||
owner=self.request.user,
|
||||
).exists():
|
||||
raise Http404
|
||||
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
extension = file_name.rpartition(".")[-1] if "." in file_name else None
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def no_http_requests(monkeypatch):
|
||||
Credits: https://blog.jerrycodes.com/no-http-requests/
|
||||
"""
|
||||
|
||||
allowed_hosts = {"localhost", "127.0.0.1", "minio", "minio:9000"}
|
||||
allowed_hosts = {"localhost", "minio", "minio:9000"}
|
||||
original_urlopen = HTTPConnectionPool.urlopen
|
||||
|
||||
def urlopen_mock(self, method, url, *args, **kwargs):
|
||||
|
||||
@@ -841,35 +841,6 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Find
|
||||
FIND_API_KEY = values.Value(
|
||||
None,
|
||||
environ_name="FIND_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FIND_API_URL = values.Value(
|
||||
"https://app-find/api",
|
||||
environ_name="FIND_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FIND_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=30, # seconds
|
||||
environ_name="FIND_API_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Docling
|
||||
DOCLING_SERVE_URL = values.Value(
|
||||
"http://docling-serve:5001",
|
||||
environ_name="DOCLING_SERVE_URL",
|
||||
environ_prefix = None,
|
||||
)
|
||||
DOCLING_SERVE_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=60, # seconds
|
||||
environ_name="DOCLING_SERVE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
@@ -940,7 +911,9 @@ USER QUESTION:
|
||||
LANGFUSE_MEDIA_UPLOAD_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = values.PositiveIntegerValue(
|
||||
default=None, environ_name="AUTO_TITLE_AFTER_USER_MESSAGES", environ_prefix=None
|
||||
)
|
||||
# WARNING: Testing purpose only. Do not use in production.
|
||||
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
|
||||
default=False,
|
||||
@@ -948,6 +921,12 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Default keepalive interval: 55s (safely below typical 60s proxy timeouts)
|
||||
# Prevents connection drops during long stream pauses while providing 5s safety margin.
|
||||
KEEPALIVE_INTERVAL = values.PositiveIntegerValue(
|
||||
default=55, environ_name="KEEPALIVE_INTERVAL", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -1160,6 +1139,8 @@ class Test(Base):
|
||||
|
||||
POSTHOG_KEY = None
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = None
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
|
||||
@@ -46,6 +46,7 @@ dependencies = [
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.10.0",
|
||||
"lxml==5.4.0",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Utility functions for OIDC token management."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
def refresh_access_token(session):
|
||||
"""Refresh the OIDC access token using the refresh token."""
|
||||
refresh_token = get_oidc_refresh_token(session)
|
||||
if not refresh_token:
|
||||
raise AuthenticationFailed({"error": "Refresh token is missing from session"})
|
||||
|
||||
response = requests.post(
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": settings.OIDC_RP_CLIENT_ID,
|
||||
"client_secret": settings.OIDC_RP_CLIENT_SECRET,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_info = response.json()
|
||||
|
||||
store_tokens(
|
||||
session,
|
||||
access_token=token_info.get("access_token"),
|
||||
id_token=None,
|
||||
refresh_token=token_info.get("refresh_token"),
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def with_fresh_access_token(func):
|
||||
"""
|
||||
Decorator to handle OIDC token refresh and extraction.
|
||||
Expects 'session' in kwargs and update it with the fresh token.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
session = kwargs.pop("session", None)
|
||||
if session is None:
|
||||
raise AuthenticationFailed({"error": "Session is required but not provided"})
|
||||
refreshed_session = refresh_access_token(session)
|
||||
return func(*args, session=refreshed_session, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -9,6 +9,7 @@
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
"lint": "tsc --noEmit && next lint",
|
||||
"lint:fix": "tsc --noEmit && next lint --fix",
|
||||
"prettier": "prettier --write .",
|
||||
"stylelint": "stylelint \"**/*.css\"",
|
||||
"test": "jest",
|
||||
|
||||
@@ -1,16 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import styled, { RuleSet } from 'styled-components';
|
||||
|
||||
export interface LinkProps {
|
||||
interface StyledLinkProps {
|
||||
$css?: string | RuleSet<object>;
|
||||
}
|
||||
|
||||
export const StyledLink = styled(Link)<LinkProps>`
|
||||
const Anchor = styled.a<StyledLinkProps>`
|
||||
text-decoration: none;
|
||||
color: #ffffff;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
|
||||
cursor: pointer;
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css}` : $css)}
|
||||
`;
|
||||
|
||||
interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
$css?: string | RuleSet<object>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link that avoids re-renders from Next.js router context.
|
||||
*
|
||||
* Use instead of Next.js `Link` in large lists (sidebars, tables) where
|
||||
* router-triggered re-renders cause performance issues.
|
||||
*
|
||||
* Warning: No automatic prefetching.
|
||||
*
|
||||
*/
|
||||
export const StyledLink = memo(function StyledLink({
|
||||
href,
|
||||
onClick,
|
||||
...props
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
|
||||
// avoid rerenders
|
||||
useEffect(() => {
|
||||
routerRef.current = router;
|
||||
}, [router]);
|
||||
|
||||
// Memoized click handler to maintain stable reference across re-renders.
|
||||
// Necessary for memo() to work correctly
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Allow default browser behavior for modifier keys (new tab, etc.)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
routerRef.current.push(href);
|
||||
},
|
||||
[href, onClick],
|
||||
);
|
||||
|
||||
return <Anchor href={href} onClick={handleClick} {...props} />;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { StyledLink } from '../Link';
|
||||
|
||||
const mockPush = jest.fn();
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('StyledLink', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a link with the correct href', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/test-path');
|
||||
});
|
||||
|
||||
it('should navigate using router.push on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
await user.click(link);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path');
|
||||
});
|
||||
|
||||
it('should call onClick prop when clicked', async () => {
|
||||
const handleClick = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StyledLink href="/test-path" onClick={handleClick}>
|
||||
Test Link
|
||||
</StyledLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
await user.click(link);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when meta key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { metaKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when ctrl key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { ctrlKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when shift key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { shiftKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow default behavior when alt key is pressed', () => {
|
||||
render(<StyledLink href="/test-path">Test Link</StyledLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
fireEvent.click(link, { altKey: true });
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass additional props to the anchor element', () => {
|
||||
render(
|
||||
<StyledLink
|
||||
href="/test-path"
|
||||
data-testid="custom-link"
|
||||
className="custom"
|
||||
>
|
||||
Test Link
|
||||
</StyledLink>,
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('custom-link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UseChatOptions, useChat as useAiSdkChat } from '@ai-sdk/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { KEY_LIST_CONVERSATION } from '@/features/chat/api/useConversations';
|
||||
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
|
||||
|
||||
const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -36,10 +39,46 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return fetchAPI(url, init);
|
||||
};
|
||||
|
||||
interface ConversationMetadataEvent {
|
||||
type: 'conversation_metadata';
|
||||
conversationId: string;
|
||||
title: string;
|
||||
}
|
||||
// Type guard to check if an item is a ConversationMetadataEvent
|
||||
function isConversationMetadataEvent(
|
||||
item: unknown,
|
||||
): item is ConversationMetadataEvent {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'type' in item &&
|
||||
item.type === 'conversation_metadata' &&
|
||||
'conversationId' in item &&
|
||||
typeof item.conversationId === 'string' &&
|
||||
'title' in item &&
|
||||
typeof item.title === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
|
||||
return useAiSdkChat({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const result = useAiSdkChat({
|
||||
...options,
|
||||
maxSteps: 3,
|
||||
fetch: fetchAPIAdapter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
for (const item of result.data) {
|
||||
if (isConversationMetadataEvent(item)) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_CONVERSATION],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [result.data, queryClient]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_CONVERSATION } from './useConversations';
|
||||
|
||||
interface RenameConversationProps {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const renameConversation = async ({
|
||||
conversationId,
|
||||
title,
|
||||
}: RenameConversationProps): Promise<void> => {
|
||||
const response = await fetchAPI(`chats/${conversationId}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to rename the conversation',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseRenameConversationOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
RenameConversationProps
|
||||
>;
|
||||
|
||||
export const useRenameConversation = (
|
||||
options?: UseRenameConversationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, RenameConversationProps>({
|
||||
mutationFn: renameConversation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_CONVERSATION],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Message,
|
||||
ReasoningUIPart,
|
||||
SourceUIPart,
|
||||
ToolInvocationUIPart,
|
||||
} from '@ai-sdk/ui-utils';
|
||||
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -816,30 +811,12 @@ export const Chat = ({
|
||||
</Box>
|
||||
)}
|
||||
{message.parts
|
||||
?.filter(
|
||||
(part) =>
|
||||
part.type === 'reasoning' ||
|
||||
part.type === 'tool-invocation',
|
||||
)
|
||||
?.filter((part) => part.type === 'tool-invocation')
|
||||
.map(
|
||||
(
|
||||
part: ReasoningUIPart | ToolInvocationUIPart,
|
||||
partIndex: number,
|
||||
) =>
|
||||
part.type === 'reasoning' ? (
|
||||
<Box
|
||||
key={`reasoning-${partIndex}`}
|
||||
$background="var(--c--theme--colors--greyscale-100)"
|
||||
$color="var(--c--theme--colors--greyscale-500)"
|
||||
$padding={{ all: 'sm' }}
|
||||
$radius="md"
|
||||
$css="font-size: 0.9em;"
|
||||
>
|
||||
{part.reasoning}
|
||||
</Box>
|
||||
) : part.type === 'tool-invocation' &&
|
||||
isCurrentlyStreaming &&
|
||||
isLastAssistantMessageInConversation ? (
|
||||
(part: ToolInvocationUIPart, partIndex: number) =>
|
||||
part.type === 'tool-invocation' &&
|
||||
isCurrentlyStreaming &&
|
||||
isLastAssistantMessageInConversation ? (
|
||||
<ToolInvocationItem
|
||||
key={`tool-invocation-${partIndex}`}
|
||||
toolInvocation={part.toolInvocation}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useToast } from '@/components/ToastProvider';
|
||||
import { FeatureFlagState, useConfig } from '@/core';
|
||||
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
|
||||
import { InputChatActions } from '@/features/chat/components/InputChatAction';
|
||||
import { SuggestionCarousel } from '@/features/chat/components/SuggestionCarousel';
|
||||
import { WelcomeMessage } from '@/features/chat/components/WelcomeMessage';
|
||||
import { useFileDragDrop } from '@/features/chat/hooks/useFileDragDrop';
|
||||
import { useFileUrls } from '@/features/chat/hooks/useFileUrls';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import FilesIcon from '../assets/files.svg';
|
||||
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { ScrollDown } from './ScrollDown';
|
||||
import { SendButton } from './SendButton';
|
||||
|
||||
interface InputChatProps {
|
||||
messagesLength: number;
|
||||
@@ -34,6 +42,76 @@ interface InputChatProps {
|
||||
isUploadingFiles?: boolean;
|
||||
}
|
||||
|
||||
const STYLES = {
|
||||
form: { width: '100%' },
|
||||
formPadding: { bottom: 'base' },
|
||||
formPaddingMobile: { bottom: '' },
|
||||
attachmentMargin: { horizontal: '0', bottom: 'xs', top: 'xs' },
|
||||
attachmentPadding: { horizontal: 'base' },
|
||||
horizontalPadding: { horizontal: 'base' },
|
||||
} as const;
|
||||
|
||||
const CONTAINER_CSS = `
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 750px;
|
||||
`;
|
||||
|
||||
const INPUT_BOX_CSS = `
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
position: relative;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const FILE_DROP_CSS = `
|
||||
top: -1px; left: -1px;
|
||||
border-radius: 12px;
|
||||
z-index: 1001;
|
||||
background-color: #EDF0FF;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: 2px solid #90A7FF;
|
||||
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
|
||||
`;
|
||||
const DRAG_FADE_CSS = `
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
animation: fadeIn 0.3s;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
pointer-events: all;
|
||||
`;
|
||||
|
||||
const TEXTAREA_STYLE: React.CSSProperties = {
|
||||
padding: '1rem 1.5rem',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '64px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--c--theme--colors--greyscale-800)',
|
||||
lineHeight: '1.5',
|
||||
};
|
||||
const SCROLL_DOWN_WRAPPER_CSS = `
|
||||
position: relative;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
max-width: 750px;
|
||||
`;
|
||||
export const InputChat = ({
|
||||
messagesLength,
|
||||
input,
|
||||
@@ -55,14 +133,13 @@ export const InputChat = ({
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
const { isDesktop, isMobile } = useResponsiveStore();
|
||||
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
|
||||
|
||||
const { data: conf } = useConfig();
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isFileAccepted = useCallback(
|
||||
(file: File): boolean => {
|
||||
@@ -87,13 +164,6 @@ export const InputChat = ({
|
||||
[conf?.chat_upload_accept],
|
||||
);
|
||||
|
||||
const suggestions = [
|
||||
t('Ask a question'),
|
||||
t('Turn this list into bullet points'),
|
||||
t('Write a short product description'),
|
||||
t('Find recent news about...'),
|
||||
];
|
||||
|
||||
const showToastError = useCallback(() => {
|
||||
showToast(
|
||||
'error',
|
||||
@@ -131,32 +201,6 @@ export const InputChat = ({
|
||||
setFileUploadEnabled(isFeatureEnabled('document-upload'));
|
||||
}, [conf, isFeatureFlagActivated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesLength === 0) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSuggestionIndex((prev) => {
|
||||
if (prev === suggestions.length - 1) {
|
||||
return suggestions.length;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [messagesLength, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSuggestionIndex === suggestions.length) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsResetting(true);
|
||||
setCurrentSuggestionIndex(0);
|
||||
setTimeout(() => setIsResetting(false), 50);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [currentSuggestionIndex, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && messagesLength === 0 && status === 'ready') {
|
||||
textareaRef.current.focus();
|
||||
@@ -169,73 +213,118 @@ export const InputChat = ({
|
||||
}
|
||||
}, [status, input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
const handleFilesAccepted = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setIsDragActive(true);
|
||||
}
|
||||
};
|
||||
// Keep existing files
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f) => dt.items.add(f));
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only hide when leaving the window completely
|
||||
if (!e.relatedTarget) {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check for rejected files during drag over (does not work on Safari)
|
||||
if (e.dataTransfer?.items) {
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
items.some((item) => {
|
||||
if (item.kind === 'file') {
|
||||
// Check file type
|
||||
const type = item.type;
|
||||
const dummyFile = new File([], '', { type });
|
||||
return !isFileAccepted(dummyFile);
|
||||
// Add new files (avoiding duplicates)
|
||||
acceptedFiles.forEach((f) => {
|
||||
const isDuplicate = Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
);
|
||||
if (!isDuplicate) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return dt.files;
|
||||
});
|
||||
},
|
||||
[setFiles],
|
||||
);
|
||||
|
||||
const { isDragActive } = useFileDragDrop({
|
||||
enabled: fileUploadEnabled,
|
||||
isFileAccepted,
|
||||
onFilesAccepted: handleFilesAccepted,
|
||||
onFilesRejected: () => showToastError(),
|
||||
});
|
||||
|
||||
const isInputDisabled = status !== 'ready' || isUploadingFiles;
|
||||
|
||||
const containerCss = useMemo(
|
||||
() => `
|
||||
${CONTAINER_CSS}
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
`,
|
||||
[isDesktop],
|
||||
);
|
||||
|
||||
const textareaStyle = useMemo(
|
||||
() => ({
|
||||
...TEXTAREA_STYLE,
|
||||
opacity: status === 'error' ? '0.5' : '1',
|
||||
}),
|
||||
[status],
|
||||
);
|
||||
|
||||
const formPadding = isDesktop ? STYLES.formPadding : STYLES.formPaddingMobile;
|
||||
|
||||
// handlers
|
||||
const handleTextareaChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
},
|
||||
[handleInputChange],
|
||||
);
|
||||
|
||||
const handleTextareaKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
const handleAttachClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
if (!fileUploadEnabled) {
|
||||
const handleWebSearchToggle = useCallback(() => {
|
||||
onToggleWebSearch?.();
|
||||
textareaRef.current?.focus();
|
||||
}, [onToggleWebSearch]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
|
||||
Array.from(droppedFiles).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (acceptedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
@@ -256,67 +345,53 @@ export const InputChat = ({
|
||||
return dt.files;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
window.addEventListener('dragleave', handleDragLeave);
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
e.target.value = '';
|
||||
},
|
||||
[isFileAccepted, showToastError, setFiles],
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [
|
||||
fileUploadEnabled,
|
||||
setFiles,
|
||||
showToastError,
|
||||
conf?.chat_upload_accept,
|
||||
isFileAccepted,
|
||||
]);
|
||||
const handleAttachmentRemove = useCallback(
|
||||
(index: number) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInputDisabled = status !== 'ready' || isUploadingFiles;
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
},
|
||||
[files, setFiles],
|
||||
);
|
||||
|
||||
const fileUrlMap = useFileUrls(files);
|
||||
|
||||
const attachments = useMemo(() => {
|
||||
if (!files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(files).map((file) => {
|
||||
const key = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
return {
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: fileUrlMap.get(key) || '',
|
||||
};
|
||||
});
|
||||
}, [files, fileUrlMap]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
$position="fixed"
|
||||
$css={`
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
animation: fadeIn 0.3s;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
pointer-events: all;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
$css={`
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
{isDragActive && <Box $position="fixed" $css={DRAG_FADE_CSS} />}
|
||||
<Box $css={containerCss}>
|
||||
{/* Bouton de scroll vers le bas */}
|
||||
{messagesLength > 1 && containerRef && onScrollToBottom && (
|
||||
<Box
|
||||
$css={`
|
||||
position: relative;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
<Box $css={SCROLL_DOWN_WRAPPER_CSS}>
|
||||
<ScrollDown
|
||||
onClick={onScrollToBottom}
|
||||
containerRef={containerRef}
|
||||
@@ -324,48 +399,16 @@ export const InputChat = ({
|
||||
</Box>
|
||||
)}
|
||||
{/* Message de bienvenue */}
|
||||
{messagesLength === 0 && (
|
||||
<Box
|
||||
$padding={{ all: 'base', bottom: 'md' }}
|
||||
$align="center"
|
||||
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
|
||||
>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{messagesLength === 0 && <WelcomeMessage />}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(fileUploadEnabled);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
// File handling is now done by global handler
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
|
||||
<form onSubmit={handleSubmit} style={STYLES.form}>
|
||||
<Box $padding={formPadding}>
|
||||
<Box
|
||||
$flex={1}
|
||||
$radius="12px"
|
||||
$position="relative"
|
||||
$background="white"
|
||||
$css={`
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
position: relative;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
`}
|
||||
$css={INPUT_BOX_CSS}
|
||||
>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
@@ -374,16 +417,7 @@ export const InputChat = ({
|
||||
$direction="row"
|
||||
$justify="center"
|
||||
$gap="1rem"
|
||||
$css={`
|
||||
top: -1px; left: -1px;
|
||||
border-radius: 12px;
|
||||
z-index: 1001;
|
||||
background-color: #EDF0FF;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: 2px solid #90A7FF;
|
||||
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
|
||||
`}
|
||||
$css={FILE_DROP_CSS}
|
||||
>
|
||||
<FilesIcon />
|
||||
<Box>
|
||||
@@ -401,88 +435,14 @@ export const InputChat = ({
|
||||
aria-label={t('Enter your message or a question')}
|
||||
value={input ?? ''}
|
||||
name="inputchat-textarea"
|
||||
onChange={(e) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.focus();
|
||||
}}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
disabled={isInputDisabled}
|
||||
rows={1}
|
||||
style={{
|
||||
padding: '1rem 1.5rem',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
opacity: status === 'error' ? '0.5' : '1',
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '64px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--c--theme--colors--greyscale-800)',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
|
||||
{!input && (
|
||||
<Box
|
||||
$css={`
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$css={`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: ${(suggestions.length + 1) * 100}%;
|
||||
transform: translateY(-${currentSuggestionIndex * (100 / (suggestions.length + 1))}%);
|
||||
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
|
||||
`}
|
||||
>
|
||||
{[...suggestions, suggestions[0]].map(
|
||||
(suggestion, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
$css={`
|
||||
height: calc(100% / ${suggestions.length + 1});
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
`}
|
||||
>
|
||||
{suggestion}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!input && <SuggestionCarousel messagesLength={messagesLength} />}
|
||||
|
||||
<input
|
||||
accept={conf?.chat_upload_accept}
|
||||
@@ -490,232 +450,37 @@ export const InputChat = ({
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedFiles: File[] = [];
|
||||
const rejectedFiles: string[] = [];
|
||||
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
rejectedFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
showToastError();
|
||||
}
|
||||
|
||||
if (acceptedFiles.length === 0) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f: File) => dt.items.add(f));
|
||||
}
|
||||
acceptedFiles.forEach((f: File) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
return dt.files;
|
||||
});
|
||||
|
||||
e.target.value = '';
|
||||
}}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/*Aperçu des fichiers*/}
|
||||
{files && files.length > 0 && (
|
||||
<Box
|
||||
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={STYLES.attachmentMargin}
|
||||
$padding={STYLES.attachmentPadding}
|
||||
>
|
||||
<AttachmentList
|
||||
attachments={Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
}))}
|
||||
onRemove={(index) => {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
}}
|
||||
attachments={attachments}
|
||||
onRemove={handleAttachmentRemove}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="sm"
|
||||
$padding={{ bottom: 'base' }}
|
||||
$align="space-between"
|
||||
$css={`
|
||||
opacity: ${status === 'error' ? '0.5' : '1'};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$flex="1"
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!fileUploadEnabled || isUploadingFiles}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label={t('Add attach file')}
|
||||
className="c__button--neutral attach-file-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="attach_file"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size={`${isMobile ? '24px' : '16px'}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text $theme="greyscale" $variation="550" $weight="500">
|
||||
{t('Attach file')}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{onToggleWebSearch && (
|
||||
<Box
|
||||
$margin={{ left: '4px' }}
|
||||
$css={`
|
||||
${
|
||||
isMobile
|
||||
? `
|
||||
.research-web-button {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
forceWebSearch
|
||||
? `
|
||||
.research-web-button {
|
||||
background-color: var(--c--theme--colors--primary-100) !important;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!webSearchEnabled || isUploadingFiles}
|
||||
onClick={() => {
|
||||
onToggleWebSearch();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t('Research on the web')}
|
||||
className="c__button--neutral research-web-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="language"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$css={`
|
||||
color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text
|
||||
$theme={forceWebSearch ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{t('Research on the web')}
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && forceWebSearch && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="space-between"
|
||||
$gap="xs"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="500"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
{t('Web')}
|
||||
</Text>
|
||||
<Icon
|
||||
iconName="close"
|
||||
$variation="text"
|
||||
$theme="primary"
|
||||
$size="md"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding-left: 4px;
|
||||
`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Box $padding={{ horizontal: 'xs' }}>
|
||||
{onModelSelect && (
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel || null}
|
||||
onModelSelect={onModelSelect}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<SendButton
|
||||
status={status}
|
||||
disabled={!input || !input.trim() || isUploadingFiles}
|
||||
onClick={onStop}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<InputChatActions
|
||||
fileUploadEnabled={fileUploadEnabled}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
isUploadingFiles={isUploadingFiles}
|
||||
isMobile={isMobile}
|
||||
forceWebSearch={forceWebSearch}
|
||||
onAttachClick={handleAttachClick}
|
||||
onWebSearchToggle={
|
||||
onToggleWebSearch ? handleWebSearchToggle : undefined
|
||||
}
|
||||
onModelSelect={onModelSelect}
|
||||
selectedModel={selectedModel || null}
|
||||
status={status}
|
||||
inputHasContent={Boolean(input?.trim())}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
|
||||
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { SendButton } from './SendButton';
|
||||
interface InputChatActionsProps {
|
||||
/** Whether file upload feature is enabled */
|
||||
fileUploadEnabled: boolean;
|
||||
/** Whether web search feature is enabled */
|
||||
webSearchEnabled: boolean;
|
||||
/** Whether files are currently being uploaded */
|
||||
isUploadingFiles: boolean;
|
||||
/** Whether the device is mobile */
|
||||
isMobile: boolean;
|
||||
/** Whether web search is forced/active */
|
||||
forceWebSearch: boolean;
|
||||
/** Handler for attach button click */
|
||||
onAttachClick: () => void;
|
||||
/** Handler for web search toggle - if undefined, button is hidden */
|
||||
onWebSearchToggle?: () => void;
|
||||
/** Handler for model selection - if undefined, selector is hidden */
|
||||
onModelSelect?: (model: LLMModel) => void;
|
||||
/** Currently selected model */
|
||||
selectedModel: LLMModel | null;
|
||||
/** Current chat status */
|
||||
status: string | null;
|
||||
/** Whether input has content (for send button) */
|
||||
inputHasContent: boolean;
|
||||
/** Handler for stop button */
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
const STYLES = {
|
||||
actionsGap: { bottom: 'base' },
|
||||
horizontalPadding: { horizontal: 'base' },
|
||||
horizontalPaddingXs: { horizontal: 'xs' },
|
||||
webSearchMargin: { left: '4px' },
|
||||
} as const;
|
||||
|
||||
const MOBILE_WEB_BUTTON_CSS = `
|
||||
.research-web-button {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const ACTIVE_WEB_BUTTON_CSS = `
|
||||
.research-web-button {
|
||||
background-color: var(--c--theme--colors--primary-100) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const MOBILE_TEXT_WRAPPER_CSS = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const MOBILE_TEXT_CSS = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const CLOSE_ICON_CSS = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Action buttons for the chat input.
|
||||
* Includes: Attach file, Web search toggle, Model selector, Send button.
|
||||
*
|
||||
* Memoized to prevent re-renders when parent updates but props haven't changed.
|
||||
*/
|
||||
export const InputChatActions = memo(
|
||||
({
|
||||
fileUploadEnabled,
|
||||
webSearchEnabled,
|
||||
isUploadingFiles,
|
||||
isMobile,
|
||||
forceWebSearch,
|
||||
onAttachClick,
|
||||
onWebSearchToggle,
|
||||
onModelSelect,
|
||||
selectedModel,
|
||||
status,
|
||||
inputHasContent,
|
||||
onStop,
|
||||
}: InputChatActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Memoized dynamic styles
|
||||
const actionsOpacityCss = useMemo(
|
||||
() => `opacity: ${status === 'error' ? '0.5' : '1'};`,
|
||||
[status],
|
||||
);
|
||||
|
||||
const webSearchWrapperCss = useMemo(() => {
|
||||
let css = '';
|
||||
if (isMobile) {
|
||||
css += MOBILE_WEB_BUTTON_CSS;
|
||||
}
|
||||
if (forceWebSearch) {
|
||||
css += ACTIVE_WEB_BUTTON_CSS;
|
||||
}
|
||||
return css;
|
||||
}, [isMobile, forceWebSearch]);
|
||||
|
||||
const webSearchIconCss = useMemo(
|
||||
() =>
|
||||
`color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}`,
|
||||
[forceWebSearch],
|
||||
);
|
||||
|
||||
const attachIconSize = isMobile ? '24px' : '16px';
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="sm"
|
||||
$padding={STYLES.actionsGap}
|
||||
$align="space-between"
|
||||
$css={actionsOpacityCss}
|
||||
>
|
||||
{/* Left side: Attach + Web Search */}
|
||||
<Box
|
||||
$flex="1"
|
||||
$direction="row"
|
||||
$padding={STYLES.horizontalPadding}
|
||||
$gap="xs"
|
||||
>
|
||||
{/* Attach file button */}
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!fileUploadEnabled || isUploadingFiles}
|
||||
onClick={onAttachClick}
|
||||
aria-label={t('Add attach file')}
|
||||
className="c__button--neutral attach-file-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="attach_file"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size={attachIconSize}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text $theme="greyscale" $variation="550" $weight="500">
|
||||
{t('Attach file')}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Web search toggle button */}
|
||||
{onWebSearchToggle && (
|
||||
<Box $margin={STYLES.webSearchMargin} $css={webSearchWrapperCss}>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!webSearchEnabled || isUploadingFiles}
|
||||
onClick={onWebSearchToggle}
|
||||
aria-label={t('Research on the web')}
|
||||
className="c__button--neutral research-web-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="language"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$css={webSearchIconCss}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text
|
||||
$theme={forceWebSearch ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{t('Research on the web')}
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && forceWebSearch && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="space-between"
|
||||
$gap="xs"
|
||||
$css={MOBILE_TEXT_WRAPPER_CSS}
|
||||
>
|
||||
<Text $theme="primary" $weight="500" $css={MOBILE_TEXT_CSS}>
|
||||
{t('Web')}
|
||||
</Text>
|
||||
<Icon
|
||||
iconName="close"
|
||||
$variation="text"
|
||||
$theme="primary"
|
||||
$size="md"
|
||||
$css={CLOSE_ICON_CSS}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right side: Model selector + Send */}
|
||||
<Box $direction="row" $padding={STYLES.horizontalPadding} $gap="xs">
|
||||
{onModelSelect && (
|
||||
<Box $padding={STYLES.horizontalPaddingXs}>
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={onModelSelect}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SendButton
|
||||
status={status}
|
||||
disabled={!inputHasContent || isUploadingFiles}
|
||||
onClick={onStop}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InputChatActions.displayName = 'InputChatActions';
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
import { Box } from '@/components';
|
||||
import { SourceItem } from '@/features/chat/components/SourceItem';
|
||||
|
||||
interface SourceMetadata {
|
||||
export interface SourceMetadata {
|
||||
title: string | null;
|
||||
favicon: string | null;
|
||||
loading: boolean;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
const SUGGESTION_KEYS = [
|
||||
'Ask a question',
|
||||
'Turn this list into bullet points',
|
||||
'Write a short product description',
|
||||
'Find recent news about...',
|
||||
] as const;
|
||||
const SUGGESTIONS_COUNT = SUGGESTION_KEYS.length;
|
||||
|
||||
const WRAPPER_CSS = `position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;`;
|
||||
|
||||
const ITEM_CSS = `
|
||||
height: calc(100% / ${SUGGESTIONS_COUNT + 1});
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
interface SuggestionCarouselProps {
|
||||
messagesLength: number;
|
||||
}
|
||||
|
||||
export const SuggestionCarousel = ({
|
||||
messagesLength,
|
||||
}: SuggestionCarouselProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTION_KEYS.map((key) => t(key)), [t]);
|
||||
const carouselSuggestions = useMemo(
|
||||
() => [...suggestions, suggestions[0]],
|
||||
[suggestions],
|
||||
);
|
||||
|
||||
const carrouselCss = useMemo(
|
||||
() => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: ${(SUGGESTIONS_COUNT + 1) * 100}%;
|
||||
transform: translateY(-${currentSuggestionIndex * (100 / (SUGGESTIONS_COUNT + 1))}%);
|
||||
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
|
||||
`,
|
||||
[currentSuggestionIndex, isResetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesLength === 0) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSuggestionIndex((prev) => {
|
||||
if (prev === SUGGESTIONS_COUNT - 1) {
|
||||
return SUGGESTIONS_COUNT;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [messagesLength]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSuggestionIndex === suggestions.length) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsResetting(true);
|
||||
setCurrentSuggestionIndex(0);
|
||||
setTimeout(() => setIsResetting(false), 50);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [currentSuggestionIndex, suggestions.length]);
|
||||
|
||||
return (
|
||||
<Box $css={WRAPPER_CSS}>
|
||||
<Box $css={carrouselCss}>
|
||||
{carouselSuggestions.map((suggestion, index) => (
|
||||
<Box key={index} $css={ITEM_CSS}>
|
||||
{suggestion}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
SuggestionCarousel.displayName = 'SuggestionCarousel';
|
||||
+17
-15
@@ -18,7 +18,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (toolInvocation.toolName === 'document_parsing') {
|
||||
if (toolInvocation.state === 'partial-call') {
|
||||
if (
|
||||
toolInvocation.state === 'partial-call' ||
|
||||
toolInvocation.state === 'result'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,24 +35,23 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
)
|
||||
? documents.map((doc) => doc.identifier)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="pre"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="6px"
|
||||
key={toolInvocation.toolCallId}
|
||||
$background="var(--c--theme--colors--greyscale-100)"
|
||||
$color="var(--c--theme--colors--greyscale-500)"
|
||||
$padding={{ all: 'sm' }}
|
||||
$radius="8px"
|
||||
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
|
||||
$width="100%"
|
||||
$maxWidth="750px"
|
||||
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
|
||||
>
|
||||
{toolInvocation.state === 'result' ? (
|
||||
<Text>{`Parsing done: ${documentIdentifiers.join(', ')}`}</Text>
|
||||
) : (
|
||||
<Box $direction="row" $gap="1rem" $align="center">
|
||||
<Loader />
|
||||
<Text>{`Parsing documents: ${documentIdentifiers.join(', ')} ...`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Loader />
|
||||
<Text $variation="600" $size="md">
|
||||
{t('Extracting documents: {{documents}} ...', {
|
||||
documents: documentIdentifiers.join(', '),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
|
||||
const WELCOME_MARGIN = {
|
||||
horizontal: 'base',
|
||||
bottom: 'md',
|
||||
top: '-105px',
|
||||
} as const;
|
||||
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
|
||||
|
||||
export const WelcomeMessage = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
WelcomeMessage.displayName = 'WelcomeMessage';
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { InputChat } from '../InputChat';
|
||||
|
||||
// Mock stores and hooks
|
||||
jest.mock('@/stores', () => ({
|
||||
useResponsiveStore: () => ({
|
||||
isDesktop: true,
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/core', () => ({
|
||||
useConfig: () => ({
|
||||
data: {
|
||||
FEATURE_FLAGS: {
|
||||
'web-search': 'enabled',
|
||||
'document-upload': 'enabled',
|
||||
},
|
||||
chat_upload_accept: '.pdf,.txt',
|
||||
},
|
||||
}),
|
||||
FeatureFlagState: {
|
||||
ENABLED: 'enabled',
|
||||
DISABLED: 'disabled',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/libs', () => ({
|
||||
useAnalytics: () => ({
|
||||
isFeatureFlagActivated: jest.fn(() => true),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ToastProvider', () => ({
|
||||
useToast: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({
|
||||
useFileDragDrop: () => ({
|
||||
isDragActive: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/hooks/useFileUrls', () => ({
|
||||
useFileUrls: () => new Map(),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
jest.mock('../InputChatAction', () => ({
|
||||
InputChatActions: () => <div data-testid="input-chat-actions">Actions</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../SuggestionCarousel', () => ({
|
||||
SuggestionCarousel: () => (
|
||||
<div data-testid="suggestion-carousel">Suggestions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../WelcomeMessage', () => ({
|
||||
WelcomeMessage: () => <div data-testid="welcome-message">Welcome</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../AttachmentList', () => ({
|
||||
AttachmentList: () => <div data-testid="attachment-list">Attachments</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../ScrollDown', () => ({
|
||||
ScrollDown: () => <div data-testid="scroll-down">Scroll Down</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../../assets/files.svg', () => () => (
|
||||
<svg data-testid="files-icon" />
|
||||
));
|
||||
|
||||
const defaultProps = {
|
||||
messagesLength: 0,
|
||||
input: '',
|
||||
handleInputChange: jest.fn(),
|
||||
handleSubmit: jest.fn(),
|
||||
status: 'ready' as const,
|
||||
files: null,
|
||||
setFiles: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InputChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the textarea', () => {
|
||||
render(<InputChat {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render welcome message when messagesLength is 0', () => {
|
||||
render(<InputChat {...defaultProps} messagesLength={0} />);
|
||||
|
||||
expect(screen.getByTestId('welcome-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render welcome message when messagesLength > 0', () => {
|
||||
render(<InputChat {...defaultProps} messagesLength={1} />);
|
||||
|
||||
expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render suggestion carousel when input is empty', () => {
|
||||
render(<InputChat {...defaultProps} input="" />);
|
||||
|
||||
expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render suggestion carousel when input has content', () => {
|
||||
render(<InputChat {...defaultProps} input="Hello" />);
|
||||
|
||||
expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render input chat actions', () => {
|
||||
render(<InputChat {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleInputChange when typing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleInputChange = jest.fn();
|
||||
render(
|
||||
<InputChat {...defaultProps} handleInputChange={handleInputChange} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, 'Hello');
|
||||
|
||||
expect(handleInputChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable textarea when status is not ready', () => {
|
||||
render(<InputChat {...defaultProps} status="streaming" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable textarea when isUploadingFiles is true', () => {
|
||||
render(<InputChat {...defaultProps} isUploadingFiles={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should submit form when pressing Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = jest.fn((e) => e.preventDefault());
|
||||
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, '{Enter}');
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not submit form when pressing Shift+Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSubmit = jest.fn((e) => e.preventDefault());
|
||||
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox', {
|
||||
name: 'Enter your message or a question',
|
||||
});
|
||||
await user.type(textarea, '{Shift>}{Enter}{/Shift}');
|
||||
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { InputChatActions } from '../InputChatAction';
|
||||
|
||||
jest.mock('../ModelSelector', () => ({
|
||||
ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => (
|
||||
<button onClick={onModelSelect} data-testid="model-selector">
|
||||
Model Selector
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../SendButton', () => ({
|
||||
SendButton: ({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-testid="send-button">
|
||||
Send
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
fileUploadEnabled: true,
|
||||
webSearchEnabled: true,
|
||||
isUploadingFiles: false,
|
||||
isMobile: false,
|
||||
forceWebSearch: false,
|
||||
onAttachClick: jest.fn(),
|
||||
selectedModel: null,
|
||||
status: null,
|
||||
inputHasContent: true,
|
||||
};
|
||||
|
||||
describe('InputChatActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render attach file button', () => {
|
||||
render(<InputChatActions {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Attach file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onAttachClick when attach button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAttachClick = jest.fn();
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onAttachClick={onAttachClick} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add attach file' }));
|
||||
|
||||
expect(onAttachClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable attach button when fileUploadEnabled is false', () => {
|
||||
render(<InputChatActions {...defaultProps} fileUploadEnabled={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable attach button when isUploadingFiles is true', () => {
|
||||
render(<InputChatActions {...defaultProps} isUploadingFiles={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add attach file' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not show attach text on mobile', () => {
|
||||
render(<InputChatActions {...defaultProps} isMobile={true} />);
|
||||
|
||||
expect(screen.queryByText('Attach file')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render web search button when onWebSearchToggle is provided', () => {
|
||||
const onWebSearchToggle = jest.fn();
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
onWebSearchToggle={onWebSearchToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render web search button when onWebSearchToggle is undefined', () => {
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onWebSearchToggle={undefined} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Research on the web' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onWebSearchToggle when web search button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWebSearchToggle = jest.fn();
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
onWebSearchToggle={onWebSearchToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
);
|
||||
|
||||
expect(onWebSearchToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable web search button when webSearchEnabled is false', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
webSearchEnabled={false}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Research on the web' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render model selector when onModelSelect is provided', () => {
|
||||
const onModelSelect = jest.fn();
|
||||
render(
|
||||
<InputChatActions {...defaultProps} onModelSelect={onModelSelect} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render model selector when onModelSelect is undefined', () => {
|
||||
render(<InputChatActions {...defaultProps} onModelSelect={undefined} />);
|
||||
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render send button', () => {
|
||||
render(<InputChatActions {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Web" text on mobile when forceWebSearch is active', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
isMobile={true}
|
||||
forceWebSearch={true}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Web')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Research on the web" text on desktop when forceWebSearch is active', () => {
|
||||
render(
|
||||
<InputChatActions
|
||||
{...defaultProps}
|
||||
isMobile={false}
|
||||
forceWebSearch={true}
|
||||
onWebSearchToggle={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Research on the web')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Web')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { SuggestionCarousel } from '../SuggestionCarousel';
|
||||
|
||||
describe('SuggestionCarousel', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render all suggestions', () => {
|
||||
render(<SuggestionCarousel messagesLength={0} />);
|
||||
|
||||
// First suggestion appears twice (duplicated for looping animation)
|
||||
expect(screen.getAllByText('Ask a question')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText('Turn this list into bullet points'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Write a short product description'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Find recent news about...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should start the interval when messagesLength is 0', () => {
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval');
|
||||
|
||||
render(<SuggestionCarousel messagesLength={0} />);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not start interval when messagesLength is greater than 0', () => {
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval');
|
||||
|
||||
render(<SuggestionCarousel messagesLength={1} />);
|
||||
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear interval on unmount', () => {
|
||||
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
|
||||
|
||||
const { unmount } = render(<SuggestionCarousel messagesLength={0} />);
|
||||
unmount();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { WelcomeMessage } from '../WelcomeMessage';
|
||||
|
||||
describe('WelcomeMessage', () => {
|
||||
it('should render the welcome message', () => {
|
||||
render(<WelcomeMessage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: 'What is on your mind?' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFileDragDrop } from '../useFileDragDrop';
|
||||
|
||||
const createFile = (name: string, type: string): File => {
|
||||
return new File(['content'], name, { type });
|
||||
};
|
||||
|
||||
const createDragEvent = (
|
||||
type: string,
|
||||
files?: File[],
|
||||
relatedTarget?: EventTarget | null,
|
||||
): DragEvent => {
|
||||
const event = new Event(type, { bubbles: true }) as DragEvent;
|
||||
|
||||
const dataTransfer = {
|
||||
types: files ? ['Files'] : [],
|
||||
files: files
|
||||
? {
|
||||
length: files.length,
|
||||
item: (i: number) => files[i],
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const file of files) {
|
||||
yield file;
|
||||
}
|
||||
},
|
||||
}
|
||||
: { length: 0 },
|
||||
} as unknown as DataTransfer;
|
||||
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
|
||||
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget });
|
||||
Object.defineProperty(event, 'preventDefault', { value: jest.fn() });
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('useFileDragDrop', () => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
isFileAccepted: jest.fn(() => true),
|
||||
onFilesAccepted: jest.fn(),
|
||||
onFilesRejected: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with isDragActive as false', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isDragActive to true on dragenter with files', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set isDragActive on dragenter without files', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragenter'));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isDragActive to false on dragleave when leaving window', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragleave', [], null));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should not set isDragActive to false on dragleave when moving between elements', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('dragleave', [], document.body));
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isDragActive to false on drop', () => {
|
||||
const { result } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onFilesAccepted with accepted files on drop', () => {
|
||||
const onFilesAccepted = jest.fn();
|
||||
const file = createFile('test.txt', 'text/plain');
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
onFilesAccepted,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(createDragEvent('drop', [file]));
|
||||
});
|
||||
|
||||
expect(onFilesAccepted).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it('should call onFilesRejected with rejected file names', () => {
|
||||
const onFilesRejected = jest.fn();
|
||||
const isFileAccepted = jest.fn(() => false);
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
isFileAccepted,
|
||||
onFilesRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [createFile('test.exe', 'application/exe')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onFilesRejected).toHaveBeenCalledWith(['test.exe']);
|
||||
});
|
||||
|
||||
it('should separate accepted and rejected files', () => {
|
||||
const onFilesAccepted = jest.fn();
|
||||
const onFilesRejected = jest.fn();
|
||||
const isFileAccepted = jest.fn((file: File) => file.type === 'text/plain');
|
||||
|
||||
const acceptedFile = createFile('good.txt', 'text/plain');
|
||||
const rejectedFile = createFile('bad.exe', 'application/exe');
|
||||
|
||||
renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
isFileAccepted,
|
||||
onFilesAccepted,
|
||||
onFilesRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('drop', [acceptedFile, rejectedFile]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onFilesAccepted).toHaveBeenCalledWith([acceptedFile]);
|
||||
expect(onFilesRejected).toHaveBeenCalledWith(['bad.exe']);
|
||||
});
|
||||
|
||||
it('should ignore drag events when disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFileDragDrop({
|
||||
...defaultProps,
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset isDragActive when disabled changes to false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useFileDragDrop({ ...defaultProps, enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
|
||||
);
|
||||
});
|
||||
expect(result.current.isDragActive).toBe(true);
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(result.current.isDragActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useFileDragDrop(defaultProps));
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragenter',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragleave',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'dragover',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'drop',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFileUrls } from '../useFileUrls';
|
||||
|
||||
const createFile = (
|
||||
name: string,
|
||||
size: number = 100,
|
||||
lastModified: number = 1234567890,
|
||||
): File => {
|
||||
const file = new File(['x'.repeat(size)], name, { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'size', { value: size });
|
||||
Object.defineProperty(file, 'lastModified', { value: lastModified });
|
||||
return file;
|
||||
};
|
||||
|
||||
const createFileList = (files: File[]): FileList => {
|
||||
return {
|
||||
length: files.length,
|
||||
item: (index: number) => files[index] || null,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const file of files) {
|
||||
yield file;
|
||||
}
|
||||
},
|
||||
...files.reduce(
|
||||
(acc, file, index) => {
|
||||
acc[index] = file;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, File>,
|
||||
),
|
||||
} as FileList;
|
||||
};
|
||||
|
||||
describe('useFileUrls', () => {
|
||||
let urlCounter: number;
|
||||
const mockCreateObjectURL = jest.fn();
|
||||
const mockRevokeObjectURL = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
urlCounter = 0;
|
||||
mockCreateObjectURL.mockImplementation(
|
||||
() => `blob:mock-url-${++urlCounter}`,
|
||||
);
|
||||
mockRevokeObjectURL.mockImplementation(() => {});
|
||||
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return an empty Map when files is null', () => {
|
||||
const { result } = renderHook(() => useFileUrls(null));
|
||||
|
||||
expect(result.current).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('should create URLs for files', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList = createFileList([file]);
|
||||
|
||||
const { result } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.get('test.txt-100-1234567890')).toBe(
|
||||
'blob:mock-url-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create URLs for multiple files', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
const fileList = createFileList([file1, file2]);
|
||||
|
||||
const { result } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.size).toBe(2);
|
||||
expect(result.current.get('file1.txt-100-1000')).toBe('blob:mock-url-1');
|
||||
expect(result.current.get('file2.txt-200-2000')).toBe('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should reuse URLs for unchanged files', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList1 = createFileList([file]);
|
||||
const fileList2 = createFileList([file]);
|
||||
|
||||
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: fileList1 },
|
||||
});
|
||||
|
||||
const initialUrl = result.current.get('test.txt-100-1234567890');
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ files: fileList2 });
|
||||
|
||||
expect(result.current.get('test.txt-100-1234567890')).toBe(initialUrl);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should revoke URLs when files are removed', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1, file2]) },
|
||||
});
|
||||
|
||||
rerender({ files: createFileList([file1]) });
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should revoke all URLs when files becomes null', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { rerender } = renderHook<
|
||||
Map<string, string>,
|
||||
{ files: FileList | null }
|
||||
>(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1, file2]) },
|
||||
});
|
||||
|
||||
rerender({ files: null });
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
|
||||
});
|
||||
|
||||
it('should clean up all URLs on unmount', () => {
|
||||
const file = createFile('test.txt');
|
||||
const fileList = createFileList([file]);
|
||||
|
||||
const { unmount } = renderHook(() => useFileUrls(fileList));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
});
|
||||
|
||||
it('should create new URL when adding a file', () => {
|
||||
const file1 = createFile('file1.txt', 100, 1000);
|
||||
const file2 = createFile('file2.txt', 200, 2000);
|
||||
|
||||
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
|
||||
initialProps: { files: createFileList([file1]) },
|
||||
});
|
||||
|
||||
expect(result.current.size).toBe(1);
|
||||
|
||||
rerender({ files: createFileList([file1, file2]) });
|
||||
|
||||
expect(result.current.size).toBe(2);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseFileDragDropOptions {
|
||||
/**
|
||||
* Whether file upload feature is enabled.
|
||||
* When false, drag/drop events are ignored.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Callback to validate if a file type is accepted.
|
||||
* @param file - File to validate
|
||||
* @returns true if file should be accepted
|
||||
*/
|
||||
isFileAccepted: (file: File) => boolean;
|
||||
|
||||
/**
|
||||
* Callback when files are dropped and validated.
|
||||
* Receives only the accepted files.
|
||||
*/
|
||||
onFilesAccepted: (files: File[]) => void;
|
||||
|
||||
/**
|
||||
* Callback when some files are rejected due to type.
|
||||
* Useful for showing error toasts.
|
||||
*/
|
||||
onFilesRejected?: (fileNames: string[]) => void;
|
||||
}
|
||||
|
||||
interface UseFileDragDropReturn {
|
||||
/**
|
||||
* Whether a drag operation is currently active over the window.
|
||||
* Use this to show a drop overlay.
|
||||
*/
|
||||
isDragActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle file drag and drop functionality.
|
||||
*
|
||||
* Purpose:
|
||||
* - Manages window-level drag/drop events for file uploads
|
||||
* - Tracks drag state to show/hide drop overlay
|
||||
* - Validates files against accepted types
|
||||
* - Separates accepted and rejected files
|
||||
* - Cleans up event listeners on unmount
|
||||
*
|
||||
* Why window-level events:
|
||||
* - Allows dropping files anywhere on the page
|
||||
* - Better UX than requiring precise drop zone targeting
|
||||
* - Handles edge cases like dragging over child elements
|
||||
*
|
||||
* @example
|
||||
* const { isDragActive } = useFileDragDrop({
|
||||
* enabled: fileUploadEnabled,
|
||||
* isFileAccepted: (file) => file.type.startsWith('image/'),
|
||||
* onFilesAccepted: (files) => setFiles(files),
|
||||
* onFilesRejected: (names) => showError(`Rejected: ${names.join(', ')}`),
|
||||
* });
|
||||
*/
|
||||
export const useFileDragDrop = ({
|
||||
enabled,
|
||||
isFileAccepted,
|
||||
onFilesAccepted,
|
||||
onFilesRejected,
|
||||
}: UseFileDragDropOptions): UseFileDragDropReturn => {
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
// Process dropped files: separate accepted from rejected
|
||||
const processFiles = useCallback(
|
||||
(fileList: FileList) => {
|
||||
const accepted: File[] = [];
|
||||
const rejectedNames: string[] = [];
|
||||
|
||||
Array.from(fileList).forEach((file) => {
|
||||
if (isFileAccepted(file)) {
|
||||
accepted.push(file);
|
||||
} else {
|
||||
rejectedNames.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Notify about rejected files first (for error toasts)
|
||||
if (rejectedNames.length > 0 && onFilesRejected) {
|
||||
onFilesRejected(rejectedNames);
|
||||
}
|
||||
|
||||
// Then handle accepted files
|
||||
if (accepted.length > 0) {
|
||||
onFilesAccepted(accepted);
|
||||
}
|
||||
},
|
||||
[isFileAccepted, onFilesAccepted, onFilesRejected],
|
||||
);
|
||||
|
||||
// Window-level drag/drop handlers
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsDragActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only activate for file drags (not text selections, etc.)
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setIsDragActive(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only deactivate when leaving the window entirely
|
||||
// relatedTarget is null when cursor leaves the document
|
||||
if (!e.relatedTarget) {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
// Required to allow drop
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
processFiles(droppedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach to window for full-page drag/drop support
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
window.addEventListener('dragleave', handleDragLeave);
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [enabled, processFiles]);
|
||||
|
||||
return {
|
||||
isDragActive,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Manages object URLs for file previews with proper memory cleanup.
|
||||
*
|
||||
* - Reuses URLs for unchanged files across re-renders
|
||||
* - Revokes URLs when files are removed
|
||||
* - Cleans up all URLs on unmount
|
||||
*
|
||||
* @param files - FileList from input or drag/drop, or null
|
||||
* @returns Map of file keys (`${name}-${size}-${lastModified}`) to object URLs
|
||||
*/
|
||||
export const useFileUrls = (files: FileList | null) => {
|
||||
const [urlMap, setUrlMap] = useState<Map<string, string>>(new Map());
|
||||
const urlMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Keep ref in sync with latest state
|
||||
useEffect(() => {
|
||||
urlMapRef.current = urlMap;
|
||||
});
|
||||
|
||||
// Handle URLs when files change
|
||||
useEffect(() => {
|
||||
const prevMap = urlMapRef.current;
|
||||
|
||||
if (!files) {
|
||||
prevMap.forEach((url) => URL.revokeObjectURL(url));
|
||||
setUrlMap(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const newMap = new Map<string, string>();
|
||||
const currentFileKeys = new Set(
|
||||
Array.from(files).map((f) => getFileKey(f)),
|
||||
);
|
||||
|
||||
// Reuse or revoke inline
|
||||
prevMap.forEach((url, key) => {
|
||||
if (currentFileKeys.has(key)) {
|
||||
newMap.set(key, url);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Create URLs for new files
|
||||
Array.from(files).forEach((file) => {
|
||||
const key = getFileKey(file);
|
||||
if (!newMap.has(key)) {
|
||||
newMap.set(key, URL.createObjectURL(file));
|
||||
}
|
||||
});
|
||||
|
||||
setUrlMap(newMap);
|
||||
}, [files]);
|
||||
|
||||
// Unmount-only cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
urlMapRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return urlMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a unique key for a file based on its properties.
|
||||
* Used to track file identity across renders.
|
||||
*/
|
||||
const getFileKey = (file: File): string => {
|
||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||
};
|
||||
+53
-28
@@ -1,73 +1,98 @@
|
||||
import { Button as _Button, useModal } from '@openfun/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
|
||||
|
||||
import { ModalRemoveConversation } from './ModalRemoveConversation';
|
||||
import { ModalRenameConversation } from './ModalRenameConversation';
|
||||
|
||||
interface ConversationItemActionsProps {
|
||||
conversation: ChatConversation;
|
||||
}
|
||||
|
||||
const dropdownStyles = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: #e1e3e7 !important;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3e5de7;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const iconStyles = css`
|
||||
font-size: 1rem;
|
||||
color: var(--c--theme--colors--primary-text-text);
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
export const ConversationItemActions = ({
|
||||
conversation,
|
||||
}: ConversationItemActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteModal = useModal();
|
||||
const deleteModal = useOwnModal();
|
||||
const renameModal = useOwnModal();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Rename chat'),
|
||||
icon: 'edit',
|
||||
callback: renameModal.open,
|
||||
disabled: false,
|
||||
testId: `conversation-item-actions-rename-${conversation.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Delete chat'),
|
||||
icon: 'delete',
|
||||
callback: () => deleteModal.open(),
|
||||
callback: deleteModal.open,
|
||||
disabled: false,
|
||||
testId: `conversation-item-actions-remove-${conversation.id}`,
|
||||
},
|
||||
];
|
||||
const dropdownLabel = useMemo(
|
||||
() =>
|
||||
t('Actions list for conversation {{title}}', {
|
||||
title: conversation.title || t('Untitled conversation'),
|
||||
}),
|
||||
[t, conversation.title],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
label={t('Actions list for conversation {{title}}', {
|
||||
title: conversation.title || t('Untitled conversation'),
|
||||
})}
|
||||
buttonCss={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: #e1e3e7 !important;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3e5de7;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
label={dropdownLabel}
|
||||
buttonCss={dropdownStyles}
|
||||
>
|
||||
<Icon
|
||||
data-testid={`conversation-item-actions-button-${conversation.id}`}
|
||||
iconName="more_horiz"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$css={css`
|
||||
font-size: 1rem;
|
||||
color: var(--c--theme--colors--primary-text-text);
|
||||
pointer-events: none;
|
||||
`}
|
||||
$css={iconStyles}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
{deleteModal.isOpen && (
|
||||
<ModalRemoveConversation
|
||||
onClose={deleteModal.onClose}
|
||||
onClose={deleteModal.close}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
{renameModal.isOpen && (
|
||||
<ModalRenameConversation
|
||||
onClose={renameModal.close}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
+82
-52
@@ -1,3 +1,4 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, StyledLink } from '@/components';
|
||||
@@ -12,61 +13,90 @@ type LeftPanelConversationItemProps = {
|
||||
isCurrentConversation: boolean;
|
||||
};
|
||||
|
||||
export const LeftPanelConversationItem = ({
|
||||
conversation,
|
||||
isCurrentConversation,
|
||||
}: LeftPanelConversationItemProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { setPanelOpen } = useChatPreferencesStore();
|
||||
const linkStyles = css`
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
color: var(--c--theme--colors--greyscale-900);
|
||||
`;
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (!isDesktop) {
|
||||
setPanelOpen(false);
|
||||
const baseBoxStyles = css`
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
margin-bottom: 1px;
|
||||
|
||||
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background-color: #ebedf1;
|
||||
.pinned-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
.pinned-actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$padding={{ horizontal: 'xs', vertical: '4px' }}
|
||||
$justify="space-between"
|
||||
$css={css`
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
margin-bottom: 1px;
|
||||
background-color: ${isCurrentConversation ? '#ebedf1' : ''};
|
||||
font-weight: ${isCurrentConversation ? '700' : '500'};
|
||||
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
|
||||
.pinned-actions {
|
||||
padding: 2px 0;
|
||||
opacity: ${isDesktop ? 0 : 1};
|
||||
background-color: transparent
|
||||
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
|
||||
}
|
||||
&:hover, &:focus, &:focus-within {
|
||||
background-color: #ebedf1;
|
||||
.pinned-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.pinned-actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
className="--docs--left-panel-favorite-item"
|
||||
>
|
||||
<StyledLink
|
||||
href={`/chat/${conversation.id}/`}
|
||||
$css="overflow: auto; flex-grow: 1;"
|
||||
onClick={handleLinkClick}
|
||||
const getBoxStyles = (
|
||||
isCurrentConversation: boolean,
|
||||
isDesktop: boolean,
|
||||
) => css`
|
||||
${baseBoxStyles}
|
||||
background-color: ${isCurrentConversation ? '#ebedf1' : 'transparent'};
|
||||
font-weight: ${isCurrentConversation ? '700' : '500'};
|
||||
|
||||
.pinned-actions {
|
||||
padding: 2px 0;
|
||||
opacity: ${isDesktop ? 0 : 1};
|
||||
background-color: transparent;
|
||||
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
const containerPadding = { horizontal: 'xs', vertical: '4px' };
|
||||
|
||||
export const LeftPanelConversationItem = memo(
|
||||
function LeftPanelConversationItem({
|
||||
conversation,
|
||||
isCurrentConversation,
|
||||
}: LeftPanelConversationItemProps) {
|
||||
const isDesktop = useResponsiveStore((state) => state.isDesktop);
|
||||
const setPanelOpen = useChatPreferencesStore((state) => state.setPanelOpen);
|
||||
|
||||
const handleLinkClick = useCallback(() => {
|
||||
if (!isDesktop) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}, [isDesktop, setPanelOpen]);
|
||||
|
||||
const boxStyles = useMemo(
|
||||
() => getBoxStyles(isCurrentConversation, isDesktop),
|
||||
[isCurrentConversation, isDesktop],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$padding={containerPadding}
|
||||
$justify="space-between"
|
||||
$css={boxStyles}
|
||||
className="--docs--left-panel-favorite-item"
|
||||
>
|
||||
<SimpleConversationItem showAccesses conversation={conversation} />
|
||||
</StyledLink>
|
||||
<StyledLink
|
||||
href={`/chat/${conversation.id}/`}
|
||||
$css={linkStyles}
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
<SimpleConversationItem showAccesses conversation={conversation} />
|
||||
</StyledLink>
|
||||
|
||||
<Box className="pinned-actions">
|
||||
<ConversationItemActions conversation={conversation} />
|
||||
<Box className="pinned-actions">
|
||||
<ConversationItemActions conversation={conversation} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+5
-2
@@ -46,7 +46,7 @@ export const ModalRemoveConversation = ({
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
color="tertiary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
@@ -79,7 +79,10 @@ export const ModalRemoveConversation = ({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box className="--converstions--modal-remove-chat">
|
||||
<Box
|
||||
className="--conversations--modal-remove-chat"
|
||||
data-testid="delete-chat-confirm"
|
||||
>
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete this conversation ?')}
|
||||
</Text>
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, useToast } from '@/components';
|
||||
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
interface ModalRenameConversationProps {
|
||||
onClose: () => void;
|
||||
conversation: ChatConversation;
|
||||
}
|
||||
|
||||
export const ModalRenameConversation = ({
|
||||
onClose,
|
||||
conversation,
|
||||
}: ModalRenameConversationProps) => {
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const { mutate: renameConversation } = useRenameConversation({
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
'success',
|
||||
t('The conversation has been renamed.'),
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error.cause?.[0] ||
|
||||
error.message ||
|
||||
t('An error occurred while renaming the conversation');
|
||||
showToast('error', errorMessage, undefined, 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const [newName, setNewName] = useState(conversation.title ?? '');
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedNewName = newName.trim();
|
||||
if (trimmedNewName) {
|
||||
renameConversation({
|
||||
conversationId: conversation.id,
|
||||
title: trimmedNewName,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-label={t('Content modal to rename a conversation')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="tertiary"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Rename chat')}
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="rename-chat-form"
|
||||
>
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h6"
|
||||
$margin={{ all: '0' }}
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
{t('Rename chat')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box className="--conversations--modal-rename-chat">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
id="rename-chat-form"
|
||||
data-testid="rename-chat-form"
|
||||
className="mt-s"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
label={t('New name')}
|
||||
maxLength={100}
|
||||
value={newName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+12
-13
@@ -1,11 +1,10 @@
|
||||
// A SUPPRIMER ?
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import BubbleIcon from '../assets/bubble-bold.svg';
|
||||
|
||||
@@ -18,19 +17,22 @@ const ItemTextCss = css`
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
`;
|
||||
|
||||
const bubbleContainerStyles = css`
|
||||
background-color: transparent;
|
||||
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
|
||||
`;
|
||||
type SimpleConversationItemProps = {
|
||||
conversation: ChatConversation;
|
||||
showAccesses?: boolean;
|
||||
};
|
||||
|
||||
export const SimpleConversationItem = ({
|
||||
export const SimpleConversationItem = memo(function SimpleConversationItem({
|
||||
conversation,
|
||||
showAccesses: _showAccesses = false,
|
||||
}: SimpleConversationItemProps) => {
|
||||
}: SimpleConversationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { isDesktop: _isDesktop } = useResponsiveStore();
|
||||
const title = conversation.title || t('Untitled conversation');
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -42,10 +44,7 @@ export const SimpleConversationItem = ({
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
background-color: transparent;
|
||||
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
|
||||
`}
|
||||
$css={bubbleContainerStyles}
|
||||
$padding={`${spacingsTokens['3xs']} 0`}
|
||||
>
|
||||
<BubbleIcon
|
||||
@@ -56,14 +55,14 @@ export const SimpleConversationItem = ({
|
||||
<Box $justify="center" $overflow="auto">
|
||||
<Text
|
||||
aria-describedby="doc-title"
|
||||
aria-label={conversation.title || t('Untitled conversation')}
|
||||
aria-label={title}
|
||||
$size="sm"
|
||||
$variation="850"
|
||||
$css={ItemTextCss}
|
||||
>
|
||||
{conversation.title || t('Untitled conversation')}
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ConversationItemActions } from '../ConversationItemActions';
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => {
|
||||
if (options) {
|
||||
return Object.entries(options).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
|
||||
key,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@/components', () => ({
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
options: { label: string; callback: () => void; testId: string }[];
|
||||
label: string;
|
||||
}) => (
|
||||
<div data-testid="dropdown-menu" aria-label={label}>
|
||||
{children}
|
||||
<ul>
|
||||
{options.map((option) => (
|
||||
<li key={option.testId}>
|
||||
<button onClick={option.callback} data-testid={option.testId}>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
Icon: ({
|
||||
iconName,
|
||||
|
||||
...props
|
||||
}: {
|
||||
iconName: string;
|
||||
}) => <span data-icon={iconName} {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock('../ModalRenameConversation', () => ({
|
||||
ModalRenameConversation: ({
|
||||
onClose,
|
||||
conversation,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
conversation: { id: string };
|
||||
}) => (
|
||||
<div data-testid="modal-rename-conversation">
|
||||
<span>Rename conversation {conversation.id}</span>
|
||||
<button onClick={onClose} data-testid="modal-rename-close-button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../ModalRemoveConversation', () => ({
|
||||
ModalRemoveConversation: ({
|
||||
onClose,
|
||||
conversation,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
conversation: { id: string };
|
||||
}) => (
|
||||
<div data-testid="modal-remove-conversation">
|
||||
<span>Remove conversation {conversation.id}</span>
|
||||
<button onClick={onClose} data-testid="modal-remove-close-button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Test Conversation',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
describe('ConversationItemActions', () => {
|
||||
it('renders the actions button', () => {
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-button-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dropdown menu with correct aria-label', () => {
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Actions list for conversation Test Conversation',
|
||||
);
|
||||
});
|
||||
it('renders dropdown menu with fallback title when conversation has no title', () => {
|
||||
const untitledConversation = { ...mockConversation, title: '' };
|
||||
render(<ConversationItemActions conversation={untitledConversation} />);
|
||||
|
||||
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Actions list for conversation Untitled conversation',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render delete and rename options', () => {
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Rename chat')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown menu when clicking the actions button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens delete modal when clicking delete option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('modal-remove-conversation'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('modal-remove-conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens rename modal when clicking rename option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('modal-rename-conversation'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('modal-rename-conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes delete modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('modal-remove-conversation')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('modal-remove-close-button'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('modal-remove-conversation'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes rename modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConversationItemActions conversation={mockConversation} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('modal-rename-conversation')).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('modal-rename-close-button'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('modal-rename-conversation'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { LeftPanelConversationItem } from '../LeftPanelConversationItem';
|
||||
|
||||
const mockSetPanelOpen = jest.fn();
|
||||
const mockPush = jest.fn();
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/stores', () => ({
|
||||
useResponsiveStore: jest.fn((selector) => selector({ isDesktop: true })),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/stores/useChatPreferencesStore', () => ({
|
||||
useChatPreferencesStore: jest.fn((selector) =>
|
||||
selector({ setPanelOpen: mockSetPanelOpen }),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../SimpleConversationItem', () => ({
|
||||
SimpleConversationItem: ({
|
||||
conversation,
|
||||
}: {
|
||||
conversation: { title: string };
|
||||
}) => <div data-testid="simple-conversation-item">{conversation.title}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../ConversationItemActions', () => ({
|
||||
ConversationItemActions: ({
|
||||
conversation,
|
||||
}: {
|
||||
conversation: { id: string };
|
||||
}) => (
|
||||
<div data-testid={`conversation-actions-${conversation.id}`}>Actions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Test Conversation',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
describe('LeftPanelConversationItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the conversation item with link', () => {
|
||||
render(
|
||||
<LeftPanelConversationItem
|
||||
conversation={mockConversation}
|
||||
isCurrentConversation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', `/chat/${mockConversation.id}/`);
|
||||
});
|
||||
|
||||
it('should render SimpleConversationItem', () => {
|
||||
render(
|
||||
<LeftPanelConversationItem
|
||||
conversation={mockConversation}
|
||||
isCurrentConversation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('simple-conversation-item')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ConversationItemActions', () => {
|
||||
render(
|
||||
<LeftPanelConversationItem
|
||||
conversation={mockConversation}
|
||||
isCurrentConversation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(`conversation-actions-${mockConversation.id}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not close panel on click when on desktop', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LeftPanelConversationItem
|
||||
conversation={mockConversation}
|
||||
isCurrentConversation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('link'));
|
||||
|
||||
expect(mockSetPanelOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close panel on click when on mobile', async () => {
|
||||
const { useResponsiveStore } = jest.requireMock('@/stores');
|
||||
useResponsiveStore.mockImplementation(
|
||||
(selector: (state: { isDesktop: boolean }) => boolean) =>
|
||||
selector({ isDesktop: false }),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LeftPanelConversationItem
|
||||
conversation={mockConversation}
|
||||
isCurrentConversation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('link'));
|
||||
|
||||
expect(mockSetPanelOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { useToast } from '@/components';
|
||||
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
import { ModalRenameConversation } from '../ModalRenameConversation';
|
||||
|
||||
jest.mock('@/components', () => ({
|
||||
...jest.requireActual('@/components'),
|
||||
useToast: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/api/useRenameConversation');
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => {
|
||||
if (options) {
|
||||
return Object.entries(options).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
|
||||
key,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
const renderWithProviders = (component: React.ReactNode) => {
|
||||
return render(<CunninghamProvider>{component}</CunninghamProvider>);
|
||||
};
|
||||
|
||||
describe('ModalRenameConversation', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockRenameConversation = jest.fn();
|
||||
|
||||
const mockConversation: ChatConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Original Title',
|
||||
messages: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as ChatConversation;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useToast as jest.Mock).mockReturnValue({
|
||||
showToast: mockShowToast,
|
||||
});
|
||||
(useRenameConversation as jest.Mock).mockReturnValue({
|
||||
mutate: mockRenameConversation,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the modal with correct title and initial value', () => {
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Rename chat')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Original Title');
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rename')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates input value when user types', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
|
||||
expect(input).toHaveValue('New Title');
|
||||
});
|
||||
|
||||
it('closes modal when Cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits form with new name and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onSuccessCallback: (() => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onSuccess }) => {
|
||||
onSuccessCallback = onSuccess;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Updated Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
expect(mockRenameConversation).toHaveBeenCalledWith({
|
||||
conversationId: 'conv-123',
|
||||
title: 'Updated Title',
|
||||
});
|
||||
|
||||
onSuccessCallback?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'success',
|
||||
'The conversation has been renamed.',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit form when new name is empty or whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, ' ');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
expect(mockRenameConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error toast when rename fails with cause', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {
|
||||
cause: ['Specific error from API'],
|
||||
message: 'Generic error',
|
||||
};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Specific error from API',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast with message when no cause is provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {
|
||||
message: 'Network error',
|
||||
};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Network error',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows default error message when error has no cause or message', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'An error occurred while renaming the conversation',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces maxLength of 100 characters on input', () => {
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toHaveAttribute('maxLength', '100');
|
||||
});
|
||||
});
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import '@/i18n/initI18n';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { SimpleConversationItem } from '../SimpleConversationItem';
|
||||
|
||||
jest.mock('../../assets/bubble-bold.svg', () => {
|
||||
return function BubbleIcon({ color, ...props }: { color: string }) {
|
||||
return <svg data-testid="bubble-icon" data-color={color} {...props} />;
|
||||
};
|
||||
});
|
||||
|
||||
const mockConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Test Conversation',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const renderWithWrapper = (ui: React.ReactElement) => {
|
||||
return render(ui, { wrapper: AppWrapper });
|
||||
};
|
||||
|
||||
describe('SimpleConversationItem', () => {
|
||||
it('should render conversation title', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem conversation={mockConversation} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render bubble icon', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem conversation={mockConversation} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('bubble-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible label for bubble icon', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem conversation={mockConversation} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Simple chat icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Untitled conversation" when title is empty', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem
|
||||
conversation={{ ...mockConversation, title: '' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Untitled conversation" when title is undefined', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem
|
||||
conversation={{
|
||||
...mockConversation,
|
||||
title: undefined as unknown as string,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-label with the title', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem conversation={mockConversation} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Test Conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-label with "Untitled conversation" when title is empty', () => {
|
||||
renderWithWrapper(
|
||||
<SimpleConversationItem
|
||||
conversation={{ ...mockConversation, title: '' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Untitled conversation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useOwnModal } from '../useModalHook';
|
||||
|
||||
describe('useOwnModal', () => {
|
||||
it('should initialize with isOpen as false by default', () => {
|
||||
const { result } = renderHook(() => useOwnModal());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with isOpen as true when initialState is true', () => {
|
||||
const { result } = renderHook(() => useOwnModal(true));
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isOpen to true when open is called', () => {
|
||||
const { result } = renderHook(() => useOwnModal());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isOpen to false when close is called', () => {
|
||||
const { result } = renderHook(() => useOwnModal(true));
|
||||
|
||||
act(() => {
|
||||
result.current.close();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should maintain stable callback references across rerenders', () => {
|
||||
const { result, rerender } = renderHook(() => useOwnModal());
|
||||
|
||||
const initialOpen = result.current.open;
|
||||
const initialClose = result.current.close;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.open).toBe(initialOpen);
|
||||
expect(result.current.close).toBe(initialClose);
|
||||
});
|
||||
|
||||
it('should handle multiple open/close cycles', () => {
|
||||
const { result } = renderHook(() => useOwnModal());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.close();
|
||||
});
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface UseModalReturn {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages modal open/close state with stable callback references.
|
||||
*
|
||||
* @param initialState - Initial open state (default: false)
|
||||
* @returns `isOpen` state and stable `open`/`close` callbacks safe for dependency arrays
|
||||
*
|
||||
*/
|
||||
export const useOwnModal = (initialState = false): UseModalReturn => {
|
||||
const [isOpen, setIsOpen] = useState(initialState);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
return { isOpen, open, close };
|
||||
};
|
||||
@@ -1,2 +1 @@
|
||||
export * from './useDate';
|
||||
export * from './useClipboard';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useCmdK = (callback: () => void) => {
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', down);
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, [callback]);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const formatDefault: DateTimeFormatOptions = {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
export const useDate = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const formatDate = (
|
||||
date: string,
|
||||
format: DateTimeFormatOptions = formatDefault,
|
||||
): string => {
|
||||
return DateTime.fromISO(date)
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
};
|
||||
|
||||
return { formatDate };
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"de": { "translation": { "ABC-1234-XY": "ABC-1234-XY" } },
|
||||
"en": { "translation": { "Login": "Login", "Logout": "Logout" } },
|
||||
"en": {
|
||||
"translation": {
|
||||
"Login": "Login",
|
||||
"Logout": "Logout",
|
||||
"Extracting documents {{documents}} ...": "Extracting documents {{documents}} ..."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"translation": {
|
||||
"30 sec to tell us what you think or report a bug": "Prenez 30 secondes pour partager votre avis ou signaler un bug",
|
||||
@@ -43,6 +49,7 @@
|
||||
"Direct exchange with our team": "Échange direct avec notre équipe",
|
||||
"Enter your message or a question": "Entrez votre message ou une question",
|
||||
"Explore other LaSuite apps": "Explorer les autres applications de LaSuite",
|
||||
"Extracting documents: {{documents}} ...": "Extraction des documents: {{documents}} ...",
|
||||
"Failed to activate account. Please try again.": "Échec de l'activation du compte. Veuillez réessayer.",
|
||||
"Failed to copy": "Échec de la copie",
|
||||
"Failed to register for notifications. Please try again.": "Échec de l'inscription aux notifications. Veuillez réessayer.",
|
||||
@@ -88,6 +95,7 @@
|
||||
"Quick search input": "Saisie de recherche rapide",
|
||||
"Remove attachment": "Supprimer la pièce jointe",
|
||||
"Research on the web": "Rechercher sur le web",
|
||||
"Retry": "Réessayer",
|
||||
"Search": "Rechercher",
|
||||
"Search for a chat": "Rechercher un chat",
|
||||
"Search results": "Résultats de la recherche",
|
||||
@@ -100,7 +108,7 @@
|
||||
"Simple chat icon": "Icône de chat simple",
|
||||
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
|
||||
"Sorry, an error occurred. Please try again.": "Désolé, une erreur s'est produite. Veuillez réessayer.",
|
||||
"Start a new conversation.": "Commencer une nouvelle conversation.",
|
||||
"Start a new conversation": "Commencer une nouvelle conversation",
|
||||
"Start conversation": "Entamer la conversation",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Résumé en cours...",
|
||||
@@ -175,6 +183,7 @@
|
||||
"Direct exchange with our team": "Directe uitwisseling met ons team",
|
||||
"Enter your message or a question": "Voer uw bericht of een vraag in",
|
||||
"Explore other LaSuite apps": "Ontdek andere LaSuite-apps",
|
||||
"Extracting documents: {{documents}} ...": "Documenten extraheren: {{documents}} ...",
|
||||
"Failed to activate account. Please try again.": "Account activeren mislukt. Probeer het opnieuw.",
|
||||
"Failed to copy": "Kopiëren mislukt",
|
||||
"Failed to register for notifications. Please try again.": "Registratie voor meldingen mislukt. Probeer het opnieuw.",
|
||||
@@ -220,6 +229,7 @@
|
||||
"Quick search input": "Snelle zoekinvoer",
|
||||
"Remove attachment": "Bijlage verwijderen",
|
||||
"Research on the web": "Onderzoek op het internet",
|
||||
"Retry": "Opnieuw proberen",
|
||||
"Search": "Zoek",
|
||||
"Search for a chat": "Zoek naar een chat",
|
||||
"Search results": "Zoekresultaten",
|
||||
@@ -232,7 +242,7 @@
|
||||
"Simple chat icon": "Eenvoudig chatpictogram",
|
||||
"Something bad happens, please retry.": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"Sorry, an error occurred. Please try again.": "Sorry, er is een fout opgetreden. Probeer het opnieuw.",
|
||||
"Start a new conversation.": "Begin een nieuw gesprek.",
|
||||
"Start a new conversation": "Begin een nieuw gesprek",
|
||||
"Start conversation": "Begin een gesprek",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Samenvatten...",
|
||||
@@ -307,6 +317,7 @@
|
||||
"Direct exchange with our team": "Прямое общение с нашей командой",
|
||||
"Enter your message or a question": "Введите сообщение или вопрос",
|
||||
"Explore other LaSuite apps": "Посмотреть другие приложения LaSuite",
|
||||
"Extracting documents: {{documents}} ...": "Извлечение документов: {{documents}} ...",
|
||||
"Failed to activate account. Please try again.": "Не удалось активировать учётную запись. Пожалуйста, попробуйте снова.",
|
||||
"Failed to copy": "Не удалось скопировать",
|
||||
"Failed to register for notifications. Please try again.": "Не удалось зарегистрироваться для получения уведомлений. Пожалуйста, попробуйте ещё раз.",
|
||||
@@ -352,6 +363,7 @@
|
||||
"Quick search input": "Быстрый поиск",
|
||||
"Remove attachment": "Удалить вложение",
|
||||
"Research on the web": "Исследование в Интернете",
|
||||
"Retry": "Повторить",
|
||||
"Search": "Поиск",
|
||||
"Search for a chat": "Поиск беседы",
|
||||
"Search results": "Результаты поиска",
|
||||
@@ -364,7 +376,7 @@
|
||||
"Simple chat icon": "Простой значок чата",
|
||||
"Something bad happens, please retry.": "Что-то пошло не так, повторите попытку.",
|
||||
"Sorry, an error occurred. Please try again.": "Извините, произошла ошибка. Пожалуйста, попробуйте ещё раз.",
|
||||
"Start a new conversation.": "Начать новую беседу.",
|
||||
"Start a new conversation": "Начать новую беседу",
|
||||
"Start conversation": "Начать беседу",
|
||||
"Stop": "Остановить",
|
||||
"Summarizing...": "Обобщение...",
|
||||
@@ -439,6 +451,7 @@
|
||||
"Direct exchange with our team": "Пряме спілкування з нашою командою",
|
||||
"Enter your message or a question": "Введіть ваше повідомлення або питання",
|
||||
"Explore other LaSuite apps": "Ознайомтесь з іншими застосунками LaSuite",
|
||||
"Extracting documents: {{documents}} ...": "Вилучення документів: {{documents}} ...",
|
||||
"Failed to activate account. Please try again.": "Не вдалося активувати обліковий запис. Спробуйте ще раз.",
|
||||
"Failed to copy": "Не вдалось скопіювати",
|
||||
"Failed to register for notifications. Please try again.": "Не вдалося виконати реєстрацію для повідомлень. Будь ласка, спробуйте ще раз.",
|
||||
@@ -484,6 +497,7 @@
|
||||
"Quick search input": "Швидкий пошук",
|
||||
"Remove attachment": "Видалити вкладення",
|
||||
"Research on the web": "Дослідження в Інтернеті",
|
||||
"Retry": "Повторити",
|
||||
"Search": "Пошук",
|
||||
"Search for a chat": "Пошук розмови",
|
||||
"Search results": "Результати пошуку",
|
||||
@@ -496,7 +510,7 @@
|
||||
"Simple chat icon": "Проста піктограма розмови",
|
||||
"Something bad happens, please retry.": "Сталася помилка, спробуйте ще раз.",
|
||||
"Sorry, an error occurred. Please try again.": "Вибачте, виникла помилка. Спробуйте ще раз.",
|
||||
"Start a new conversation.": "Розпочати нову розмову.",
|
||||
"Start a new conversation": "Розпочати нову розмову",
|
||||
"Start conversation": "Почати розмову",
|
||||
"Stop": "Зупинити",
|
||||
"Summarizing...": "Узагальнення...",
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Chat page', () => {
|
||||
|
||||
const messageContent = page.getByTestId('assistant-message-content');
|
||||
await expect(messageContent).toBeVisible();
|
||||
await expect(messageContent).not.toBeEmpty();
|
||||
await expect(messageContent).not.toBeEmpty();
|
||||
|
||||
// Check history
|
||||
const chatHistoryLink = page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "conversations",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-conversations",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:conversations",
|
||||
|
||||
@@ -11355,9 +11355,9 @@ posthog-js@1.249.3:
|
||||
web-vitals "^4.2.4"
|
||||
|
||||
preact@^10.19.3:
|
||||
version "10.26.6"
|
||||
resolved "https://registry.npmjs.org/preact/-/preact-10.26.6.tgz"
|
||||
integrity sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==
|
||||
version "10.24.0"
|
||||
resolved "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz"
|
||||
integrity sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
+4
-4
@@ -399,10 +399,10 @@ glob-parent@~5.1.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
glob@^10.3.10, glob@^10.3.3:
|
||||
version "10.4.5"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
||||
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
||||
glob@^10.5.0:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
|
||||
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^3.1.2"
|
||||
|
||||
Reference in New Issue
Block a user