Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc611d35c8 | |||
| b483e9c200 |
@@ -11,6 +11,7 @@ and this project adheres to
|
||||
### Changed
|
||||
- 🐛(front) optimize chat
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+16
-1
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+100
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user