diff --git a/CHANGELOG.md b/CHANGELOG.md index 3401cbb..f8dbe79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to ### Changed - 📦️(front) update react +- ✨(chat) Generate and edit conversation title ### Fixed diff --git a/env.d/development/common.e2e.dist b/env.d/development/common.e2e.dist index 091cb17..0ef1658 100644 --- a/env.d/development/common.e2e.dist +++ b/env.d/development/common.e2e.dist @@ -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 diff --git a/src/backend/chat/agents/base.py b/src/backend/chat/agents/base.py index be49443..f005c3b 100644 --- a/src/backend/chat/agents/base.py +++ b/src/backend/chat/agents/base.py @@ -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] diff --git a/src/backend/chat/agents/conversation.py b/src/backend/chat/agents/conversation.py index 227259e..963efc5 100644 --- a/src/backend/chat/agents/conversation.py +++ b/src/backend/chat/agents/conversation.py @@ -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" + ) diff --git a/src/backend/chat/clients/pydantic_ai.py b/src/backend/chat/clients/pydantic_ai.py index a1dc803..223c8b7 100644 --- a/src/backend/chat/clients/pydantic_ai.py +++ b/src/backend/chat/clients/pydantic_ai.py @@ -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 diff --git a/src/backend/chat/migrations/0005_chatconversation_title_set_by_user_at.py b/src/backend/chat/migrations/0005_chatconversation_title_set_by_user_at.py new file mode 100644 index 0000000..32f7ea8 --- /dev/null +++ b/src/backend/chat/migrations/0005_chatconversation_title_set_by_user_at.py @@ -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, + ), + ), + ] diff --git a/src/backend/chat/models.py b/src/backend/chat/models.py index 334cf7e..ddd151f 100644 --- a/src/backend/chat/models.py +++ b/src/backend/chat/models.py @@ -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, diff --git a/src/backend/chat/serializers.py b/src/backend/chat/serializers.py index add4e5f..48a7b7f 100644 --- a/src/backend/chat/serializers.py +++ b/src/backend/chat/serializers.py @@ -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): """ diff --git a/src/backend/chat/tests/agents/test_title_generation_agent.py b/src/backend/chat/tests/agents/test_title_generation_agent.py new file mode 100644 index 0000000..d66c41e --- /dev/null +++ b/src/backend/chat/tests/agents/test_title_generation_agent.py @@ -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 diff --git a/src/backend/chat/tests/views/chat/conversations/conftest.py b/src/backend/chat/tests/views/chat/conversations/conftest.py index 75b55b6..c8cfc2a 100644 --- a/src/backend/chat/tests/views/chat/conversations/conftest.py +++ b/src/backend/chat/tests/views/chat/conversations/conftest.py @@ -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(): diff --git a/src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py b/src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py index 8c530bf..0b6ff82 100644 --- a/src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py +++ b/src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py @@ -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 diff --git a/src/backend/chat/tests/views/chat/test_create.py b/src/backend/chat/tests/views/chat/test_create.py index 76cd684..e6f0706 100644 --- a/src/backend/chat/tests/views/chat/test_create.py +++ b/src/backend/chat/tests/views/chat/test_create.py @@ -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): diff --git a/src/backend/chat/tests/views/chat/test_update.py b/src/backend/chat/tests/views/chat/test_update.py index 96f5874..bce38bd 100644 --- a/src/backend/chat/tests/views/chat/test_update.py +++ b/src/backend/chat/tests/views/chat/test_update.py @@ -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): diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index 221ba6e..56d2fe3 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -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"] diff --git a/src/frontend/apps/conversations/package.json b/src/frontend/apps/conversations/package.json index 67a4167..8f157b4 100644 --- a/src/frontend/apps/conversations/package.json +++ b/src/frontend/apps/conversations/package.json @@ -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", diff --git a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx index cf3dfb2..081511a 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx @@ -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) { - 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; } diff --git a/src/frontend/apps/conversations/src/features/chat/api/useRenameConversation.tsx b/src/frontend/apps/conversations/src/features/chat/api/useRenameConversation.tsx new file mode 100644 index 0000000..201aafb --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/api/useRenameConversation.tsx @@ -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 => { + 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({ + 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); + } + }, + }); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationItemActions.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationItemActions.tsx index 0e516c2..587c79a 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationItemActions.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationItemActions.tsx @@ -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 && ( + + )} ); }; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx index db16c9f..48b430e 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx @@ -46,7 +46,7 @@ export const ModalRemoveConversation = ({ <> + + + } + size={ModalSize.SMALL} + title={ + + {t('Rename chat')} + + } + > + +
+ ) => { + setNewName(e.target.value); + }} + /> +
+
+ + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ConversationItemActions.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ConversationItemActions.test.tsx new file mode 100644 index 0000000..8a4b40c --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ConversationItemActions.test.tsx @@ -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) => { + 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( + + {ui} + , + ); +}; + +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( + , + ); + }; + + 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(); + }); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx new file mode 100644 index 0000000..2d5fa21 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx @@ -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) => { + 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({component}); +}; + +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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('maxLength', '100'); + }); +});