Compare commits

...

3 Commits

Author SHA1 Message Date
coderabbitai[bot] cc611d35c8 📝 Add docstrings to providenz/conversation-title
Docstrings generation was requested by @providenz.

* https://github.com/suitenumerique/conversations/pull/216#issuecomment-3699989398

The following files were modified:

* `src/backend/chat/clients/pydantic_ai.py`
* `src/backend/chat/serializers.py`
* `src/backend/chat/tests/views/chat/conversations/conftest.py`
* `src/backend/chat/tests/views/chat/conversations/test_conversation_with_history.py`
* `src/frontend/apps/conversations/src/features/chat/api/useChat.tsx`
2025-12-30 20:33:07 +00:00
Laurent Paoletti b483e9c200 (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>
2025-12-30 19:08:19 +01:00
Eléonore Voisin 69bf2cab5d 🐛(front) optimize chat
Simplified chat rendering
2025-12-19 17:12:53 +01:00
21 changed files with 1504 additions and 715 deletions
+2 -1
View File
@@ -9,8 +9,9 @@ and this project adheres to
## [Unreleased]
### Changed
- 🐛(front) optimize chat
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
### Fixed
+90 -12
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,
@@ -356,7 +357,16 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
messages: List[UIMessage],
force_web_search: bool = False,
) -> events_v4.Event | events_v5.Event:
"""Run the Pydantic AI agent and stream events."""
"""
Drive the agent for the provided user message, stream Vercel-AI-SDK event parts representing model and tool activity, and persist the final conversation state.
Parameters:
messages (List[UIMessage]): UI messages for the conversation; the last message must be from the user.
force_web_search (bool): If true, require the agent to invoke the configured web search tool before answering (ignored if the feature or tool is unavailable).
Returns:
events_v4.Event | events_v5.Event: Streamed event parts such as `TextPart`, `ToolCallPart`/`ToolCallStreamingStartPart`/`ToolCallDeltaPart`, `ToolResultPart`, `ReasoningPart`, `SourcePart`, `DataPart`, `StartStepPart`, and `FinishMessagePart` that drive frontend updates.
"""
if messages[-1].role != "user":
return
@@ -446,6 +456,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 +757,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,18 +783,25 @@ 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.
Things to improve here:
- The way we need to add the UI sources to the final output message.
Args:
final_output (List[ModelRequest | ModelMessage]): The final output from the agent.
usage (Dict[str, int]): The token usage statistics.
user_initial_prompt_str (str | None): The initial user prompt string, if any.
ui_sources (List[SourceUIPart]): Optional UI sources to include in the conversation.
Merge the agent's final outputs into the conversation and persist updated conversation state.
Parameters:
final_output (List[ModelRequest | ModelMessage]): Sequence of model requests and responses produced by the agent run; these will be merged into a single request and a single response before saving.
usage (Dict[str, int]): Token usage statistics to store on the conversation (e.g., promptTokens, completionTokens).
final_output_from_tool (str | None): Optional text produced by a tool that should be appended to the final model response.
ui_sources (List[SourceUIPart], optional): Optional UI-visible source parts to attach to the final response message.
model_response_message_id (str | None, optional): If provided, assign this id to the saved model response UI message; if omitted, a warning will be logged.
image_key_mapping (Dict[str, str], optional): Mapping from original (unsigned) media URLs to presigned/rewritten URLs; applied to image/document references in the merged request parts.
generated_title (str | None, optional): Optional auto-generated conversation title to apply to the conversation.
Behavior:
- Merges multiple model request/response objects into a single ModelRequest and ModelResponse.
- Rewrites image/document URLs in user prompt parts when an image_key_mapping is provided.
- Converts merged model messages to UI messages, appends ui_sources if present, and sets the response message id when supplied.
- Appends the merged request and response messages to the conversation, updates agent usage and pydantic messages, applies a generated title if given, and saves the conversation.
"""
_merged_final_output_request = ModelRequest(
parts=[
@@ -807,5 +847,43 @@ 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:
"""
Create a concise conversation title based on the conversation's first messages.
Uses the summarization agent to produce a short title in the same language as the user's messages. Returns the generated title text trimmed to at most 100 characters, or `None` if generation fails or produces no text.
Returns:
str | None: The generated title (trimmed to 100 characters), or `None` when no title is available.
"""
# 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,
+16 -1
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,20 @@ 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
"""
Update the ChatConversation instance and record when the title is changed by the user.
If `validated_data` contains a `title` different from the instance's current title, sets `title_set_by_user_at` to the current time.
Returns:
The updated ChatConversation instance.
"""
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):
"""
@@ -198,4 +213,4 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
f"File size exceeds the maximum limit of {max_size:d} MB."
)
return size
return size
@@ -10,15 +10,19 @@ 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():
def build_openai_stream():
"""
Fixture to mock the OpenAI stream response.
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
Constructs a string that simulates an OpenAI streaming response payload.
The returned string contains three OpenAI-style `data:` blocks: a first chunk with content "Hello",
a second chunk with content " there" and a `finish_reason` of "stop" (including a `usage` object),
and a final `data: [DONE]` marker. Timestamp fields are generated from timezone.now() converted to
naive timestamps.
Returns:
A string containing concatenated `data:` lines representing streaming chunks and a final `[DONE]` marker.
"""
openai_stream = (
return (
"data: "
+ json.dumps(
{
@@ -59,7 +63,24 @@ 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():
"""
Yield each line of the prepared OpenAI-style streaming payload as encoded bytes.
Yields:
AsyncGenerator[bytes, None]: Sequential byte chunks for each line in the constructed stream, preserving original line endings.
"""
for line in openai_stream.splitlines(keepends=True):
yield line.encode()
@@ -70,10 +91,100 @@ 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():
"""
Mock pytest fixture that intercepts POST requests to the external chat completions endpoint and returns either a streaming chat response or a non-streaming title-generation response depending on the incoming request.
When the request JSON has "stream" set to True, the fixture returns an HTTP streaming response that imitates OpenAI's chat streaming payload; otherwise it returns a non-streaming JSON response containing a generated title and usage metadata.
Returns:
respx.Route: A configured respx route that intercepts POST requests to
"https://www.external-ai-service.com/chat/completions" and replies based on the request body.
"""
def create_stream_response():
"""
Create an HTTP response whose body streams encoded lines of an OpenAI-style streaming payload.
Returns:
httpx.Response: HTTP 200 response with a streaming body that yields encoded bytes for each line of the streaming payload.
"""
openai_stream = build_openai_stream()
async def mock_stream():
"""
Yield encoded byte chunks for each line of the OpenAI stream.
Each yielded value is a bytes object containing one line (including its line ending) from the prebuilt OpenAI streaming payload, suitable for use as an HTTP streaming response body.
"""
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 OpenAI-like chat completion response containing a generated title.
Returns:
httpx.Response: HTTP 200 response whose JSON payload represents a chat completion with a single assistant message containing the generated title and accompanying metadata (id, model, timestamps, choices, and usage).
"""
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):
"""
Selects a streaming or non-streaming HTTP response based on the request JSON `stream` flag.
Parameters:
request (httpx.Request): Incoming request whose JSON body is inspected for the `stream` boolean flag.
Returns:
httpx.Response: A response that streams the OpenAI-style event lines if `stream` is True, otherwise a non-streaming JSON response.
"""
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():
"""Fixture to mock the OpenAI response."""
"""
Create a respx route that returns a fixed, non-streaming OpenAI chat completion response.
The mocked response is an HTTP 200 JSON payload representing a completed assistant message (explaining Rayleigh scattering) with associated metadata and usage details.
Returns:
respx.Route: The configured respx route intercepting POST requests to https://www.external-ai-service.com/chat/completions.
"""
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
return_value=httpx.Response(
@@ -387,4 +498,4 @@ def fixture_mock_openai_stream_tool():
]
)
return route
return route
@@ -29,12 +29,24 @@ pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
"""
Configure AI-related settings for tests on the provided settings object.
Sets test values for AI service base URL, API key, model, agent instructions, and sets
AUTO_TITLE_AFTER_USER_MESSAGES to 999 to disable automatic title generation during tests.
Parameters:
settings (object): Django settings-like object to be mutated for test configuration.
Returns:
object: The same settings object with AI-related test configuration applied.
"""
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 999 # disable auto title generation
return settings
@@ -1569,3 +1581,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,8 +1,9 @@
import dynamic from 'next/dynamic';
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
import searchingAnimation from '@/assets/lotties/searching';
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
export function Loader() {
return (
<div role="status">
@@ -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,55 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
return fetchAPI(url, init);
};
interface ConversationMetadataEvent {
type: 'conversation_metadata';
conversationId: string;
title: string;
}
/**
* Type guard that determines whether a value is a ConversationMetadataEvent.
*
* @param item - Value to test
* @returns `true` if `item` is a ConversationMetadataEvent, `false` otherwise.
*/
function isConversationMetadataEvent(
item: unknown,
): item is ConversationMetadataEvent {
return (
typeof item === 'object' &&
item !== null &&
'type' in item &&
item.type === 'conversation_metadata'
);
}
/**
* Hook that provides chat functionality with a custom fetch adapter and automatic conversation-list cache invalidation.
*
* The hook invokes the underlying AI chat implementation with `maxSteps` set to 3 and a fetch wrapper that appends UI-driven query parameters; when the chat stream emits a `conversation_metadata` event the hook invalidates the conversation list cache (KEY_LIST_CONVERSATION).
*
* @param options - Chat configuration options (note: `maxSteps` is overridden to 3 and the `fetch` implementation is replaced)
* @returns The chat hook result object containing `data`, status flags, and control methods for interacting with the chat stream.
*/
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);
}
},
});
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
import {
Message,
ReasoningUIPart,
SourceUIPart,
ToolInvocationUIPart,
} from '@ai-sdk/ui-utils';
import 'katex/dist/katex.min.css';
import { memo, useDeferredValue } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import rehypePrettyCode from 'rehype-pretty-code';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { Box, Icon, Text } from '@/components';
import { useClipboard } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { AttachmentList } from './AttachmentList';
import { CodeBlock } from './CodeBlock';
import { FeedbackButtons } from './FeedbackButtons';
import { SourceItemList } from './SourceItemList';
import { ToolInvocationItem } from './ToolInvocationItem';
// Mémoriser les plugins Markdown en dehors du composant pour éviter les recréations
const remarkPlugins = [remarkGfm, remarkMath];
const rehypePlugins = [
[
rehypePrettyCode,
{
theme: 'github-dark-dimmed',
},
],
rehypeKatex,
];
// Composants Markdown mémorisés
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markdownComponents: any = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
p: ({ node, ...props }: any) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a: ({ children, ...props }: any) => (
<a target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
pre: ({ node, children, ...props }: any) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
};
// Composant Markdown mémorisé pour éviter les recalculs inutiles
const MemoizedMarkdown = memo(function MemoizedMarkdown({
content,
}: {
content: string;
}) {
return (
<MarkdownHooks
remarkPlugins={remarkPlugins}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
rehypePlugins={rehypePlugins as any} // Type mismatch with react-markdown types
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
components={markdownComponents}
>
{content}
</MarkdownHooks>
);
});
interface ChatMessageProps {
message: Message;
isLastAssistantMessageInConversation: boolean;
shouldApplyStreamingHeight: boolean;
streamingMessageHeight: number | null;
isCurrentlyStreaming: boolean;
status: 'idle' | 'streaming' | 'submitted' | 'ready' | 'error';
isSourceOpen: string | null;
conversationId: string | undefined;
onOpenSources: (messageId: string) => void;
getMetadata: (url: string) =>
| {
title: string | null;
favicon: string | null;
loading: boolean;
error: boolean;
}
| undefined;
}
export const ChatMessage = memo(function ChatMessage({
message,
isLastAssistantMessageInConversation,
shouldApplyStreamingHeight,
streamingMessageHeight,
isCurrentlyStreaming,
status,
isSourceOpen,
conversationId,
onOpenSources,
getMetadata,
}: ChatMessageProps) {
const { t } = useTranslation();
const copyToClipboard = useClipboard();
const { isMobile } = useResponsiveStore();
const deferredContent = useDeferredValue(message.content);
const contentToRender =
message.role === 'assistant' ? deferredContent : message.content;
return (
<Box
key={message.id}
data-message-id={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessageInConversation ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
$radius="8px"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
$maxWidth="100%"
$padding={`${message.role === 'user' ? '12px' : '0'}`}
$margin={{ vertical: 'base' }}
$background={`${message.role === 'user' ? '#EEF1F4' : 'white'}`}
$css={`
display: inline-block;
float: right;
${shouldApplyStreamingHeight ? `min-height: ${streamingMessageHeight}px;` : ''}
`}
>
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
<MemoizedMarkdown content={contentToRender} />
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessageInConversation &&
status === 'streaming' &&
message.parts?.some(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !== 'document_parsing',
) && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Text $variation="600" $size="md">
{(() => {
const toolInvocation = message.parts?.find(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !== 'document_parsing',
);
if (
toolInvocation?.type === 'tool-invocation' &&
toolInvocation.toolInvocation.toolName === 'summarize'
) {
return t('Summarizing...');
}
return t('Search...');
})()}
</Text>
</Box>
)}
{message.parts
?.filter(
(part) =>
part.type === 'reasoning' || part.type === 'tool-invocation',
)
.map(
(
part: ReasoningUIPart | ToolInvocationUIPart,
partIndex: number,
) =>
part.type === 'reasoning' ? (
<Box
key={`reasoning-${partIndex}`}
$background="var(--c--theme--colors--greyscale-100)"
$color="var(--c--theme--colors--greyscale-500)"
$padding={{ all: 'sm' }}
$radius="md"
$css="font-size: 0.9em;"
>
{part.reasoning}
</Box>
) : part.type === 'tool-invocation' &&
isCurrentlyStreaming &&
isLastAssistantMessageInConversation ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(
isLastAssistantMessageInConversation && status === 'streaming'
) && (
<Box
$css="color: #222631; font-size: 12px;"
$direction="row"
$align="center"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$direction="row"
$align="center"
$gap="4px"
className="c__button--neutral action-chat-button"
onClick={() => copyToClipboard(message.content)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyToClipboard(message.content);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
{!isMobile && (
<Text $theme="greyscale" $variation="550">
{t('Copy')}
</Text>
)}
</Box>
{message.parts?.some((part) => part.type === 'source') &&
(() => {
const sourceCount =
message.parts?.filter((part) => part.type === 'source')
.length || 0;
return (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => onOpenSources(message.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenSources(message.id);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceCount}{' '}
{sourceCount !== 1 ? t('sources') : t('source')}
</Text>
</Box>
);
})()}
</Box>
<Box $direction="row" $gap="4px">
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{message.parts &&
isSourceOpen === message.id &&
(() => {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
return (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList
parts={sourceParts}
getMetadata={getMetadata}
/>
</Box>
);
})()}
</Box>
</Box>
</Box>
);
});
@@ -1,2 +1,3 @@
export { useChatScroll } from './useChatScroll';
export { useSourceMetadataCache } from './useSourceMetadata';
export { useModelSelection } from './useModelSelection';
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { LLMModel, useLLMConfiguration } from '../api/useLLMConfiguration';
import { useChatPreferencesStore } from '../stores/useChatPreferencesStore';
export const useModelSelection = () => {
const { data: llmConfig } = useLLMConfiguration();
const { selectedModelHrid, setSelectedModelHrid } = useChatPreferencesStore();
const [selectedModel, setSelectedModel] = useState<LLMModel | null>(null);
const hasInitializedRef = useRef(false);
useEffect(() => {
// Ne s'exécuter qu'une seule fois quand llmConfig est chargé
if (llmConfig?.models && !hasInitializedRef.current) {
let modelToSelect: LLMModel | undefined;
if (selectedModelHrid) {
// Try to find the previously selected model
modelToSelect = llmConfig.models.find(
(model) =>
model.hrid === selectedModelHrid && model.is_active !== false,
);
}
// If no saved model or saved model not found/inactive, use default
if (!modelToSelect) {
modelToSelect = llmConfig.models.find((model) => model.is_default);
}
if (modelToSelect) {
setSelectedModel(modelToSelect);
setSelectedModelHrid(modelToSelect.hrid);
hasInitializedRef.current = true;
}
}
}, [llmConfig?.models, selectedModelHrid, setSelectedModelHrid]);
const handleModelSelect = (model: LLMModel) => {
setSelectedModel(model);
setSelectedModelHrid(model.hrid);
};
return { selectedModel, handleModelSelect };
};
@@ -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.",