Compare commits

...

15 Commits

Author SHA1 Message Date
natoromano f52a27f218 (front) i18n and standardize pdf parsing display
fix: align i18n key

🌐fix: keep filename

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

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

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-26 16:20:25 +01:00
Laurent Paoletti 120b204729 ️(front) chat input performance improvements
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-22 12:19:27 +01:00
charles d9078e75e5 🚨(backend) fix tests
I am removing hard coded datetime.
2026-01-22 11:39:47 +01:00
Quentin BEY 09b003856b 🔒️(node-packages) update fixed CVE packages
Trivy complains about some packages with fixed CVE,
we update them.
2026-01-19 16:09:07 +01:00
Quentin BEY 0b5317a773 🔒️(jaraco) enforce version to fix CVE
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see GHSA-58pv-8j8x-9vj2

The CVE is not actionable, anyway, we want to please
trivy.
2026-01-19 14:38:59 +01:00
Quentin BEY abf61a9556 🔥(chat) consider PDF documents as other kind of documents
We remove the specific management for PDF because it introduces:
 - limitation regarding the LLM we can use
 - bad behavior when uploading huge PDFs
 - more code complexity
while not providing really actionnable improvements.

This commit removes this, to keep a better control over this.
2026-01-19 14:04:32 +01:00
Laurent Paoletti 3e8c5c77d5 (chat) generate and edit conversation title
- Auto-generate title via LLM after reaching user message threshold
- Add title_set_by_user_at field to track user-customized titles
- Skip auto-generation when user has set a custom title
- Stream conversation_metadata event to frontend on title update
- Invalidate React Query cache to refresh conversation list

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-18 23:04:43 +01:00
Laurent Paoletti ddfc86a88f 🐛(back) stream tool responses to prevent too call timeouts
Implement sync/sync utilities that inject
keepalive messages at regular intervals during stream pauses,
preventing proxy timeouts on long-running operations like
document(s) summarization.

Keepalive messages maintain active connections while tools execute,
eliminating forced conversation restarts.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-17 13:50:35 +01:00
qbey e7d76e4477 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-16 12:13:55 +01:00
Quentin BEY fd3399dd66 🔖(patch) bump release to 0.0.11
Changed

- 📦️(front) update react

Fixed

- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
- ⚰️(back) remove dead code and unused files

Removed

- 🔥(chat) remove thinking part from frontend #227
2026-01-16 12:03:05 +01:00
Quentin BEY 13c6499c66 🔥(chat) remove thinking part from frontend
We want to enable the OSS model but seems like it returns
thinking values twice and we don't manage it well...

So we disable the frontend while we still don't know
how to display the thinking stuff.
We could have also cleaned the backend while unused.
2026-01-16 11:43:05 +01:00
Berry den Hartog a0b31e1e61 🐛(front) fix link color in LeftPanelConversationItem component
fix link color component for default theme
2026-01-16 11:43:05 +01:00
Laurent Paoletti daf90cf110 ⚰️(back) remove dead code and unused files
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-16 11:43:05 +01:00
Laurent Paoletti 29f76fe040 🐛(back) fix system prompt compatibility with self-hosted models
Pydantic AI allows setting multiple static and dynamic system prompts
to define conversation context and rules. Previously, these were sent
to the model API as separate messages, which caused compatibility
issues with some self-hosted models (e.g., Gemma3/vLLM).

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

Reference: https://ai.pydantic.dev/agents/#system-prompts
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-16 11:43:05 +01:00
86 changed files with 4584 additions and 1593 deletions
+25 -11
View File
@@ -8,13 +8,32 @@ and this project adheres to
## [Unreleased]
### Fixed
- ⚡️(front) performance improvements on chat input
- 💄(front) i18n and standardize pdf parsing display
### Removed
- 🔥(chat) consider PDF documents as other kind of documents #234
## [0.0.11] - 2026-01-16
### Changed
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
### Fixed
- 🐛(e2e) fix test-e2e-chronium
- 🐛(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
@@ -34,6 +53,7 @@ and this project adheres to
## [0.0.9] - 2025-11-17
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
@@ -41,7 +61,6 @@ and this project adheres to
- 🔊(langfuse) enable tracing with redacted content #162
## [0.0.8] - 2025-11-10
### Fixed
@@ -56,28 +75,24 @@ and this project adheres to
- 🔥(posthog) remove posthog middleware for async mode fix #146
## [0.0.7] - 2025-10-28
### Fixed
- 🚑️(posthog) fix the posthog middleware for async mode #133
## [0.0.6] - 2025-10-28
### Fixed
- 🚑️(stats) fix tracking id in upload event #130
## [0.0.5] - 2025-10-27
### Fixed
- 🚑️(drag-drop) fix the rejection display on Safari #127
## [0.0.4] - 2025-10-27
### Added
@@ -94,14 +109,12 @@ and this project adheres to
- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
## [0.0.3] - 2025-10-21
### Fixed
- 🚑️(web-search) fix missing argument in RAG backend #116
## [0.0.2] - 2025-10-21
### Added
@@ -111,6 +124,7 @@ and this project adheres to
- 📈(posthog) add `sub` field to tracking #95
### Changed
- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
@@ -118,7 +132,6 @@ and this project adheres to
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
## [0.0.1] - 2025-10-19
### Changed
@@ -141,7 +154,7 @@ and this project adheres to
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- ✨(ui) fix retour global ui
- ✨(ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- ✨(ui) delete flex message
@@ -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
View File
@@ -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 && \
+2
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
{
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@ai-sdk/ui-utils": "^1.2.11"
}
}
+13 -18
View File
@@ -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,22 +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, system_prompt=_system_prompt, tools=_tools, **kwargs
)
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]
+26 -5
View File
@@ -16,7 +16,6 @@ from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
@@ -102,10 +101,10 @@ class ConversationAgent(BaseAgent):
if settings.WARNING_MOCK_CONVERSATION_AGENT:
self._model = FunctionModel(stream_function=mocked_agent_model)
@self.system_prompt
@self.instructions
def add_the_date() -> str:
"""
Dynamic system prompt function to add the current date.
Dynamic instruction function to add the current date.
Warning: this will always use the date in the server timezone,
not the user's timezone...
@@ -113,9 +112,9 @@ class ConversationAgent(BaseAgent):
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
return f"Today is {_formatted_date}."
@self.system_prompt
@self.instructions
def enforce_response_language() -> str:
"""Dynamic system prompt function to set the expected language to use."""
"""Dynamic instruction function to set the expected language to use."""
return f"Answer in {get_language_name(language).lower()}." if language else ""
def get_web_search_tool_name(self) -> str | None:
@@ -132,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"
)
+92 -54
View File
@@ -6,6 +6,7 @@ implementation while keeping the *exact* same public API so that no
changes are needed in views.py or tests.
"""
import asyncio
import dataclasses
import functools
import json
@@ -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,10 @@ 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)
logger = logging.getLogger(__name__)
@@ -119,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:
@@ -236,6 +239,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
Parse and store input documents in the conversation's document store.
"""
# Early external document URL rejection
if any(
not document.url.startswith("/media-key/")
for document in documents
@@ -249,8 +253,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
):
raise ValueError("Document URL does not belong to the conversation.")
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
document_store = document_store_backend(self.conversation.collection_id)
if not document_store.collection_id:
# Create a new collection for the conversation
@@ -272,7 +274,9 @@ 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,
@@ -281,7 +285,9 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
# 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,
@@ -420,6 +426,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
],
},
)
try:
await self.parse_input_documents(input_documents)
except Exception as exc: # pylint: disable=broad-except
@@ -457,7 +464,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
if force_web_search:
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def force_web_search_prompt() -> str:
"""Dynamic system prompt function to force web search."""
return (
@@ -468,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
@@ -505,7 +504,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
)
# Inform the model (system-level) that documents are attached and available
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def attached_documents_note() -> str:
return (
"[Internal context] User documents are attached to this conversation. "
@@ -518,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
@@ -556,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,
@@ -717,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,
@@ -727,11 +702,39 @@ 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"
)
# Vercel finish message
yield events_v4.FinishMessagePart(
finish_reason=events_v4.FinishReason.STOP,
@@ -741,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],
@@ -808,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
+171
View File
@@ -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,
),
),
]
+6 -1
View File
@@ -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,
+7
View File
@@ -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):
"""
@@ -27,9 +27,14 @@ def test_build_pydantic_agent_success_no_tools():
"""Test successful agent creation without tools."""
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ()
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[2].__name__ == "enforce_response_language"
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -37,6 +42,7 @@ def test_build_pydantic_agent_success_no_tools():
assert agent._function_toolset.tools == {}
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_build_pydantic_agent_with_tools(settings):
"""Test successful agent creation with tools."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -44,8 +50,14 @@ def test_build_pydantic_agent_with_tools(settings):
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -56,21 +68,23 @@ def test_build_pydantic_agent_with_tools(settings):
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_add_dynamic_system_prompt():
"""
Ensure add_the_date and enforce_response_language system prompt are registered
Ensure add_the_date and enforce_response_language instructions are registered
and returns proper values.
"""
agent = ConversationAgent(model_hrid="default-model")
assert len(agent._system_prompt_functions) == 2
assert len(agent._system_prompt_functions) == 0
assert agent._system_prompt_functions[0].function.__name__ == "add_the_date"
assert agent._system_prompt_functions[0].function() == "Today is Friday 25/07/2025."
assert agent._system_prompt_functions[1].function.__name__ == "enforce_response_language"
assert agent._system_prompt_functions[1].function() == ""
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
agent = ConversationAgent(model_hrid="default-model", language="fr-fr")
assert agent._system_prompt_functions[1].function() == "Answer in french."
assert agent._instructions[2]() == "Answer in french."
def test_agent_get_web_search_tool_name(settings):
@@ -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
@@ -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
@@ -130,6 +131,143 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
{
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
"id": "yuPoOuBkKA4FnKvk",
"parts": [{"text": "Hello", "type": "text"}],
"role": "user",
}
]
assert len(chat_conversation.messages) == 2
assert 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\nAnswer 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
@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 == [
{
@@ -172,27 +310,13 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -255,6 +379,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
assert response_content == "Hello there"
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
@@ -296,29 +429,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -409,11 +528,12 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
# Check the exact structure expected by the AI service
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025."
"\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{
"content": [
{"text": "Hello, what do you see on this picture?", "type": "text"},
@@ -498,27 +618,12 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": [
"Hello, what do you see on this picture?",
@@ -616,11 +721,12 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -678,27 +784,12 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Weather in Paris?"],
"part_kind": "user-prompt",
@@ -737,7 +828,10 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
@@ -829,11 +923,12 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in french.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -891,27 +986,12 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in french.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Weather in Paris?"],
"part_kind": "user-prompt",
@@ -950,7 +1030,10 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"parts": [
{
@@ -1214,27 +1297,11 @@ def test_post_conversation_data_protocol_no_stream(
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are an amazing assistant.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Why the sky is blue?"],
"part_kind": "user-prompt",
@@ -1369,27 +1436,12 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -1420,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,
},
]
@@ -216,7 +216,8 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
@@ -353,53 +354,25 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"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.",
"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.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["What does the document say?"],
"part_kind": "user-prompt",
@@ -439,14 +412,21 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"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."
"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."
),
"kind": "request",
"parts": [
@@ -499,7 +479,8 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
@responses.activate
@respx.mock
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_post_conversation_with_document_upload_feature_disabled( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload_feature_disabled(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
caplog,
mock_openai_stream, # pylint: disable=unused-argument
@@ -552,14 +533,12 @@ def test_post_conversation_with_document_upload_feature_disabled( # pylint: dis
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"From the document, I can see that "\n'
"0:\"it says 'Hello PDF'.\"\n"
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
# This behavior must be improved in the future to inform the user properly
assert "Document upload feature is disabled, ignoring input documents." in caplog.text
@@ -582,6 +561,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
api_client.force_authenticate(user=chat_conversation.owner)
pdf_base64 = base64.b64encode(sample_pdf_content.read()).decode("utf-8")
message = UIMessage(
id="1",
role="user",
@@ -643,7 +623,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":317,"completionTokens":19}}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":287,"completionTokens":19}}\n'
)
# Check that the conversation was updated
@@ -705,52 +685,25 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"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.",
"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."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["Make a summary of this document."],
"part_kind": "user-prompt",
@@ -790,14 +743,21 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"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."
"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."
),
"kind": "request",
"parts": [
@@ -17,7 +17,6 @@ from pydantic_ai.messages import (
DocumentUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -61,7 +60,8 @@ def fixture_sample_document_content():
@responses.activate
@freeze_time()
def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_promt_date,
@@ -120,32 +120,28 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].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=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this document?",
DocumentUrl(
url=presigned_url, # presigned URL for this conversation
media_type="application/pdf",
identifier="sample.pdf",
),
],
timestamp=timezone.now(),
),
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
],
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,
)
]
@@ -189,9 +185,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
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,
@@ -221,38 +215,31 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"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"
"\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": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"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,
@@ -429,7 +416,6 @@ def test_post_conversation_with_remote_document_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -437,6 +423,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"""
chat_conversation_pk = "0be55da5-8eb7-4dad-aa0f-fea454bd5809"
document_url = f"/media-key/{chat_conversation_pk}/sample.pdf"
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
chat_conversation = ChatConversationFactory(
pk=chat_conversation_pk,
owner__language="en-us",
@@ -472,27 +460,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
pydantic_messages=[
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"Today is {formatted_date}.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -555,7 +527,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
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
@@ -564,18 +536,6 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -588,6 +548,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[0].run_id,
),
ModelResponse(
@@ -606,6 +569,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
timestamp=timezone.now(),
)
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[2].run_id,
),
]
@@ -705,27 +671,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -772,7 +722,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
# no run_id here
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
@@ -823,7 +775,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
("data.csv", "text/csv"),
],
)
def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_not_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_ai_agent_service,
@@ -886,27 +839,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
SystemPromptPart(
content=(
"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."
),
timestamp=timezone.now(),
),
SystemPromptPart(
content=(
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -916,14 +848,22 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
),
],
instructions=(
"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."
"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,
)
@@ -999,53 +939,25 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
assert chat_conversation.pydantic_messages == [
{
"instructions": (
"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."
"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."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "[Internal context] User documents are attached to "
"this conversation. Do not request re-upload of "
"documents; consider them already available via the "
"internal store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -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
@@ -919,7 +922,7 @@ def history_conversation_with_tool_fixture():
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages including a tool invocation
conversation = ChatConversationFactory()
conversation = ChatConversationFactory(owner__language="nl-nl")
# Add previous user and assistant messages with tool invocation
conversation.messages = [
@@ -1377,7 +1380,9 @@ def test_post_conversation_with_existing_tool_history(
# Verify the new tool call request is included
assert history_conversation_with_tool.pydantic_messages[8] == {
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -1420,7 +1425,9 @@ def test_post_conversation_with_existing_tool_history(
}
assert history_conversation_with_tool.pydantic_messages[10] == {
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -1569,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
@@ -2,7 +2,7 @@
import uuid
from django.utils import timezone
from django.utils import formats, timezone
import pytest
from dirty_equals import IsUUID
@@ -12,7 +12,6 @@ from pydantic_ai.messages import (
ImageUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -87,22 +86,15 @@ def test_post_conversation_with_local_image_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
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
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(
content="Today is Saturday 18/10/2025.", timestamp=timezone.now()
),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -115,6 +107,8 @@ def test_post_conversation_with_local_image_url(
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -184,27 +178,10 @@ def test_post_conversation_with_local_image_url(
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Today is Saturday 18/10/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -286,11 +263,6 @@ def test_post_conversation_with_local_image_wrong_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -303,6 +275,8 @@ def test_post_conversation_with_local_image_wrong_url(
timestamp=timezone.now(),
),
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -374,11 +348,6 @@ def test_post_conversation_with_remote_image_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -391,6 +360,8 @@ def test_post_conversation_with_remote_image_url(
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -504,27 +475,10 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -587,7 +541,7 @@ def test_post_conversation_with_local_image_url_in_history(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
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
@@ -596,18 +550,6 @@ def test_post_conversation_with_local_image_url_in_history(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this image?",
@@ -619,7 +561,9 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -637,6 +581,8 @@ def test_post_conversation_with_local_image_url_in_history(
)
],
run_id=messages[2].run_id,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
),
]
yield "This is an image of square, very small and nice."
@@ -735,27 +681,10 @@ def test_post_conversation_with_local_image_url_in_history(
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -796,7 +725,8 @@ def test_post_conversation_with_local_image_url_in_history(
},
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
@@ -1,4 +1,4 @@
"""Test the post_stop_steaming view."""
"""Test the post_stop_streaming view."""
from unittest.mock import patch
@@ -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):
@@ -39,7 +39,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
metadata={"sources": {result.url for result in rag_results.data}},
)
@agent.system_prompt
@agent.instructions
def document_rag_instructions() -> str:
"""Dynamic system prompt function to add RAG instructions if any."""
return (
-11
View File
@@ -3,17 +3,6 @@
from pydantic_ai import ModelRetry
class ModelRetryLast(ModelRetry):
"""
Same as ModelRetry but also holds the last retry message to return when all attempts failed.
"""
def __init__(self, message: str, last_retry_message: str):
"""Initialize ModelRetryLast with message and last retry message."""
self.last_retry_message = last_retry_message
super().__init__(message)
class ModelCannotRetry(ModelRetry):
"""
Exception to raise when a tool function cannot be retried.
@@ -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.
"""
+9 -10
View File
@@ -26,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__)
@@ -188,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
@@ -221,7 +221,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
url_path="stop-streaming",
url_name="stop-streaming",
)
def post_stop_steaming(self, request, pk): # pylint: disable=unused-argument
def post_stop_streaming(self, request, pk): # pylint: disable=unused-argument
"""Handle POST requests to stop streaming the chat conversation.
This action will put a poison pill in the redis cache to stop any ongoing streaming.
@@ -371,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
+11 -1
View File
@@ -911,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,
@@ -919,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):
@@ -1131,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"]
-14
View File
@@ -1,12 +1,9 @@
"""Conversations core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework import exceptions as drf_exceptions
from rest_framework import views as drf_views
from rest_framework.decorators import api_view
from rest_framework.response import Response
def exception_handler(exc, context):
@@ -28,14 +25,3 @@ def exception_handler(exc, context):
exc = drf_exceptions.ValidationError(detail=detail)
return drf_views.exception_handler(exc, context)
# pylint: disable=unused-argument
@api_view(["GET"])
def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
-20
View File
@@ -20,23 +20,3 @@ class UserSerializer(serializers.ModelSerializer):
"sub",
]
read_only_fields = ["id", "email", "full_name", "short_name", "sub"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -1,52 +0,0 @@
"""Custom authentication classes for the Conversations core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
-25
View File
@@ -1,25 +0,0 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers
class JSONField(serializers.Field):
"""
A custom field for handling JSON data.
"""
def to_representation(self, value):
"""
Convert the JSON string to a Python dictionary for serialization.
"""
return value
def to_internal_value(self, data):
"""
Convert the Python dictionary to a JSON string for deserialization.
"""
if data is None:
return None
return json.dumps(data)
-22
View File
@@ -2,31 +2,9 @@
import unicodedata
import django_filters
def remove_accents(value):
"""Remove accents from a string (vélo -> velo)."""
return "".join(
c for c in unicodedata.normalize("NFD", value) if unicodedata.category(c) != "Mn"
)
class AccentInsensitiveCharFilter(django_filters.CharFilter):
"""
A custom CharFilter that filters on the accent-insensitive value searched.
"""
def filter(self, qs, value):
"""
Apply the filter to the queryset using the unaccented version of the field.
Args:
qs: The queryset to filter.
value: The value to search for in the unaccented field.
Returns:
A filtered queryset.
"""
if value:
value = remove_accents(value)
return super().filter(qs, value)
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -1,58 +0,0 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
@@ -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"
+1
View File
@@ -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",
+2 -1
View File
@@ -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';
@@ -18,7 +18,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
const { t } = useTranslation();
if (toolInvocation.toolName === 'document_parsing') {
if (toolInvocation.state === 'partial-call') {
if (
toolInvocation.state === 'partial-call' ||
toolInvocation.state === 'result'
) {
return null;
}
@@ -32,24 +35,23 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
)
? documents.map((doc) => doc.identifier)
: [];
return (
<Box
as="pre"
$direction="row"
$align="center"
$gap="6px"
key={toolInvocation.toolCallId}
$background="var(--c--theme--colors--greyscale-100)"
$color="var(--c--theme--colors--greyscale-500)"
$padding={{ all: 'sm' }}
$radius="8px"
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
>
{toolInvocation.state === 'result' ? (
<Text>{`Parsing done: ${documentIdentifiers.join(', ')}`}</Text>
) : (
<Box $direction="row" $gap="1rem" $align="center">
<Loader />
<Text>{`Parsing documents: ${documentIdentifiers.join(', ')} ...`}</Text>
</Box>
)}
<Loader />
<Text $variation="600" $size="md">
{t('Extracting documents: {{documents}} ...', {
documents: documentIdentifiers.join(', '),
})}
</Text>
</Box>
);
}
@@ -0,0 +1,25 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
const WELCOME_MARGIN = {
horizontal: 'base',
bottom: 'md',
top: '-105px',
} as const;
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
export const WelcomeMessage = memo(() => {
const { t } = useTranslation();
return (
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
{t('What is on your mind?')}
</Text>
</Box>
);
});
WelcomeMessage.displayName = 'WelcomeMessage';
@@ -0,0 +1,190 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { InputChat } from '../InputChat';
// Mock stores and hooks
jest.mock('@/stores', () => ({
useResponsiveStore: () => ({
isDesktop: true,
isMobile: false,
}),
}));
jest.mock('@/core', () => ({
useConfig: () => ({
data: {
FEATURE_FLAGS: {
'web-search': 'enabled',
'document-upload': 'enabled',
},
chat_upload_accept: '.pdf,.txt',
},
}),
FeatureFlagState: {
ENABLED: 'enabled',
DISABLED: 'disabled',
},
}));
jest.mock('@/libs', () => ({
useAnalytics: () => ({
isFeatureFlagActivated: jest.fn(() => true),
}),
}));
jest.mock('@/components/ToastProvider', () => ({
useToast: () => ({
showToast: jest.fn(),
}),
}));
jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({
useFileDragDrop: () => ({
isDragActive: false,
}),
}));
jest.mock('@/features/chat/hooks/useFileUrls', () => ({
useFileUrls: () => new Map(),
}));
// Mock child components
jest.mock('../InputChatAction', () => ({
InputChatActions: () => <div data-testid="input-chat-actions">Actions</div>,
}));
jest.mock('../SuggestionCarousel', () => ({
SuggestionCarousel: () => (
<div data-testid="suggestion-carousel">Suggestions</div>
),
}));
jest.mock('../WelcomeMessage', () => ({
WelcomeMessage: () => <div data-testid="welcome-message">Welcome</div>,
}));
jest.mock('../AttachmentList', () => ({
AttachmentList: () => <div data-testid="attachment-list">Attachments</div>,
}));
jest.mock('../ScrollDown', () => ({
ScrollDown: () => <div data-testid="scroll-down">Scroll Down</div>,
}));
jest.mock('../../assets/files.svg', () => () => (
<svg data-testid="files-icon" />
));
const defaultProps = {
messagesLength: 0,
input: '',
handleInputChange: jest.fn(),
handleSubmit: jest.fn(),
status: 'ready' as const,
files: null,
setFiles: jest.fn(),
};
describe('InputChat', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the textarea', () => {
render(<InputChat {...defaultProps} />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeInTheDocument();
});
it('should render welcome message when messagesLength is 0', () => {
render(<InputChat {...defaultProps} messagesLength={0} />);
expect(screen.getByTestId('welcome-message')).toBeInTheDocument();
});
it('should not render welcome message when messagesLength > 0', () => {
render(<InputChat {...defaultProps} messagesLength={1} />);
expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument();
});
it('should render suggestion carousel when input is empty', () => {
render(<InputChat {...defaultProps} input="" />);
expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument();
});
it('should not render suggestion carousel when input has content', () => {
render(<InputChat {...defaultProps} input="Hello" />);
expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument();
});
it('should render input chat actions', () => {
render(<InputChat {...defaultProps} />);
expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument();
});
it('should call handleInputChange when typing', async () => {
const user = userEvent.setup();
const handleInputChange = jest.fn();
render(
<InputChat {...defaultProps} handleInputChange={handleInputChange} />,
);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, 'Hello');
expect(handleInputChange).toHaveBeenCalled();
});
it('should disable textarea when status is not ready', () => {
render(<InputChat {...defaultProps} status="streaming" />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeDisabled();
});
it('should disable textarea when isUploadingFiles is true', () => {
render(<InputChat {...defaultProps} isUploadingFiles={true} />);
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
).toBeDisabled();
});
it('should submit form when pressing Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, '{Enter}');
expect(handleSubmit).toHaveBeenCalled();
});
it('should not submit form when pressing Shift+Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
});
await user.type(textarea, '{Shift>}{Enter}{/Shift}');
expect(handleSubmit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,192 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { InputChatActions } from '../InputChatAction';
jest.mock('../ModelSelector', () => ({
ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => (
<button onClick={onModelSelect} data-testid="model-selector">
Model Selector
</button>
),
}));
jest.mock('../SendButton', () => ({
SendButton: ({
onClick,
disabled,
}: {
onClick: () => void;
disabled: boolean;
}) => (
<button onClick={onClick} disabled={disabled} data-testid="send-button">
Send
</button>
),
}));
const defaultProps = {
fileUploadEnabled: true,
webSearchEnabled: true,
isUploadingFiles: false,
isMobile: false,
forceWebSearch: false,
onAttachClick: jest.fn(),
selectedModel: null,
status: null,
inputHasContent: true,
};
describe('InputChatActions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render attach file button', () => {
render(<InputChatActions {...defaultProps} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeInTheDocument();
expect(screen.getByText('Attach file')).toBeInTheDocument();
});
it('should call onAttachClick when attach button is clicked', async () => {
const user = userEvent.setup();
const onAttachClick = jest.fn();
render(
<InputChatActions {...defaultProps} onAttachClick={onAttachClick} />,
);
await user.click(screen.getByRole('button', { name: 'Add attach file' }));
expect(onAttachClick).toHaveBeenCalledTimes(1);
});
it('should disable attach button when fileUploadEnabled is false', () => {
render(<InputChatActions {...defaultProps} fileUploadEnabled={false} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeDisabled();
});
it('should disable attach button when isUploadingFiles is true', () => {
render(<InputChatActions {...defaultProps} isUploadingFiles={true} />);
expect(
screen.getByRole('button', { name: 'Add attach file' }),
).toBeDisabled();
});
it('should not show attach text on mobile', () => {
render(<InputChatActions {...defaultProps} isMobile={true} />);
expect(screen.queryByText('Attach file')).not.toBeInTheDocument();
});
it('should render web search button when onWebSearchToggle is provided', () => {
const onWebSearchToggle = jest.fn();
render(
<InputChatActions
{...defaultProps}
onWebSearchToggle={onWebSearchToggle}
/>,
);
expect(
screen.getByRole('button', { name: 'Research on the web' }),
).toBeInTheDocument();
});
it('should not render web search button when onWebSearchToggle is undefined', () => {
render(
<InputChatActions {...defaultProps} onWebSearchToggle={undefined} />,
);
expect(
screen.queryByRole('button', { name: 'Research on the web' }),
).not.toBeInTheDocument();
});
it('should call onWebSearchToggle when web search button is clicked', async () => {
const user = userEvent.setup();
const onWebSearchToggle = jest.fn();
render(
<InputChatActions
{...defaultProps}
onWebSearchToggle={onWebSearchToggle}
/>,
);
await user.click(
screen.getByRole('button', { name: 'Research on the web' }),
);
expect(onWebSearchToggle).toHaveBeenCalledTimes(1);
});
it('should disable web search button when webSearchEnabled is false', () => {
render(
<InputChatActions
{...defaultProps}
webSearchEnabled={false}
onWebSearchToggle={jest.fn()}
/>,
);
expect(
screen.getByRole('button', { name: 'Research on the web' }),
).toBeDisabled();
});
it('should render model selector when onModelSelect is provided', () => {
const onModelSelect = jest.fn();
render(
<InputChatActions {...defaultProps} onModelSelect={onModelSelect} />,
);
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
});
it('should not render model selector when onModelSelect is undefined', () => {
render(<InputChatActions {...defaultProps} onModelSelect={undefined} />);
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument();
});
it('should render send button', () => {
render(<InputChatActions {...defaultProps} />);
expect(screen.getByTestId('send-button')).toBeInTheDocument();
});
it('should show "Web" text on mobile when forceWebSearch is active', () => {
render(
<InputChatActions
{...defaultProps}
isMobile={true}
forceWebSearch={true}
onWebSearchToggle={jest.fn()}
/>,
);
expect(screen.getByText('Web')).toBeInTheDocument();
});
it('should show "Research on the web" text on desktop when forceWebSearch is active', () => {
render(
<InputChatActions
{...defaultProps}
isMobile={false}
forceWebSearch={true}
onWebSearchToggle={jest.fn()}
/>,
);
expect(screen.getByText('Research on the web')).toBeInTheDocument();
expect(screen.queryByText('Web')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { SuggestionCarousel } from '../SuggestionCarousel';
describe('SuggestionCarousel', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should render all suggestions', () => {
render(<SuggestionCarousel messagesLength={0} />);
// First suggestion appears twice (duplicated for looping animation)
expect(screen.getAllByText('Ask a question')).toHaveLength(2);
expect(
screen.getByText('Turn this list into bullet points'),
).toBeInTheDocument();
expect(
screen.getByText('Write a short product description'),
).toBeInTheDocument();
expect(screen.getByText('Find recent news about...')).toBeInTheDocument();
});
it('should start the interval when messagesLength is 0', () => {
const setIntervalSpy = jest.spyOn(global, 'setInterval');
render(<SuggestionCarousel messagesLength={0} />);
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
setIntervalSpy.mockRestore();
});
it('should not start interval when messagesLength is greater than 0', () => {
const setIntervalSpy = jest.spyOn(global, 'setInterval');
render(<SuggestionCarousel messagesLength={1} />);
expect(setIntervalSpy).not.toHaveBeenCalled();
setIntervalSpy.mockRestore();
});
it('should clear interval on unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
const { unmount } = render(<SuggestionCarousel messagesLength={0} />);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { WelcomeMessage } from '../WelcomeMessage';
describe('WelcomeMessage', () => {
it('should render the welcome message', () => {
render(<WelcomeMessage />);
expect(
screen.getByRole('heading', { level: 2, name: 'What is on your mind?' }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,257 @@
import { act, renderHook } from '@testing-library/react';
import { useFileDragDrop } from '../useFileDragDrop';
const createFile = (name: string, type: string): File => {
return new File(['content'], name, { type });
};
const createDragEvent = (
type: string,
files?: File[],
relatedTarget?: EventTarget | null,
): DragEvent => {
const event = new Event(type, { bubbles: true }) as DragEvent;
const dataTransfer = {
types: files ? ['Files'] : [],
files: files
? {
length: files.length,
item: (i: number) => files[i],
[Symbol.iterator]: function* () {
for (const file of files) {
yield file;
}
},
}
: { length: 0 },
} as unknown as DataTransfer;
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget });
Object.defineProperty(event, 'preventDefault', { value: jest.fn() });
return event;
};
describe('useFileDragDrop', () => {
const defaultProps = {
enabled: true,
isFileAccepted: jest.fn(() => true),
onFilesAccepted: jest.fn(),
onFilesRejected: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize with isDragActive as false', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
expect(result.current.isDragActive).toBe(false);
});
it('should set isDragActive to true on dragenter with files', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
});
it('should not set isDragActive on dragenter without files', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(createDragEvent('dragenter'));
});
expect(result.current.isDragActive).toBe(false);
});
it('should set isDragActive to false on dragleave when leaving window', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(createDragEvent('dragleave', [], null));
});
expect(result.current.isDragActive).toBe(false);
});
it('should not set isDragActive to false on dragleave when moving between elements', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(createDragEvent('dragleave', [], document.body));
});
expect(result.current.isDragActive).toBe(true);
});
it('should set isDragActive to false on drop', () => {
const { result } = renderHook(() => useFileDragDrop(defaultProps));
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(false);
});
it('should call onFilesAccepted with accepted files on drop', () => {
const onFilesAccepted = jest.fn();
const file = createFile('test.txt', 'text/plain');
renderHook(() =>
useFileDragDrop({
...defaultProps,
onFilesAccepted,
}),
);
act(() => {
window.dispatchEvent(createDragEvent('drop', [file]));
});
expect(onFilesAccepted).toHaveBeenCalledWith([file]);
});
it('should call onFilesRejected with rejected file names', () => {
const onFilesRejected = jest.fn();
const isFileAccepted = jest.fn(() => false);
renderHook(() =>
useFileDragDrop({
...defaultProps,
isFileAccepted,
onFilesRejected,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [createFile('test.exe', 'application/exe')]),
);
});
expect(onFilesRejected).toHaveBeenCalledWith(['test.exe']);
});
it('should separate accepted and rejected files', () => {
const onFilesAccepted = jest.fn();
const onFilesRejected = jest.fn();
const isFileAccepted = jest.fn((file: File) => file.type === 'text/plain');
const acceptedFile = createFile('good.txt', 'text/plain');
const rejectedFile = createFile('bad.exe', 'application/exe');
renderHook(() =>
useFileDragDrop({
...defaultProps,
isFileAccepted,
onFilesAccepted,
onFilesRejected,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('drop', [acceptedFile, rejectedFile]),
);
});
expect(onFilesAccepted).toHaveBeenCalledWith([acceptedFile]);
expect(onFilesRejected).toHaveBeenCalledWith(['bad.exe']);
});
it('should ignore drag events when disabled', () => {
const { result } = renderHook(() =>
useFileDragDrop({
...defaultProps,
enabled: false,
}),
);
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(false);
});
it('should reset isDragActive when disabled changes to false', () => {
const { result, rerender } = renderHook(
({ enabled }) => useFileDragDrop({ ...defaultProps, enabled }),
{ initialProps: { enabled: true } },
);
act(() => {
window.dispatchEvent(
createDragEvent('dragenter', [createFile('test.txt', 'text/plain')]),
);
});
expect(result.current.isDragActive).toBe(true);
rerender({ enabled: false });
expect(result.current.isDragActive).toBe(false);
});
it('should clean up event listeners on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useFileDragDrop(defaultProps));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragenter',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragleave',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'dragover',
expect.any(Function),
);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'drop',
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});
});
@@ -0,0 +1,161 @@
import { renderHook } from '@testing-library/react';
import { useFileUrls } from '../useFileUrls';
const createFile = (
name: string,
size: number = 100,
lastModified: number = 1234567890,
): File => {
const file = new File(['x'.repeat(size)], name, { type: 'text/plain' });
Object.defineProperty(file, 'size', { value: size });
Object.defineProperty(file, 'lastModified', { value: lastModified });
return file;
};
const createFileList = (files: File[]): FileList => {
return {
length: files.length,
item: (index: number) => files[index] || null,
[Symbol.iterator]: function* () {
for (const file of files) {
yield file;
}
},
...files.reduce(
(acc, file, index) => {
acc[index] = file;
return acc;
},
{} as Record<number, File>,
),
} as FileList;
};
describe('useFileUrls', () => {
let urlCounter: number;
const mockCreateObjectURL = jest.fn();
const mockRevokeObjectURL = jest.fn();
beforeEach(() => {
urlCounter = 0;
mockCreateObjectURL.mockImplementation(
() => `blob:mock-url-${++urlCounter}`,
);
mockRevokeObjectURL.mockImplementation(() => {});
global.URL.createObjectURL = mockCreateObjectURL;
global.URL.revokeObjectURL = mockRevokeObjectURL;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return an empty Map when files is null', () => {
const { result } = renderHook(() => useFileUrls(null));
expect(result.current).toEqual(new Map());
});
it('should create URLs for files', () => {
const file = createFile('test.txt');
const fileList = createFileList([file]);
const { result } = renderHook(() => useFileUrls(fileList));
expect(mockCreateObjectURL).toHaveBeenCalledWith(file);
expect(result.current.size).toBe(1);
expect(result.current.get('test.txt-100-1234567890')).toBe(
'blob:mock-url-1',
);
});
it('should create URLs for multiple files', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const fileList = createFileList([file1, file2]);
const { result } = renderHook(() => useFileUrls(fileList));
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
expect(result.current.size).toBe(2);
expect(result.current.get('file1.txt-100-1000')).toBe('blob:mock-url-1');
expect(result.current.get('file2.txt-200-2000')).toBe('blob:mock-url-2');
});
it('should reuse URLs for unchanged files', () => {
const file = createFile('test.txt');
const fileList1 = createFileList([file]);
const fileList2 = createFileList([file]);
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: fileList1 },
});
const initialUrl = result.current.get('test.txt-100-1234567890');
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
rerender({ files: fileList2 });
expect(result.current.get('test.txt-100-1234567890')).toBe(initialUrl);
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
});
it('should revoke URLs when files are removed', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1, file2]) },
});
rerender({ files: createFileList([file1]) });
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
});
it('should revoke all URLs when files becomes null', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { rerender } = renderHook<
Map<string, string>,
{ files: FileList | null }
>(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1, file2]) },
});
rerender({ files: null });
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-2');
});
it('should clean up all URLs on unmount', () => {
const file = createFile('test.txt');
const fileList = createFileList([file]);
const { unmount } = renderHook(() => useFileUrls(fileList));
unmount();
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
});
it('should create new URL when adding a file', () => {
const file1 = createFile('file1.txt', 100, 1000);
const file2 = createFile('file2.txt', 200, 2000);
const { result, rerender } = renderHook(({ files }) => useFileUrls(files), {
initialProps: { files: createFileList([file1]) },
});
expect(result.current.size).toBe(1);
rerender({ files: createFileList([file1, file2]) });
expect(result.current.size).toBe(2);
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useState } from 'react';
interface UseFileDragDropOptions {
/**
* Whether file upload feature is enabled.
* When false, drag/drop events are ignored.
*/
enabled: boolean;
/**
* Callback to validate if a file type is accepted.
* @param file - File to validate
* @returns true if file should be accepted
*/
isFileAccepted: (file: File) => boolean;
/**
* Callback when files are dropped and validated.
* Receives only the accepted files.
*/
onFilesAccepted: (files: File[]) => void;
/**
* Callback when some files are rejected due to type.
* Useful for showing error toasts.
*/
onFilesRejected?: (fileNames: string[]) => void;
}
interface UseFileDragDropReturn {
/**
* Whether a drag operation is currently active over the window.
* Use this to show a drop overlay.
*/
isDragActive: boolean;
}
/**
* Custom hook to handle file drag and drop functionality.
*
* Purpose:
* - Manages window-level drag/drop events for file uploads
* - Tracks drag state to show/hide drop overlay
* - Validates files against accepted types
* - Separates accepted and rejected files
* - Cleans up event listeners on unmount
*
* Why window-level events:
* - Allows dropping files anywhere on the page
* - Better UX than requiring precise drop zone targeting
* - Handles edge cases like dragging over child elements
*
* @example
* const { isDragActive } = useFileDragDrop({
* enabled: fileUploadEnabled,
* isFileAccepted: (file) => file.type.startsWith('image/'),
* onFilesAccepted: (files) => setFiles(files),
* onFilesRejected: (names) => showError(`Rejected: ${names.join(', ')}`),
* });
*/
export const useFileDragDrop = ({
enabled,
isFileAccepted,
onFilesAccepted,
onFilesRejected,
}: UseFileDragDropOptions): UseFileDragDropReturn => {
const [isDragActive, setIsDragActive] = useState(false);
// Process dropped files: separate accepted from rejected
const processFiles = useCallback(
(fileList: FileList) => {
const accepted: File[] = [];
const rejectedNames: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
accepted.push(file);
} else {
rejectedNames.push(file.name);
}
});
// Notify about rejected files first (for error toasts)
if (rejectedNames.length > 0 && onFilesRejected) {
onFilesRejected(rejectedNames);
}
// Then handle accepted files
if (accepted.length > 0) {
onFilesAccepted(accepted);
}
},
[isFileAccepted, onFilesAccepted, onFilesRejected],
);
// Window-level drag/drop handlers
useEffect(() => {
if (!enabled) {
setIsDragActive(false);
return;
}
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
// Only activate for file drags (not text selections, etc.)
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// Only deactivate when leaving the window entirely
// relatedTarget is null when cursor leaves the document
if (!e.relatedTarget) {
setIsDragActive(false);
}
};
const handleDragOver = (e: DragEvent) => {
// Required to allow drop
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
processFiles(droppedFiles);
}
};
// Attach to window for full-page drag/drop support
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [enabled, processFiles]);
return {
isDragActive,
};
};
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
/**
* Manages object URLs for file previews with proper memory cleanup.
*
* - Reuses URLs for unchanged files across re-renders
* - Revokes URLs when files are removed
* - Cleans up all URLs on unmount
*
* @param files - FileList from input or drag/drop, or null
* @returns Map of file keys (`${name}-${size}-${lastModified}`) to object URLs
*/
export const useFileUrls = (files: FileList | null) => {
const [urlMap, setUrlMap] = useState<Map<string, string>>(new Map());
const urlMapRef = useRef<Map<string, string>>(new Map());
// Keep ref in sync with latest state
useEffect(() => {
urlMapRef.current = urlMap;
});
// Handle URLs when files change
useEffect(() => {
const prevMap = urlMapRef.current;
if (!files) {
prevMap.forEach((url) => URL.revokeObjectURL(url));
setUrlMap(new Map());
return;
}
const newMap = new Map<string, string>();
const currentFileKeys = new Set(
Array.from(files).map((f) => getFileKey(f)),
);
// Reuse or revoke inline
prevMap.forEach((url, key) => {
if (currentFileKeys.has(key)) {
newMap.set(key, url);
} else {
URL.revokeObjectURL(url);
}
});
// Create URLs for new files
Array.from(files).forEach((file) => {
const key = getFileKey(file);
if (!newMap.has(key)) {
newMap.set(key, URL.createObjectURL(file));
}
});
setUrlMap(newMap);
}, [files]);
// Unmount-only cleanup
useEffect(() => {
return () => {
urlMapRef.current.forEach((url) => URL.revokeObjectURL(url));
};
}, []);
return urlMap;
};
/**
* Generates a unique key for a file based on its properties.
* Used to track file identity across renders.
*/
const getFileKey = (file: File): string => {
return `${file.name}-${file.size}-${file.lastModified}`;
};
@@ -1,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}
/>
)}
@@ -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>
);
};
);
},
);
@@ -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>
@@ -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>
);
};
@@ -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>
);
};
});
@@ -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();
});
});
@@ -0,0 +1,128 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { LeftPanelConversationItem } from '../LeftPanelConversationItem';
const mockSetPanelOpen = jest.fn();
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
jest.mock('@/stores', () => ({
useResponsiveStore: jest.fn((selector) => selector({ isDesktop: true })),
}));
jest.mock('@/features/chat/stores/useChatPreferencesStore', () => ({
useChatPreferencesStore: jest.fn((selector) =>
selector({ setPanelOpen: mockSetPanelOpen }),
),
}));
jest.mock('../SimpleConversationItem', () => ({
SimpleConversationItem: ({
conversation,
}: {
conversation: { title: string };
}) => <div data-testid="simple-conversation-item">{conversation.title}</div>,
}));
jest.mock('../ConversationItemActions', () => ({
ConversationItemActions: ({
conversation,
}: {
conversation: { id: string };
}) => (
<div data-testid={`conversation-actions-${conversation.id}`}>Actions</div>
),
}));
const mockConversation = {
id: 'conv-123',
title: 'Test Conversation',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
messages: [],
};
describe('LeftPanelConversationItem', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the conversation item with link', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', `/chat/${mockConversation.id}/`);
});
it('should render SimpleConversationItem', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
expect(screen.getByTestId('simple-conversation-item')).toBeInTheDocument();
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
});
it('should render ConversationItemActions', () => {
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
expect(
screen.getByTestId(`conversation-actions-${mockConversation.id}`),
).toBeInTheDocument();
});
it('should not close panel on click when on desktop', async () => {
const user = userEvent.setup();
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
await user.click(screen.getByRole('link'));
expect(mockSetPanelOpen).not.toHaveBeenCalled();
});
it('should close panel on click when on mobile', async () => {
const { useResponsiveStore } = jest.requireMock('@/stores');
useResponsiveStore.mockImplementation(
(selector: (state: { isDesktop: boolean }) => boolean) =>
selector({ isDesktop: false }),
);
const user = userEvent.setup();
render(
<LeftPanelConversationItem
conversation={mockConversation}
isCurrentConversation={false}
/>,
);
await user.click(screen.getByRole('link'));
expect(mockSetPanelOpen).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,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');
});
});
@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import '@/i18n/initI18n';
import { AppWrapper } from '@/tests/utils';
import { SimpleConversationItem } from '../SimpleConversationItem';
jest.mock('../../assets/bubble-bold.svg', () => {
return function BubbleIcon({ color, ...props }: { color: string }) {
return <svg data-testid="bubble-icon" data-color={color} {...props} />;
};
});
const mockConversation = {
id: 'conv-123',
title: 'Test Conversation',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
messages: [],
};
const renderWithWrapper = (ui: React.ReactElement) => {
return render(ui, { wrapper: AppWrapper });
};
describe('SimpleConversationItem', () => {
it('should render conversation title', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByText('Test Conversation')).toBeInTheDocument();
});
it('should render bubble icon', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByTestId('bubble-icon')).toBeInTheDocument();
});
it('should have accessible label for bubble icon', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByLabelText('Simple chat icon')).toBeInTheDocument();
});
it('should display "Untitled conversation" when title is empty', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{ ...mockConversation, title: '' }}
/>,
);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
it('should display "Untitled conversation" when title is undefined', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{
...mockConversation,
title: undefined as unknown as string,
}}
/>,
);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
it('should have aria-label with the title', () => {
renderWithWrapper(
<SimpleConversationItem conversation={mockConversation} />,
);
expect(screen.getByLabelText('Test Conversation')).toBeInTheDocument();
});
it('should have aria-label with "Untitled conversation" when title is empty', () => {
renderWithWrapper(
<SimpleConversationItem
conversation={{ ...mockConversation, title: '' }}
/>,
);
expect(screen.getByLabelText('Untitled conversation')).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { act, renderHook } from '@testing-library/react';
import { useOwnModal } from '../useModalHook';
describe('useOwnModal', () => {
it('should initialize with isOpen as false by default', () => {
const { result } = renderHook(() => useOwnModal());
expect(result.current.isOpen).toBe(false);
});
it('should initialize with isOpen as true when initialState is true', () => {
const { result } = renderHook(() => useOwnModal(true));
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to true when open is called', () => {
const { result } = renderHook(() => useOwnModal());
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to false when close is called', () => {
const { result } = renderHook(() => useOwnModal(true));
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
});
it('should maintain stable callback references across rerenders', () => {
const { result, rerender } = renderHook(() => useOwnModal());
const initialOpen = result.current.open;
const initialClose = result.current.close;
rerender();
expect(result.current.open).toBe(initialOpen);
expect(result.current.close).toBe(initialClose);
});
it('should handle multiple open/close cycles', () => {
const { result } = renderHook(() => useOwnModal());
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
});
});
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react';
interface UseModalReturn {
isOpen: boolean;
open: () => void;
close: () => void;
}
/**
* Manages modal open/close state with stable callback references.
*
* @param initialState - Initial open state (default: false)
* @returns `isOpen` state and stable `open`/`close` callbacks safe for dependency arrays
*
*/
export const useOwnModal = (initialState = false): UseModalReturn => {
const [isOpen, setIsOpen] = useState(initialState);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
return { isOpen, open, close };
};
@@ -1,2 +1 @@
export * from './useDate';
export * from './useClipboard';
@@ -1,16 +0,0 @@
import { useEffect } from 'react';
export const useCmdK = (callback: () => void) => {
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
callback();
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [callback]);
};
@@ -1,25 +0,0 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useTranslation } from 'react-i18next';
const formatDefault: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
export const useDate = () => {
const { i18n } = useTranslation();
const formatDate = (
date: string,
format: DateTimeFormatOptions = formatDefault,
): string => {
return DateTime.fromISO(date)
.setLocale(i18n.language)
.toLocaleString(format);
};
return { formatDate };
};
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:conversations",
+3 -3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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"