(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 <[email protected]>
This commit is contained in:
Laurent Paoletti
2025-12-30 19:08:19 +01:00
parent 69bf2cab5d
commit b483e9c200
16 changed files with 597 additions and 15 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to
### Changed
- 🐛(front) optimize chat
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
### Fixed
+57 -1
View File
@@ -60,6 +60,7 @@ from chat.agents.local_media_url_processors import (
update_history_local_urls,
update_local_urls,
)
from chat.agents.summarize import SummarizationAgent
from chat.ai_sdk_types import (
LanguageModelV1Source,
SourceUIPart,
@@ -446,6 +447,28 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
await self._agent_stop_streaming(force_cache_check=True)
generated_title = None
# +1 because we're about to add a new user message
current_user_count = sum(1 for msg in self.conversation.messages if msg.role == "user") + 1
if (
current_user_count == settings.AUTO_TITLE_AFTER_USER_MESSAGES
and not self.conversation.title_set_by_user_at
):
generated_title = await self._generate_title()
# 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 force_web_search and not self._is_web_search_enabled:
logger.warning("Web search is forced but the feature is disabled, ignoring.")
force_web_search = False
@@ -725,6 +748,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
ui_sources=_ui_sources,
model_response_message_id=_model_response_message_id,
image_key_mapping=image_key_mapping or None,
generated_title=generated_title,
)
if self._langfuse_available:
@@ -750,6 +774,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
ui_sources: List[SourceUIPart] = None,
model_response_message_id: str | None = None,
image_key_mapping: Dict[str, str] = None,
generated_title: str | None = None,
): # pylint: disable=too-many-arguments
"""
Save everything related to the conversation.
@@ -807,5 +832,36 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation.pydantic_messages += json.loads(
ModelMessagesTypeAdapter.dump_json(final_output).decode("utf-8")
)
if generated_title:
self.conversation.title = generated_title
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 the first messages
context = "\n".join(
f"{msg.role}: {msg.content[:300]}" # Limit content length per message
for msg in self.conversation.messages[:6] # First few messages (3 user + 3 assistant)
)
prompt = (
"Generate a short, concise title (maximum 60 characters) for this conversation. "
"The title should capture the main topic or intent. "
"Return ONLY the title text, nothing else. No quotes, no explanations.\n\n"
"Return the title text in the same language the user messages are written."
f"If in doubt, use {self.language or 'French'}."
f"Conversation:\n{context}"
)
try:
agent = SummarizationAgent()
result = await agent.run(prompt)
title = (result.output or "").strip()[:100] # Enforce max length
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):
"""
@@ -10,15 +10,9 @@ import respx
from freezegun import freeze_time
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
"""
Fixture to mock the OpenAI stream response.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
"""
openai_stream = (
def build_openai_stream():
"""Build open ai stream"""
return (
"data: "
+ json.dumps(
{
@@ -59,6 +53,17 @@ def fixture_mock_openai_stream():
"data: [DONE]\n\n"
)
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
"""
Fixture to mock the OpenAI stream response.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
"""
openai_stream = build_openai_stream()
async def mock_stream():
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
@@ -70,6 +75,62 @@ def fixture_mock_openai_stream():
return route
@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.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
"""
def create_stream_response():
"""Create a fresh streaming response for each call."""
openai_stream = build_openai_stream()
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():
@@ -35,6 +35,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 = 999 # disable auto title generation
return settings
@@ -1569,3 +1570,184 @@ 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
def test_post_conversation_triggers_automatic_title_generation(
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
):
"""
Test that posting the 3rd user message triggers automatic title generation.
The history_conversation fixture has 2 user messages. Posting a 3rd message
should trigger title generation via the SummarizationAgent.
"""
# 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 is not None
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
@@ -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 is not None
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):
+3 -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(
3, 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,
@@ -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,42 @@ 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'
);
}
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,6 +18,7 @@ export const ConversationItemActions = ({
const { t } = useTranslation();
const deleteModal = useModal();
const renameModal = useModal();
const options: DropdownMenuOption[] = [
{
@@ -26,6 +28,13 @@ export const ConversationItemActions = ({
disabled: false,
testId: `conversation-item-actions-remove-${conversation.id}`,
},
{
label: t('Rename chat'),
icon: 'tune',
callback: () => renameModal.open(),
disabled: false,
testId: `conversation-item-actions-rename-${conversation.id}`,
},
];
return (
@@ -71,6 +80,12 @@ export const ConversationItemActions = ({
conversation={conversation}
/>
)}
{renameModal.isOpen && (
<ModalRenameConversation
onClose={renameModal.onClose}
conversation={conversation}
/>
)}
</>
);
};
@@ -79,7 +79,7 @@ export const ModalRemoveConversation = ({
</Text>
}
>
<Box className="--converstions--modal-remove-chat">
<Box className="--conversations--modal-remove-chat">
<Text $size="sm" $variation="600">
{t('Are you sure you want to delete this conversation ?')}
</Text>
@@ -0,0 +1,100 @@
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { Box, Text, useToast } from '@/components';
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
import { ChatConversation } from '@/features/chat/types';
interface ModalRenameConversation {
onClose: () => void;
conversation: ChatConversation;
}
export const ModalRenameConversation = ({
onClose,
conversation,
}: ModalRenameConversation) => {
const { showToast } = useToast();
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', t(errorMessage), undefined, 4000);
},
});
const [newName, setNewName] = useState(conversation.title ?? '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newName.trim()) {
renameConversation({
conversationId: conversation.id,
title: newName,
});
}
};
return (
<Modal
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-label={t('Content modal to rename a conversation')}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
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" className="mt-s">
<Input
label={t('New name')}
maxLength={100}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value);
}}
/>
</form>
</Box>
</Modal>
);
};
@@ -31,6 +31,7 @@
"Close the modal": "Fermer la modale",
"Confirm deletion": "Confirmer la suppression",
"Content modal to delete conversation": "Modale pour supprimer la conversation",
"Content modal to rename conversation": "Modale pour renommer la conversation",
"Conversation analysis disabled": "Analyse de la conversation désactivée",
"Conversation analysis enabled": "Analyse de la conversation activée",
"Copied": "Copié",
@@ -76,6 +77,7 @@
"Logout": "Se déconnecter",
"New chat": "Nouvelle conversation",
"New feedback": "Nouveaux commentaires",
"New name": "Nouveau nom",
"No code? ": "Pas de code ? ",
"No conversation found": "Aucune conversation trouvée",
"Notify me": "Me notifier",
@@ -87,6 +89,8 @@
"Proconnect Login": "Connexion Proconnect",
"Quick search input": "Saisie de recherche rapide",
"Remove attachment": "Supprimer la pièce jointe",
"Rename": "Renommer",
"Rename chat": "Renommer la conversation",
"Research on the web": "Rechercher sur le web",
"Search": "Rechercher",
"Search for a chat": "Rechercher un chat",
@@ -107,6 +111,7 @@
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "L'Assistant est une IA souveraine conçue pour les fonctionnaires. Il vous permet de gagner du temps sur des tâches quotidiennes telles que la reformulation, le résumé, la traduction ou la recherche d'informations. Vos données ne quittent jamais la France et sont stockées sur des infrastructures sûres et conformes à l'état et ne sont jamais utilisées à des fins commerciales.",
"The Assistant is in Beta": "L'Assistant est en Bêta",
"The conversation has been deleted.": "La conversation a été supprimée.",
"The conversation has been renamed": "La conversation a été renom.",
"The summary feature is not supported yet.": "La fonctionnalité de résumé n'est pas encore prise en charge.",
"Thinking...": "Réflexion...",
"To add a file to the conversation, drop it here.": "Pour ajouter un fichier à la conversation, déposez-le ici.",