(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>
This commit is contained in:
Laurent Paoletti
2025-12-29 09:13:59 +01:00
parent ddfc86a88f
commit 3e8c5c77d5
22 changed files with 1386 additions and 26 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
### Changed
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
### Fixed
+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
+12 -15
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,20 +173,18 @@ class BaseAgent(Agent):
# and pydantic_ai.models.infer_model()
_model_instance = self.configuration.model_name
_system_prompt = self.configuration.system_prompt
_base_toolset = (
[
FunctionToolset(
tools=[
get_pydantic_tools_by_name(tool_name)
for tool_name in self.configuration.tools
]
)
]
if self.configuration.tools
else None
)
_system_prompt = self.get_system_prompt()
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
_tools = self.get_tools()
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
def get_system_prompt(self) -> str | None:
"""Override this method to customize the system prompt."""
return self.configuration.system_prompt
def get_tools(self) -> list | None:
"""Override this method to customize tools."""
if not self.configuration.tools:
return []
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
+22
View File
@@ -131,3 +131,25 @@ class ConversationAgent(BaseAgent):
if tool.name.startswith("web_search_"):
return tool.name
return None
@dataclasses.dataclass(init=False)
class TitleGenerationAgent(BaseAgent):
"""Agent that generates concise, descriptive titles for conversations."""
def __init__(self, **kwargs):
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=str,
**kwargs,
)
def get_tools(self):
return []
def get_system_prompt(self):
return (
"You are a title generator. Your task is to create concise, descriptive titles "
"that accurately summarize conversation content and help the user quickly identify the "
"conversation.\n\n"
)
+69 -5
View File
@@ -55,7 +55,7 @@ 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,
@@ -720,8 +720,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
await self._agent_stop_streaming(force_cache_check=True)
# Persist conversation
await sync_to_async(self._update_conversation)(
# Prepare conversation update (save deferred until after potential title generation)
await sync_to_async(self._prepare_update_conversation)(
final_output=run.result.new_messages(),
usage=usage,
final_output_from_tool=_final_output_from_tool,
@@ -730,6 +730,35 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
image_key_mapping=image_key_mapping or None,
)
generated_title = None
# Auto-generate title after N user messages if not manually set
user_messages_count = sum(1 for msg in self.conversation.messages if msg.role == "user")
should_generate_title = (
user_messages_count == settings.AUTO_TITLE_AFTER_USER_MESSAGES
and not self.conversation.title_set_by_user_at
)
if should_generate_title:
if generated_title := await self._generate_title():
self.conversation.title = generated_title
# Persist conversation (including any generated title)
await sync_to_async(self.conversation.save)()
# Notify frontend about the title update
if generated_title:
yield events_v4.DataPart(
data=[
{
"type": "conversation_metadata",
"conversationId": str(self.conversation.pk),
"title": generated_title,
}
]
)
if self._langfuse_available:
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
@@ -743,7 +772,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
),
)
def _update_conversation( # noqa: PLR0913
def _prepare_update_conversation( # noqa: PLR0913
self,
*,
final_output: List[ModelRequest | ModelMessage],
@@ -810,4 +839,39 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
ModelMessagesTypeAdapter.dump_json(final_output).decode("utf-8")
)
self.conversation.save()
async def _generate_title(self) -> str | None:
"""Generate a title for the conversation using LLM based on first messages."""
# Build context from messages
# Note: We intentionally use only msg.content for title generation.
# Parts containing tool invocations or reasoning are excluded as they
# don't contribute to a meaningful context here
context = "\n".join(
f"{msg.role}: {(msg.content or '')[:300]}" # Limit content length per message
for msg in self.conversation.messages
if msg.content
)
language = self.language or settings.LANGUAGE_CODE
prompt = (
"Generate a concise title (3-5 words, max 100 characters) for this conversation.\n\n"
"Requirements:\n"
"- Capture the main topic or user intent\n"
"- The title must be a simple string, no markdown\n"
"- Help the user quickly identify the conversation\n"
f"- Match the language of the user messages (default: {language})\n"
"- Avoid the word 'summary' unless explicitly requested\n\n"
"Output: Title text only, no quotes, labels, or explanation.\n\n"
f"Conversation:\n{context}"
)
try:
agent = TitleGenerationAgent()
result = await agent.run(prompt)
title = (result.output or "").strip()[:100] # Enforce max length (conversation.title)
logger.info("Generated title for conversation %s: %s", self.conversation.pk, title)
return title if title else None
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
logger.warning(
"Failed to generate title for conversation %s: %s", self.conversation.pk, exc
)
return None
@@ -0,0 +1,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):
"""
@@ -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
@@ -93,6 +93,77 @@ def fixture_mock_openai_stream_slow():
return _create_mock_openai_route(with_delays=True, delay_seconds=1.0)
@pytest.fixture(name="mock_openai_stream_with_title_generation")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream_with_title_generation():
"""
Fixture to mock the OpenAI stream response.
This fixture handles two different types of API calls made during a single request:
1. **Conversation (streaming)**: The main chat uses `stream=True` to get real-time
token-by-token responses. The API returns chunked data like:
`data: {"choices": [{"delta": {"content": "Hello"}}]}`
2. **Title generation (non-streaming)**: After the conversation, the backend calls
the API again with `stream=False` to generate a title. This returns a standard
JSON response with the complete message.
The `handle_request` function inspects each incoming request's body to determine
which type of response to return:
- `{"stream": true, ...}` → SSE streaming response
- `{"stream": false, ...}` → JSON response with generated title
Each call gets a new generator instance (avoiding generator exhaustion)
"""
def create_stream_response():
"""Create a fresh streaming response for each call."""
openai_stream = _create_openai_stream_data()
async def mock_stream():
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
return httpx.Response(200, stream=mock_stream())
def create_non_stream_response():
"""Create a non-streaming response for title generation."""
return httpx.Response(
200,
json={
"id": "chatcmpl-title",
"object": "chat.completion",
"created": int(timezone.make_naive(timezone.now()).timestamp()),
"model": "test-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "GENERATED TITLE",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 50, "completion_tokens": 5, "total_tokens": 55},
},
)
def handle_request(request):
"""Route to streaming or non-streaming response based on request."""
body = json.loads(request.content)
if body.get("stream", False):
return create_stream_response()
return create_non_stream_response()
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
side_effect=handle_request
)
return route
@pytest.fixture(name="mock_openai_no_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_no_stream():
@@ -2,6 +2,7 @@
# pylint: disable=too-many-lines
import json
from unittest.mock import patch
from django.utils import timezone
@@ -11,6 +12,7 @@ from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
from chat.agents.conversation import TitleGenerationAgent
from chat.ai_sdk_types import (
Attachment,
TextUIPart,
@@ -35,6 +37,7 @@ def ai_settings(settings):
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
settings.AUTO_TITLE_AFTER_USER_MESSAGES = None # disable auto title generation
return settings
@@ -1573,3 +1576,307 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
toolInvocations=None,
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
)
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
@patch("chat.clients.pydantic_ai.TitleGenerationAgent", wraps=TitleGenerationAgent)
def test_post_conversation_triggers_automatic_title_generation_after_first_message(
mock_title_agent, api_client, mock_openai_stream_with_title_generation, settings
):
"""
Test that posting the first user message triggers automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 1
The conversation is a new one. Posting the first message
should trigger title generation via the TitleGenerationAgent.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 1
conversation = ChatConversationFactory()
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(conversation.owner)
conversation.title = "initial title"
conversation.save()
assert not conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is in the stream
assert '"type": "conversation_metadata"' in response_content
# Refresh and verify title was updated
conversation.refresh_from_db()
assert conversation.title == "GENERATED TITLE"
# title_set_by_user_at should remain None since it was auto-generated
assert not conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 2
# Verify TitleGenerationAgent was called
mock_title_agent.assert_called_once()
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_triggers_automatic_title_generation_at_threshold(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that posting the 3rd user message triggers automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 3
The history_conversation fixture has 2 user messages. Posting a 3rd message
should trigger title generation via the TitleGenerationAgent.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
history_conversation.title = "initial title"
history_conversation.save()
assert not history_conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is in the stream
assert '"type": "conversation_metadata"' in response_content
# Refresh and verify title was updated
history_conversation.refresh_from_db()
assert history_conversation.title == "GENERATED TITLE"
# title_set_by_user_at should remain None since it was auto-generated
assert not history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 2
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_regenerate_title_when_user_set(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that title is NOT regenerated if the user has manually set a title.
"""
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
# Simulate user having set a custom title
history_conversation.title = "My Custom Title"
history_conversation.title_set_by_user_at = timezone.now()
history_conversation.save()
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Consume the stream
response_content = b"".join(response.streaming_content).decode("utf-8")
# conversation_metadata should NOT be in the stream since title wasn't generated
assert "conversation_metadata" not in response_content
# Refresh and verify title was NOT changed
history_conversation.refresh_from_db()
assert history_conversation.title == "My Custom Title"
assert history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.called
assert mock_openai_stream_with_title_generation.call_count == 1
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_generate_title_before_threshold(
api_client, mock_openai_stream_with_title_generation, settings
):
"""
Test that title is NOT generated before reaching the message threshold.
"""
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
# Create a conversation with only 1 user message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
conversation = ChatConversationFactory(title="initial title")
conversation.messages = [
UIMessage(
id="prev-user-msg-1",
createdAt=history_timestamp,
content="Hello!",
reasoning=None,
experimental_attachments=None,
role="user",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello!")],
),
UIMessage(
id="prev-assistant-msg-1",
createdAt=history_timestamp.replace(minute=31),
content="Hi there! How can I help you?",
reasoning=None,
experimental_attachments=None,
role="assistant",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hi there! How can I help you?")],
),
]
conversation.save()
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "second-user-msg",
"role": "user",
"parts": [{"text": "What's machine learning?", "type": "text"}],
"content": "What's machine learning?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Consume the stream
response_content = b"".join(response.streaming_content).decode("utf-8")
# conversation_metadata should NOT be in the stream (only 2 user messages)
assert "conversation_metadata" not in response_content
# Refresh and verify title was not updated
conversation.refresh_from_db()
assert conversation.title == "initial title"
assert not conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.call_count == 1
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_does_not_generate_title_after_threshold(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that posting the 3rd user message does not trigger automatic title generation.
AUTO_TITLE_AFTER_USER_MESSAGES = 2
The history_conversation fixture has 2 user messages. Posting a 3rd message
should not trigger title generation.
"""
# Configure the title generation threshold
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 2
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "third-user-msg",
"role": "user",
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
"content": "Can you explain backpropagation?",
"createdAt": "2025-07-25T10:36:00.000Z",
}
]
}
api_client.force_login(history_conversation.owner)
history_conversation.title = "initial title"
history_conversation.save()
assert not history_conversation.title_set_by_user_at
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.streaming
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Verify the conversation_metadata event is not in the stream
assert "conversation_metadata" not in response_content
# Refresh and verify title was NOT updated (past threshold)
history_conversation.refresh_from_db()
# title not updated
assert history_conversation.title == "initial title"
# title_set_by_user_at should remain None since it was auto-generated
assert not history_conversation.title_set_by_user_at
assert mock_openai_stream_with_title_generation.call_count == 1
@@ -28,6 +28,7 @@ def test_create_conversation(api_client):
conversation = ChatConversation.objects.get(id=response.data["id"])
assert conversation.owner == user
assert conversation.title == "New Conversation"
assert not conversation.title_set_by_user_at
def test_create_conversation_other_owner(api_client):
@@ -2,6 +2,7 @@
import pytest
from rest_framework import status
from rest_framework.exceptions import ErrorDetail
from core.factories import UserFactory
@@ -26,6 +27,34 @@ def test_update_conversation(api_client):
# Verify in database
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
assert conversation.title == "Updated Title"
assert conversation.title_set_by_user_at
def test_update_conversation_limit_title_length(api_client):
"""Test that updating a conversation with a title exceeding 100 characters fails validation."""
chat_conversation = ChatConversationFactory(title="Initial title")
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
# Create a 101-character title to exceed the 100-character maximum limit
new_title = "X" * 101
data = {"title": new_title}
api_client.force_login(chat_conversation.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {
"title": [
ErrorDetail(
string="Ensure this field has no more than 100 characters.", code="max_length"
)
]
}
# Verify in database (title should remain unchanged)
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
assert conversation.title == "Initial title"
assert not conversation.title_set_by_user_at
def test_update_conversation_anonymous(api_client):
+5 -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,
@@ -1137,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"]
@@ -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,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,4 +1,4 @@
import { Button as _Button, useModal } from '@openfun/cunningham-react';
import { useModal } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -6,6 +6,7 @@ import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { ModalRemoveConversation } from './ModalRemoveConversation';
import { ModalRenameConversation } from './ModalRenameConversation';
interface ConversationItemActionsProps {
conversation: ChatConversation;
@@ -17,8 +18,16 @@ export const ConversationItemActions = ({
const { t } = useTranslation();
const deleteModal = useModal();
const renameModal = useModal();
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',
@@ -71,6 +80,12 @@ export const ConversationItemActions = ({
conversation={conversation}
/>
)}
{renameModal.isOpen && (
<ModalRenameConversation
onClose={renameModal.onClose}
conversation={conversation}
/>
)}
</>
);
};
@@ -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>
);
};
@@ -0,0 +1,255 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ToastProvider } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { ConversationItemActions } from '../ConversationItemActions';
const mockPush = jest.fn();
let mockPathname = '/';
jest.mock('next/router', () => ({
useRouter: () => ({
push: mockPush,
pathname: mockPathname,
route: '/',
query: {},
asPath: '/',
}),
}));
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
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('@/features/chat/api/useRenameConversation', () => ({
useRenameConversation: () => ({
mutate: jest.fn(),
}),
}));
jest.mock('@/features/chat/api/useRemoveConversation', () => ({
useRemoveConversation: () => ({
mutate: jest.fn(),
}),
}));
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<ToastProvider>{ui}</ToastProvider>
</CunninghamProvider>,
);
};
describe('ConversationItemActions', () => {
const mockConversation: ChatConversation = {
id: 'conv-123',
title: 'Original Title',
messages: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
beforeEach(() => {
jest.clearAllMocks();
mockPathname = '/';
});
const renderComponent = (conversation = mockConversation) => {
return renderWithProviders(
<ConversationItemActions conversation={conversation} />,
);
};
it('renders the actions button', () => {
renderComponent();
expect(
screen.getByTestId(
`conversation-item-actions-button-${mockConversation.id}`,
),
).toBeInTheDocument();
});
it('renders dropdown menu with correct aria-label', () => {
renderComponent();
expect(
screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
),
).toBeInTheDocument();
});
it('renders dropdown menu with fallback title when conversation has no title', () => {
const untitledConversation = { ...mockConversation, title: '' };
renderComponent(untitledConversation);
expect(
screen.getByLabelText(
`Actions list for conversation Untitled conversation`,
),
).toBeInTheDocument();
});
it('opens dropdown menu when clicking the actions button', async () => {
const user = userEvent.setup();
renderComponent();
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('displays rename and delete options in the dropdown', async () => {
const user = userEvent.setup();
renderComponent();
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
expect(screen.getByText('Rename chat')).toBeInTheDocument();
expect(screen.getByText('Delete chat')).toBeInTheDocument();
});
it('opens rename modal when clicking rename option', async () => {
const user = userEvent.setup();
renderComponent();
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
const renameOption = screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
);
await user.click(renameOption);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByRole('textbox')).toHaveValue(mockConversation.title);
expect(screen.getByTestId('rename-chat-form')).toBeInTheDocument();
});
it('opens delete modal when clicking delete option', async () => {
const user = userEvent.setup();
renderComponent();
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
const deleteOption = screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
);
await user.click(deleteOption);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByTestId('delete-chat-confirm')).toBeInTheDocument();
});
it('does not render modals initially', () => {
renderComponent();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-chat-confirm')).not.toBeInTheDocument();
expect(screen.queryByTestId('rename-chat-form')).not.toBeInTheDocument();
});
it('closes rename modal when onClose is called', async () => {
const user = userEvent.setup();
renderComponent();
// Open dropdown and click rename
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
await user.click(
screen.getByTestId(
`conversation-item-actions-rename-${mockConversation.id}`,
),
);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Close the modal
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('closes delete modal when onClose is called', async () => {
const user = userEvent.setup();
renderComponent();
// Open dropdown and click delete
const actionsButton = screen.getByLabelText(
`Actions list for conversation ${mockConversation.title}`,
);
await user.click(actionsButton);
await user.click(
screen.getByTestId(
`conversation-item-actions-remove-${mockConversation.id}`,
),
);
// Modal should be open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Close the modal
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
});
@@ -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');
});
});