Compare commits

...

13 Commits

Author SHA1 Message Date
natoromano 88be0c94dd (tests) fix tests 2026-02-24 17:39:36 +01:00
natoromano 00f28f4647 🖋️(translate): more fixes 2026-02-23 16:53:53 +01:00
natoromano 34a619a54f chore: small fixes 2026-02-23 16:34:59 +01:00
natoromano bd4b312bba (tools) add basic translate tool 2026-02-20 15:43:27 +01:00
Laurent Paoletti ba712af899 ♻️(back) refactor AIAgentService for readability and maintainability
Extract large _run_agent method into smaller focused methods
Add comprehensive module and method documentation
Minor fixes

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-19 23:31:28 +01:00
Manuel Raynaud e60c938d44 🐛(helm) reverse liveness and readiness for backend deployment
The liveness and readiness are reversed. The liveness was using the
heartbeat process that is cheking all django checks and the database
connection.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-19 19:46:45 +01:00
Eléonore Voisin 5d895d15f9 (table) temporarily adjust array
temporarily adjust css table
2026-02-18 09:56:27 +01:00
Eléonore Voisin 96a2963adf (waffle) hide the waffle if not fr theme
show waffe only on fr theme
2026-02-16 18:20:49 +01:00
Laurent Paoletti 224e6f83fd ⬆️(back) update pydantic-ai
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-16 11:11:27 +01:00
Laurent Paoletti af6facf4a3 ️(front) optimize streaming markdown rendering with block splitting
Split streaming content into independent memoized blocks to avoid
re-rendering all markdown/katex/syntax-highlighting on each update.
Add some tests for split functions and components.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-16 09:46:23 +01:00
natoromano 9d16460777 ⬆️ (fix): redo after rebase
(front) add e2e pasting an attachment from clipboard

add test e2e paste attachment
2026-02-13 11:53:17 +01:00
Laurent Paoletti 09dceb508d 🐛(front) fix math formulas and carousel translations
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-10 10:23:11 +01:00
Laurent Paoletti 63e0e6c383 💚(docker) vendor mime.types file i/o fetching from Apache
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-10 09:45:25 +01:00
39 changed files with 5329 additions and 1007 deletions
+2
View File
@@ -9,9 +9,11 @@ skip =
**/e2e/report/**,
*.tsbuildinfo,
**/uv.lock,
./docker/files/etc/mime.types,
check-filenames = true
ignore-words-list =
afterAll,
statics,
exclude-file =
./src/backend/chat/agent_rag/web_search/mocked.py,
+1 -1
View File
@@ -200,7 +200,7 @@ jobs:
- name: Install gettext (required to compile messages) and MIME support
run: |
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
sudo cp $GITHUB_WORKSPACE/docker/files/etc/mime.types /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
+23 -4
View File
@@ -8,6 +8,25 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(waffle) hide the waffle if not fr theme
- ✨(front) allow pasting an attachment from clipboard
- ✨(array) temporarily adjust array
- ✨(tools) add basic translate tool
### Changed
- ⚡️(front) optimize streaming markdown rendering performance
- ⬆️(back) update pydantic-ai
- ♻️(chat) refactor AIAgentService for readability and maintainability
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
- 🐛(front) fix math formulas and carousel translations
- 🐛(helm) reverse liveness and readiness for backend deployment
## [0.0.13] - 2026-02-09
### Added
@@ -18,17 +37,17 @@ and this project adheres to
- 🧱(files) allow to use S3 storage without external access #849
- ✨(backend) add FindRagBackend #209
- ⬆️(back) update dependencies
- ✨(back) Use adaptive parsing for pdf documents
- ✨(back) use adaptive parsing for pdf documents
### Changed
- 💄(darkmode) change color feedback butto
- 💄(darkmode) change color feedback button
- 🏗️(back) migrate to uv
- ♻️(front) optimize syntax highlighting bundle size
### Fixed
- 🐛(back) Cast collection Ids to API expected types
- 🐛(back) cast collection Ids to API expected types
## [0.0.12] - 2026-01-27
@@ -46,7 +65,7 @@ and this project adheres to
### Changed
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
- ✨(chat) generate and edit conversation title
### Fixed
+1 -1
View File
@@ -93,7 +93,7 @@ RUN apk add \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
COPY ./docker/files/etc/mime.types /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
"""Build the translation agent."""
import dataclasses
import logging
from django.conf import settings
from .base import BaseAgent
logger = logging.getLogger(__name__)
@dataclasses.dataclass(init=False)
class TranslationAgent(BaseAgent):
"""Create a Pydantic AI translation Agent instance with the configured settings"""
def __init__(self, **kwargs):
"""Initialize the agent with the configured model."""
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=str,
**kwargs,
)
def get_tools(self) -> list:
"""Translation does not need any tools."""
return []
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -15,12 +15,13 @@ import core.urls
import chat.views
import conversations.urls
from chat.agents.summarize import SummarizationAgent
from chat.agents.translate import TranslationAgent
from chat.clients.pydantic_ai import AIAgentService
logger = logging.getLogger(__name__)
@pytest.fixture(name="today_promt_date")
@pytest.fixture(name="today_prompt_date")
def today_prompt_date_fixture():
"""Fixture to mock date the system prompt when useless to test it."""
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
@@ -91,6 +92,34 @@ def mock_summarization_agent_fixture():
yield _mock_agent
@pytest.fixture(name="mock_translation_agent")
def mock_translation_agent_fixture():
"""Fixture to mock TranslationAgent with a custom model."""
@contextmanager
def _mock_agent(model):
"""Context manager to mock TranslationAgent with a custom model."""
with ExitStack() as stack:
class TranslationAgentMock(TranslationAgent):
"""Mocked TranslationAgent to override the model."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
logger.info("Overriding TranslationAgent model with %s", model)
self._model = model # pylint: disable=protected-access
stack.enter_context(
patch("chat.agents.translate.TranslationAgent", new=TranslationAgentMock)
)
stack.enter_context(
patch("chat.tools.document_translate.TranslationAgent", new=TranslationAgentMock)
)
yield
yield _mock_agent
PIXEL_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
@@ -0,0 +1,318 @@
"""Tests for document_translate functionality."""
import io
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from pydantic_ai import ModelResponse, RunContext, TextPart
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.usage import RunUsage
from chat.llm_configuration import LLModel, LLMProvider
from chat.tools.document_translate import document_translate
@pytest.fixture(autouse=True)
def fixture_translation_agent_config(settings):
"""Fixture to set used settings for agent configuration."""
settings.TRANSLATION_MAX_CHARS = 100_000
settings.LLM_CONFIGURATIONS = {
settings.LLM_DEFAULT_MODEL_HRID: LLModel(
hrid="mistral-model",
model_name="mistral-medium-2508",
human_readable_name="Mistral Medium 2508",
profile=None,
provider=LLMProvider(
hrid="mistral-medium-2508",
kind="mistral",
base_url="https://api.mistral.ai/v1",
api_key="testkey",
),
is_active=True,
system_prompt="direct",
tools=[],
),
}
@pytest.fixture(name="mocked_context")
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = mock.Mock(spec=RunContext)
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
return mock_ctx
def _mock_attachments_queryset(attachment):
"""Create a mock queryset that chains .filter().order_by().afirst() returning the attachment."""
mock_qs = mock.Mock()
mock_qs.order_by.return_value = mock_qs
mock_qs.afirst = mock.AsyncMock(return_value=attachment)
mock_attachments = mock.Mock()
mock_attachments.filter.return_value = mock_qs
return mock_attachments
def mocked_translation(_messages, _info=None):
"""Mocked translation response."""
return ModelResponse(parts=[TextPart(content="Ceci est une traduction du document.")])
@pytest.mark.asyncio
async def test_document_translate_single_document(mocked_context, mock_translation_agent):
"""Test document_translate with a single document."""
mock_attachment = mock.Mock()
mock_attachment.key = "test_doc.txt"
mock_attachment.file_name = "test_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a test document. " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_full(_message, _info=None):
"""Mocked translation for full flow."""
return ModelResponse(parts=[TextPart(content="Ceci est un document de test.")])
with mock_translation_agent(FunctionModel(mocked_translate_full)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "Ceci est un document de test." in result.return_value
assert result.metadata["sources"] == {"test_doc.txt"}
@pytest.mark.asyncio
async def test_document_translate_uses_last_document(mocked_context, mock_translation_agent):
"""Test document_translate uses the last uploaded document."""
mock_attachment = mock.Mock()
mock_attachment.key = "latest_doc.txt"
mock_attachment.file_name = "latest_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Content of the latest document."
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert result.metadata["sources"] == {"latest_doc.txt"}
# Verify order_by was called with -created_at
mock_conversation.attachments.filter.return_value.order_by.assert_called_once_with(
"-created_at"
)
@pytest.mark.asyncio
async def test_document_translate_with_custom_instructions(mocked_context, mock_translation_agent):
"""Test document_translate with custom instructions."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
captured_prompts = []
def mocked_translate_with_instructions(messages, _info=None):
"""Mocked translation that captures prompt."""
messages_text = messages[0].parts[-1].content
captured_prompts.append(messages_text)
return ModelResponse(parts=[TextPart(content="Traduction formelle")])
with mock_translation_agent(FunctionModel(mocked_translate_with_instructions)):
result = await document_translate(
mocked_context, target_language="French", instructions="Use formal tone"
)
assert result.return_value is not None
assert len(captured_prompts) == 1
assert "Use formal tone" in captured_prompts[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("target_language", [None, ""])
async def test_document_translate_no_target_language(
target_language, mocked_context, mock_translation_agent
):
"""Test document_translate asks the user for language when target_language is not specified."""
mocked_context.deps = mock.Mock()
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language=target_language, instructions=None
)
assert "target language is not specified" in result
@pytest.mark.asyncio
async def test_document_translate_no_text_attachments(mocked_context, mock_translation_agent):
"""Test document_translate returns error message when no text documents found."""
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(None)
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "No text documents found in the conversation" in result
@pytest.mark.asyncio
async def test_document_translate_error_reading_document(mocked_context, mock_translation_agent):
"""Test document_translate handles errors when reading documents."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "An unexpected error occurred during document translation" in result
@pytest.mark.asyncio
async def test_document_translate_error_during_translation(mocked_context, mock_translation_agent):
"""Test document_translate handles ModelRetry during translation."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_error(_messages, _info=None):
"""Mocked translation that raises an error."""
raise ValueError("Translation error")
with mock_translation_agent(FunctionModel(mocked_translate_error)):
with pytest.raises(ModelRetry):
await document_translate(
mocked_context, target_language="French", instructions=None
)
@pytest.mark.asyncio
async def test_document_translate_too_large(settings, mocked_context, mock_translation_agent):
"""Test document_translate rejects documents exceeding max chars."""
settings.TRANSLATION_MAX_CHARS = 100 # Very small limit
mock_attachment = mock.Mock()
mock_attachment.key = "large_doc.txt"
mock_attachment.file_name = "large_doc.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a word. " * 100 # Much larger than 100 chars
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "too large to translate" in result
@pytest.mark.asyncio
async def test_document_translate_empty_result(mocked_context, mock_translation_agent):
"""Test document_translate raises ModelRetry when translation produces empty result."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_attachment.size = None
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_empty_translation(_messages, _info=None):
"""Mocked translation that returns empty."""
return ModelResponse(parts=[TextPart(content=" ")])
with mock_translation_agent(FunctionModel(mocked_empty_translation)):
with pytest.raises(ModelRetry) as exc_info:
await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "produced an empty result" in str(exc_info.value)
@@ -190,6 +190,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -198,15 +199,29 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -317,6 +332,7 @@ def test_post_conversation_data_protocol_triggers_keepalives(
"Answer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -325,15 +341,29 @@ def test_post_conversation_data_protocol_triggers_keepalives(
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -438,6 +468,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -446,15 +477,29 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -624,6 +669,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -644,15 +690,29 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "I see a cat in the picture.", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "I see a cat in the picture.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -790,6 +850,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Weather in Paris?"],
@@ -798,23 +859,31 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {"finish_reason": "tool_calls"},
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -834,6 +903,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
@@ -845,17 +915,29 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
{
"content": "The current weather in Paris is nice",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {"finish_reason": "stop"},
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -992,6 +1074,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Weather in Paris?"],
@@ -1000,23 +1083,31 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {"finish_reason": "tool_calls"},
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1036,6 +1127,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "Unknown tool name: 'get_current_weather'. No tools available.",
@@ -1046,17 +1138,29 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{"content": "I cannot give you an answer to that.", "id": None, "part_kind": "text"}
{
"content": "I cannot give you an answer to that.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {"finish_reason": "stop"},
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1302,6 +1406,7 @@ def test_post_conversation_data_protocol_no_stream(
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Why the sky is blue?"],
@@ -1310,10 +1415,12 @@ def test_post_conversation_data_protocol_no_stream(
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
"parts": [
{
@@ -1321,12 +1428,18 @@ def test_post_conversation_data_protocol_no_stream(
"Rayleigh scattering.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {"finish_reason": "stop"},
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-09-22T14:13:49Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-92c413bb5a45426299335d0621324654",
"timestamp": "2025-09-22T14:13:49Z",
"provider_url": "https://www.external-ai-service.com",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
"cache_read_tokens": 0,
@@ -1442,6 +1555,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -1450,15 +1564,29 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
},
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1582,6 +1710,7 @@ async def test_post_conversation_async_triggers_keepalive(
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Hello"],
@@ -1590,15 +1719,29 @@ async def test_post_conversation_async_triggers_keepalive(
},
],
"run_id": _run_id,
"timestamp": ANY,
},
{
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"parts": [
{
"content": "Hello there",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {
"finish_reason": "stop",
"timestamp": ANY,
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": ANY,
"usage": {
"cache_audio_read_tokens": 0,
@@ -273,7 +273,7 @@ def test_post_conversation_with_document_upload(
api_client,
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -409,7 +409,7 @@ def test_post_conversation_with_document_upload(
assert chat_conversation.pydantic_messages[0] == {
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -424,6 +424,7 @@ def test_post_conversation_with_document_upload(
"Do not request re-upload of documents; consider them already "
"available via the internal store.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["What does the document say?"],
@@ -432,10 +433,12 @@ def test_post_conversation_with_document_upload(
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
@@ -444,11 +447,14 @@ def test_post_conversation_with_document_upload(
"part_kind": "tool-call",
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -465,7 +471,7 @@ def test_post_conversation_with_document_upload(
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -481,6 +487,7 @@ def test_post_conversation_with_document_upload(
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -498,21 +505,26 @@ def test_post_conversation_with_document_upload(
}
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "From the document, I can see that it says 'Hello PDF'.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -602,7 +614,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
api_client,
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
mock_summarization_agent, # pylint: disable=unused-argument
):
@@ -739,7 +751,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert chat_conversation.pydantic_messages[0] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -755,6 +767,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Make a summary of this document."],
@@ -763,10 +776,12 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
@@ -775,11 +790,14 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"part_kind": "tool-call",
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -796,7 +814,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -812,6 +830,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "The document discusses various topics.",
@@ -823,17 +842,26 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
}
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{"content": "The document discusses various topics.", "id": None, "part_kind": "text"}
{
"content": "The document discusses various topics.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timezone_now,
"usage": {
"cache_audio_read_tokens": 0,
@@ -73,12 +73,13 @@ def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
):
"""
Test POST to /api/v1/chats/{pk}/conversation/ with a document URL.
"""
responses.post(
"https://albert.api.etalab.gouv.fr/v1/collections",
json={"id": 123, "object": "collection"},
@@ -140,7 +141,7 @@ def test_post_conversation_with_local_pdf_document_url(
],
instructions=(
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from attached "
"documents. Do NOT use it to summarize; for summaries, call the summarize "
@@ -156,6 +157,7 @@ def test_post_conversation_with_local_pdf_document_url(
"via the internal store."
),
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is a document about a single pixel."
@@ -229,7 +231,7 @@ def test_post_conversation_with_local_pdf_document_url(
assert chat_conversation.pydantic_messages == [
{
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n"
"\n"
"Use document_search_rag ONLY to retrieve specific passages "
@@ -249,6 +251,7 @@ def test_post_conversation_with_local_pdf_document_url(
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal store.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -259,21 +262,26 @@ def test_post_conversation_with_local_pdf_document_url(
},
],
"run_id": _run_id,
"timestamp": timestamp,
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document about a single pixel.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timestamp,
"usage": {
"cache_audio_read_tokens": 0,
@@ -545,6 +553,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
@@ -558,7 +567,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
identifier="sample.pdf",
),
],
timestamp=timezone.now(),
timestamp=timestamp_now,
),
],
instructions="You are a helpful test assistant :)\n\n"
@@ -570,7 +579,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
parts=[TextPart(content="This is a document about a single pixel.")],
usage=RequestUsage(input_tokens=50, output_tokens=9),
model_name="function::agent_model",
timestamp=timezone.now(),
timestamp=timestamp_now,
run_id=messages[1].run_id,
),
ModelRequest(
@@ -579,9 +588,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
content=[
"Give more details about this document.",
],
timestamp=timezone.now(),
timestamp=timestamp_now,
)
],
timestamp=timestamp_now,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
@@ -738,6 +748,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"metadata": None,
"kind": "request",
"parts": [
{
@@ -747,21 +758,26 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
}
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document of square, very small and nice.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -791,7 +807,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
def test_post_conversation_with_local_not_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
file_name,
content_type,
@@ -853,6 +869,8 @@ def test_post_conversation_with_local_not_pdf_document_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
@@ -861,12 +879,13 @@ def test_post_conversation_with_local_not_pdf_document_url(
"What is in this document?",
# No presigned URL for non-PDF documents (not supporter by LLM)
],
timestamp=timezone.now(),
timestamp=timestamp_now,
),
],
timestamp=timestamp_now,
instructions=(
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -957,7 +976,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
{
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
f"{today_prompt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
@@ -974,6 +993,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
"consider them already available via the internal store."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -984,21 +1004,26 @@ def test_post_conversation_with_local_not_pdf_document_url(
},
],
"run_id": _run_id,
"timestamp": timestamp,
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is a document about you.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": timestamp,
"usage": {
"cache_audio_read_tokens": 0,
@@ -29,6 +29,10 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
PYAI_CURRENT = "current"
PYAI_V1_17 = "v1.17"
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
@@ -41,17 +45,9 @@ def ai_settings(settings):
return settings
@pytest.fixture(name="history_conversation")
def history_conversation_fixture():
"""Create a conversation with existing message history."""
# Create a timestamp for the first message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages
conversation = ChatConversationFactory()
# Add previous user and assistant messages
conversation.messages = [
def build__history_conversation_ui_messages(history_timestamp):
"""Build ui messages list for fixtures."""
return [
UIMessage(
id="prev-user-msg-1",
createdAt=history_timestamp,
@@ -120,94 +116,205 @@ def history_conversation_fixture():
),
]
@pytest.fixture(name="history_conversation")
def history_conversation_fixture(request):
"""Create a conversation with existing message history according to pydantic ai version."""
# Create a timestamp for the first message
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages
conversation = ChatConversationFactory()
pyai_version = getattr(request, "param", PYAI_CURRENT)
# Add previous user and assistant messages
if pyai_version == PYAI_V1_17:
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"vendor_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"vendor_details": None,
"vendor_id": None,
},
]
else:
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "some model",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"provider_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"finish_reason": "stop",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "test-model",
"provider_url": "https://www.external-ai-service.com/",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"provider_details": {
"timestamp": "2025-07-25T10:36:35.297675Z",
"finish_reason": "stop",
},
"provider_name": "test-model",
"provider_response_id": "xyz",
},
]
# Set up the OpenAI message format as well
conversation.pydantic_messages = [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
{
"content": ["How does machine learning work?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:30:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Machine learning is a branch of artificial intelligence that "
"focuses on building systems that learn from data."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:31:00.000000Z",
"usage": {
"details": None,
"request_tokens": 10,
"requests": 1,
"response_tokens": 20,
"total_tokens": 30,
},
"vendor_details": None,
"vendor_id": None,
},
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": ["What are neural networks?"],
"part_kind": "user-prompt",
"timestamp": "2025-06-15T10:32:00.000000Z",
},
],
},
{
"kind": "response",
"model_name": "test-model",
"parts": [
{
"content": (
"Neural networks are computing systems inspired by the "
"biological neural networks in animal brains."
),
"part_kind": "text",
}
],
"timestamp": "2025-06-15T10:33:00.000000Z",
"usage": {
"details": None,
"request_tokens": 5,
"requests": 1,
"response_tokens": 15,
"total_tokens": 20,
},
"vendor_details": None,
"vendor_id": None,
},
]
conversation.messages = build__history_conversation_ui_messages(history_timestamp)
conversation.save()
return conversation
@pytest.mark.parametrize("history_conversation", [PYAI_CURRENT, PYAI_V1_17], indirect=True)
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol_with_history(
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'data' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
@@ -1028,6 +1135,7 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1069,6 +1177,7 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1102,6 +1211,7 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1148,6 +1258,7 @@ def history_conversation_with_tool_fixture():
},
{
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
@@ -1384,6 +1495,7 @@ def test_post_conversation_with_existing_tool_history(
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["How about Paris weather?"],
@@ -1392,24 +1504,32 @@ def test_post_conversation_with_existing_tool_history(
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
}
assert history_conversation_with_tool.pydantic_messages[9] == {
"finish_reason": "tool_call",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{
"args": '{"location":"Paris", "unit":"celsius"}',
"id": None,
"part_kind": "tool-call",
"provider_details": None,
"provider_name": None,
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
"tool_name": "get_current_weather",
}
],
"provider_details": {"finish_reason": "tool_calls"},
"provider_details": {
"finish_reason": "tool_calls",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-tool-call",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -1429,6 +1549,7 @@ def test_post_conversation_with_existing_tool_history(
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
@@ -1440,18 +1561,30 @@ def test_post_conversation_with_existing_tool_history(
}
],
"run_id": _run_id,
"timestamp": "2025-07-25T10:36:35.297675Z",
}
assert history_conversation_with_tool.pydantic_messages[11] == {
"finish_reason": "stop",
"kind": "response",
"metadata": None,
"model_name": "test-model",
"parts": [
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
{
"content": "The current weather in Paris is nice",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": {"finish_reason": "stop"},
"provider_details": {
"finish_reason": "stop",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
"provider_name": "openai",
"provider_response_id": "chatcmpl-final",
"provider_url": "https://www.external-ai-service.com/",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -92,6 +92,7 @@ def test_post_conversation_with_local_image_url(
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
@@ -110,6 +111,7 @@ def test_post_conversation_with_local_image_url(
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is an image of a single pixel."
@@ -181,6 +183,7 @@ def test_post_conversation_with_local_image_url(
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -199,17 +202,26 @@ def test_post_conversation_with_local_image_url(
},
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{"content": "This is an image of a single pixel.", "id": None, "part_kind": "text"}
{
"content": "This is an image of a single pixel.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
@@ -229,7 +241,7 @@ def test_post_conversation_with_local_image_url(
@freeze_time()
def test_post_conversation_with_local_image_wrong_url(
api_client,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -275,7 +287,8 @@ def test_post_conversation_with_local_image_wrong_url(
timestamp=timezone.now(),
),
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
timestamp=timezone.now(),
instructions=f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
@@ -314,7 +327,7 @@ def test_post_conversation_with_local_image_wrong_url(
@freeze_time()
def test_post_conversation_with_remote_image_url(
api_client,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -361,8 +374,9 @@ def test_post_conversation_with_remote_image_url(
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
f"{today_prompt_date}\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is an image of a single pixel."
@@ -432,7 +446,7 @@ def test_post_conversation_with_remote_image_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_image_url_in_history(
api_client,
today_promt_date,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -475,7 +489,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
@@ -547,6 +561,8 @@ def test_post_conversation_with_local_image_url_in_history(
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
@@ -559,11 +575,11 @@ def test_post_conversation_with_local_image_url_in_history(
identifier="sample.png",
),
],
timestamp=timezone.now(),
timestamp=timestamp_now,
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
f"{today_prompt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -577,12 +593,13 @@ def test_post_conversation_with_local_image_url_in_history(
content=[
"Give more details about this image.",
],
timestamp=timezone.now(),
timestamp=timestamp_now,
)
],
run_id=messages[2].run_id,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
timestamp=timestamp_now,
),
]
yield "This is an image of square, very small and nice."
@@ -681,7 +698,7 @@ def test_post_conversation_with_local_image_url_in_history(
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
@@ -728,6 +745,7 @@ def test_post_conversation_with_local_image_url_in_history(
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Give more details about this image."],
@@ -736,21 +754,26 @@ def test_post_conversation_with_local_image_url_in_history(
}
],
"run_id": _run_id,
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"content": "This is an image of square, very small and nice.",
"id": None,
"part_kind": "text",
"provider_details": None,
"provider_name": None,
}
],
"provider_details": None,
"provider_name": None,
"provider_response_id": None,
"provider_url": None,
"timestamp": "2025-10-18T20:48:20.286204Z",
"usage": {
"cache_audio_read_tokens": 0,
+1 -9
View File
@@ -4,7 +4,6 @@ import asyncio
import logging
from django.conf import settings
from django.core.files.storage import default_storage
import semchunk
from asgiref.sync import sync_to_async
@@ -14,18 +13,11 @@ from pydantic_ai.messages import ToolReturn
from chat.agents.summarize import SummarizationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
logger = logging.getLogger(__name__)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
"""Summarize a single chunk of text."""
sum_prompt = (
@@ -0,0 +1,136 @@
"""Translation tool used for uploaded documents."""
import logging
from django.conf import settings
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from chat.agents.translate import TranslationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
logger = logging.getLogger(__name__)
@last_model_retry_soft_fail
async def document_translate(
ctx: RunContext,
*,
target_language: str | None = None,
instructions: str | None = None,
) -> ToolReturn:
"""
Translate the full content of the last uploaded document into the specified target language.
Preserve the original markdown formatting unless the instructions say otherwise.
Return this translation directly to the user WITHOUT any modification
or additional summarization.
The translation is already complete and MUST be presented as-is in the final response.
If target_language isn't specified or unknown, the target language should be asked
to the user.
Instructions are optional but should reflect the user's request.
Examples:
"Translate this doc to English" -> target_language = "English", instructions = ""
"Translate to Spanish, in formal tone" -> target_language = "Spanish",
instructions = "Use formal tone"
"Traduis ce document en français" -> target_language = "French", instructions = ""
"Translate to German, keep technical terms in English" -> target_language = "German",
instructions = "Keep technical terms in English"
"Translate this" -> ask the user: "Which language would you like the document
translated into?"
Args:
target_language (str | None): The language to translate the document into.
If None, ask the user.
instructions (str | None): Optional instructions for the translation style or preferences
"""
try:
if not target_language:
raise ModelCannotRetry(
"The target language is not specified. "
"You must ask the user which language they want the document translated into."
)
instructions_hint = (
f"Follow these instructions: {instructions.strip()}" if instructions else ""
)
translation_agent = TranslationAgent()
# Get the last uploaded text document
last_attachment = await (
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
.order_by("-created_at")
.afirst()
)
if not last_attachment:
raise ModelCannotRetry(
"No text documents found in the conversation. "
"You must explain this to the user and ask them to provide documents."
)
doc_name, content = await read_document_content(last_attachment)
max_chars = settings.TRANSLATION_MAX_CHARS
if len(content) > max_chars:
raise ModelCannotRetry(
f"The document is too large to translate ({len(content):,} characters, "
f"limit is {max_chars:,}). "
"You must explain this to the user, without providing numerical details. "
"Suggest them to reduce the document size by summarizing it or "
"by splitting it into smaller parts. "
"Also offer them to summarize the document in the target language instead, "
"which can be a good alternative to translation for large documents."
)
logger.info(
"[translate] translating '%s', %s chars, target_language='%s', instructions='%s'",
doc_name,
len(content),
target_language,
instructions_hint,
)
# Translate the document directly
translate_prompt = (
f"You are an agent specializing in text translation. "
f"Translate the following document to {target_language}. "
f"Preserve all markdown formatting exactly as-is. "
f"{instructions_hint}\n\n"
f"'''\n{content}\n'''\n\n"
f"Respond directly with the translated text only, no commentary."
)
logger.debug("[translate] prompt for '%s'=> %s", doc_name, translate_prompt[:100] + "...")
try:
resp = await translation_agent.run(translate_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during translation of '%s': %s", doc_name, exc, exc_info=True)
raise ModelRetry(f"An error occurred while translating document '{doc_name}'.") from exc
translated_text = (resp.output or "").strip()
if not translated_text:
raise ModelRetry(f"The translation of '{doc_name}' produced an empty result.")
logger.debug("[translate] final translation length: %s chars", len(translated_text))
return ToolReturn(
return_value=translated_text,
metadata={"sources": {doc_name}},
)
except (ModelCannotRetry, ModelRetry):
raise
except Exception as exc:
logger.exception("Unexpected error in document_translate: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during document translation: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
+10
View File
@@ -4,6 +4,9 @@ import functools
import logging
from typing import Any, Callable
from django.core.files.storage import default_storage
from asgiref.sync import sync_to_async
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
@@ -48,3 +51,10 @@ def last_model_retry_soft_fail(
raise # Re-raise to allow retrying
return wrapper
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
+7
View File
@@ -883,6 +883,13 @@ USER QUESTION:
environ_prefix=None,
)
# Translation
TRANSLATION_MAX_CHARS = values.PositiveIntegerValue(
default=100_000, # ~100k characters, roughly half a 128k context window
environ_name="TRANSLATION_MAX_CHARS",
environ_prefix=None,
)
# Tavily API
TAVILY_API_KEY = values.Value(
None, # Tavily API key is not set by default
+8 -3
View File
@@ -56,7 +56,7 @@ dependencies = [
"nested-multipart-parser==1.6.0",
"posthog==7.0.0",
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.17.0",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.56.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
@@ -108,6 +108,11 @@ zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv]
override-dependencies = [
"cryptography>=46.0.5", # CVE-2026-26007
]
[tool.uv.build-backend]
module-root = ""
source-exclude = [
@@ -147,8 +152,8 @@ select = [
]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","conversations","first-party","local-folder"]
sections = { conversations=["core"], django=["django"] }
section-order = ["future", "standard-library", "django", "third-party", "conversations", "first-party", "local-folder"]
sections = { conversations = ["core"], django = ["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
+70 -42
View File
@@ -7,6 +7,9 @@ resolution-markers = [
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[manifest]
overrides = [{ name = "cryptography", specifier = ">=46.0.5" }]
[[package]]
name = "amqp"
version = "5.3.1"
@@ -505,7 +508,7 @@ requires-dist = [
{ name = "posthog", specifier = "==7.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.12" },
{ name = "pydantic", specifier = "==2.12.4" },
{ name = "pydantic-ai-slim", extras = ["openai", "mistral", "mcp", "evals", "logfire"], specifier = "==1.17.0" },
{ name = "pydantic-ai-slim", extras = ["openai", "mistral", "mcp", "evals", "logfire"], specifier = "==1.56.0" },
{ name = "pyfakefs", marker = "extra == 'dev'", specifier = "==5.10.2" },
{ name = "pyjwt", specifier = "==2.10.1" },
{ name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.9" },
@@ -584,43 +587,41 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.3"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
@@ -2163,7 +2164,7 @@ wheels = [
[[package]]
name = "pydantic-ai-slim"
version = "1.17.0"
version = "1.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "genai-prices" },
@@ -2174,9 +2175,9 @@ dependencies = [
{ name = "pydantic-graph" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/bc/39f1dca02883372ccccd82e55b21a0bf4d248e69f40a22a6e177a285781a/pydantic_ai_slim-1.17.0.tar.gz", hash = "sha256:7c6a10b0842819cd1328dc6d0b64faba4ae59b78d9a97b53910aff1a28108e0a", size = 301616, upload-time = "2025-11-14T00:40:17.329Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/5c/3a577825b9c1da8f287be7f2ee6fe9aab48bc8a80e65c8518052c589f51c/pydantic_ai_slim-1.56.0.tar.gz", hash = "sha256:9f9f9c56b1c735837880a515ae5661b465b40207b25f3a3434178098b2137f05", size = 415265, upload-time = "2026-02-06T01:13:23.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/16/be20a655d6a9323165306b0889b2f04b985025a7f195c486e4292fd51f74/pydantic_ai_slim-1.17.0-py3-none-any.whl", hash = "sha256:2fc64bad8b6396a2af32c1ff04d73f4cde62ba15c28329cc98dbae47651cad90", size = 401934, upload-time = "2025-11-14T00:40:03.326Z" },
{ url = "https://files.pythonhosted.org/packages/62/4b/34682036528eeb9aaf093c2073540ddf399ab37b99d282a69ca41356f1aa/pydantic_ai_slim-1.56.0-py3-none-any.whl", hash = "sha256:d657e4113485020500b23b7390b0066e2a0277edc7577eaad2290735ca5dd7d5", size = 542270, upload-time = "2026-02-06T01:13:14.918Z" },
]
[package.optional-dependencies]
@@ -2194,6 +2195,7 @@ mistral = [
]
openai = [
{ name = "openai" },
{ name = "tiktoken" },
]
[[package]]
@@ -2223,7 +2225,7 @@ wheels = [
[[package]]
name = "pydantic-evals"
version = "1.17.0"
version = "1.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2233,14 +2235,14 @@ dependencies = [
{ name = "pyyaml" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/f5/4126cedb4c65e54c13d44b03a9551ed8cf6d4b1c0551e61a2ed9f700c73c/pydantic_evals-1.17.0.tar.gz", hash = "sha256:54e24324fb99b453b27817a8c51510a56282a41bc7968e1e5585355cf0c1aea1", size = 46978, upload-time = "2025-11-14T00:40:18.719Z" }
sdist = { url = "https://files.pythonhosted.org/packages/98/f2/8c59284a2978af3fbda45ae3217218eaf8b071207a9290b54b7613983e5d/pydantic_evals-1.56.0.tar.gz", hash = "sha256:206635107127af6a3ee4b1fc8f77af6afb14683615a2d6b3609f79467c1c0d28", size = 47210, upload-time = "2026-02-06T01:13:25.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/04/2ddffbd5b45388e7e0f6019670e4cd6cf524fef07597938107f0e6aeb431/pydantic_evals-1.17.0-py3-none-any.whl", hash = "sha256:a7447c99ca86bf68880c3078f1b751c418145a725185bce99b604dab9479bf1a", size = 56134, upload-time = "2025-11-14T00:40:06.793Z" },
{ url = "https://files.pythonhosted.org/packages/89/51/9875d19ff6d584aaeb574aba76b49d931b822546fc60b29c4fc0da98170d/pydantic_evals-1.56.0-py3-none-any.whl", hash = "sha256:d1efb410c97135aabd2a22453b10c981b2b9851985e9354713af67ae0973b7a9", size = 56407, upload-time = "2026-02-06T01:13:17.098Z" },
]
[[package]]
name = "pydantic-graph"
version = "1.17.0"
version = "1.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -2248,9 +2250,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/c8/7d41e6f07d2e81851036552a6a4cd62b100509bfeafceb2e46051beea7b0/pydantic_graph-1.17.0.tar.gz", hash = "sha256:0e673f049fa5e86443ea90f0b79d8e24696b2d49545d31556933a08b9363633b", size = 57983, upload-time = "2025-11-14T00:40:19.876Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/03/f92881cdb12d6f43e60e9bfd602e41c95408f06e2324d3729f7a194e2bcd/pydantic_graph-1.56.0.tar.gz", hash = "sha256:5e22972dbb43dbc379ab9944252ff864019abf3c7d465dcdf572fc8aec9a44a1", size = 58460, upload-time = "2026-02-06T01:13:26.708Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/6c/a3ccda382ad69d5549da186fe349b17efd1f4ad7bf871584186381a1bc31/pydantic_graph-1.17.0-py3-none-any.whl", hash = "sha256:436e11e8ca5bd6a99e5e2ba50a42f958bfff65aeb8a40aef83ae5da1bb8b5891", size = 72003, upload-time = "2025-11-14T00:40:08.329Z" },
{ url = "https://files.pythonhosted.org/packages/08/07/8c823eb4d196137c123d4d67434e185901d3cbaea3b0c2b7667da84e72c1/pydantic_graph-1.56.0-py3-none-any.whl", hash = "sha256:ec3f0a1d6fcedd4eb9c59fef45079c2ee4d4185878d70dae26440a9c974c6bb3", size = 72346, upload-time = "2026-02-06T01:13:18.792Z" },
]
[[package]]
@@ -2888,6 +2890,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "tiktoken"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "regex" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
]
[[package]]
name = "tld"
version = "0.13.1"
@@ -145,10 +145,8 @@ export const commonGlobals = {
export const whiteLabelGlobals = {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'logo-1': '#4844AD',
'logo-2': '#4844AD',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -1,8 +1,8 @@
:root {
--c--globals--colors--logo-1-light: #4844ad;
--c--globals--colors--logo-2-light: #4844ad;
--c--globals--colors--logo-1-dark: #bec5f0;
--c--globals--colors--logo-2-dark: #bec5f0;
--c--globals--colors--logo-1-light: #377fdb;
--c--globals--colors--logo-2-light: #377fdb;
--c--globals--colors--logo-1-dark: #c1d6f2;
--c--globals--colors--logo-2-dark: #c1d6f2;
--c--globals--colors--brand-050: #eef1fa;
--c--globals--colors--brand-100: #dde2f5;
--c--globals--colors--brand-150: #ced3f1;
@@ -332,6 +332,8 @@
--c--globals--colors--white-900: #f8f8f9e5;
--c--globals--colors--white-950: #f8f8f9f2;
--c--globals--colors--white-975: #f8f8f9f9;
--c--globals--colors--logo-1: #4844ad;
--c--globals--colors--logo-2: #4844ad;
--c--globals--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--globals--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--globals--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -893,10 +895,10 @@
}
.cunningham-theme--dark {
--c--globals--colors--logo-1-light: #4844ad;
--c--globals--colors--logo-2-light: #4844ad;
--c--globals--colors--logo-1-dark: #bec5f0;
--c--globals--colors--logo-2-dark: #bec5f0;
--c--globals--colors--logo-1-light: #377fdb;
--c--globals--colors--logo-2-light: #377fdb;
--c--globals--colors--logo-1-dark: #c1d6f2;
--c--globals--colors--logo-2-dark: #c1d6f2;
--c--globals--colors--brand-050: #eef1fa;
--c--globals--colors--brand-100: #dde2f5;
--c--globals--colors--brand-150: #ced3f1;
@@ -1226,6 +1228,8 @@
--c--globals--colors--white-900: #f8f8f9e5;
--c--globals--colors--white-950: #f8f8f9f2;
--c--globals--colors--white-975: #f8f8f9f9;
--c--globals--colors--logo-1: #4844ad;
--c--globals--colors--logo-2: #4844ad;
--c--globals--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--globals--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--globals--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -4854,6 +4858,14 @@
color: var(--c--globals--colors--white-975);
}
.clr-logo-1 {
color: var(--c--globals--colors--logo-1);
}
.clr-logo-2 {
color: var(--c--globals--colors--logo-2);
}
.bg-logo-1-light {
background-color: var(--c--globals--colors--logo-1-light);
}
@@ -6186,6 +6198,14 @@
background-color: var(--c--globals--colors--white-975);
}
.bg-logo-1 {
background-color: var(--c--globals--colors--logo-1);
}
.bg-logo-2 {
background-color: var(--c--globals--colors--logo-2);
}
.bg-surface-primary {
background-color: var(--c--contextuals--background--surface--primary);
}
@@ -3,10 +3,10 @@ export const tokens = {
default: {
globals: {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'logo-1-light': '#377FDB',
'logo-2-light': '#377FDB',
'logo-1-dark': '#C1D6F2',
'logo-2-dark': '#C1D6F2',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -336,6 +336,8 @@ export const tokens = {
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
'logo-1': '#4844AD',
'logo-2': '#4844AD',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -547,8 +549,8 @@ export const tokens = {
},
},
content: {
logo1: '#4844AD',
logo2: '#4844AD',
logo1: '#377FDB',
logo2: '#377FDB',
semantic: {
contextual: { primary: '#F8F8F9F2' },
overlay: { primary: '#F8F8F9F2' },
@@ -681,10 +683,10 @@ export const tokens = {
dark: {
globals: {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'logo-1-light': '#377FDB',
'logo-2-light': '#377FDB',
'logo-1-dark': '#C1D6F2',
'logo-2-dark': '#C1D6F2',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
@@ -1014,6 +1016,8 @@ export const tokens = {
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
'logo-1': '#4844AD',
'logo-2': '#4844AD',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -1225,8 +1229,8 @@ export const tokens = {
},
},
content: {
logo1: '#BEC5F0',
logo2: '#BEC5F0',
logo1: '#C1D6F2',
logo2: '#C1D6F2',
semantic: {
contextual: { primary: '#1B1B23D9' },
overlay: { primary: '#1B1B23D9' },
@@ -1876,8 +1880,8 @@ export const tokens = {
},
},
content: {
logo1: '#4844AD',
logo2: '#4844AD',
logo1: '#377FDB',
logo2: '#377FDB',
semantic: {
contextual: { primary: '#F8F8F9F2' },
overlay: { primary: '#F8F8F9F2' },
@@ -2527,8 +2531,8 @@ export const tokens = {
},
},
content: {
logo1: '#BEC5F0',
logo2: '#BEC5F0',
logo1: '#C1D6F2',
logo2: '#C1D6F2',
semantic: {
contextual: { primary: '#1B1B23D9' },
overlay: { primary: '#1B1B23D9' },
@@ -1,18 +1,13 @@
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
import { Message, SourceUIPart } from '@ai-sdk/ui-utils';
import { Modal, ModalSize } from '@openfun/cunningham-react';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { useRouter } from 'next/router';
import { use, useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Box, Icon, Loader, Text } from '@/components';
import { Box, Loader, Text } from '@/components';
import { useUploadFile } from '@/features/attachments/hooks/useUploadFile';
import { useChat } from '@/features/chat/api/useChat';
import { getConversation } from '@/features/chat/api/useConversation';
@@ -21,13 +16,9 @@ import {
LLMModel,
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { ChatError } from '@/features/chat/components/ChatError';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import { InputChat } from '@/features/chat/components/InputChat';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem';
import { MessageItem } from '@/features/chat/components/MessageItem';
import { useClipboard } from '@/hook';
import { useResponsiveStore } from '@/stores';
@@ -35,9 +26,6 @@ import { useSourceMetadataCache } from '../hooks';
import { useChatPreferencesStore } from '../stores/useChatPreferencesStore';
import { usePendingChatStore } from '../stores/usePendingChatStore';
import { useScrollStore } from '../stores/useScrollStore';
import { getHighlighter } from '../utils/shiki';
const highlighterPromise = getHighlighter();
// Define Attachment type locally (mirroring backend structure)
export interface Attachment {
@@ -54,7 +42,7 @@ export const Chat = ({
const { t } = useTranslation();
const copyToClipboard = useClipboard();
const { isMobile } = useResponsiveStore();
const highlighter = use(highlighterPromise);
const streamProtocol = 'data'; // or 'text'
const {
@@ -292,21 +280,21 @@ export const Chat = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages]);
const openSources = (messageId: string) => {
if (isSourceOpen === messageId) {
setIsSourceOpen(null);
return;
}
const message = messages.find((msg) => msg.id === messageId);
if (message?.parts) {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
if (sourceParts.length > 0) {
setIsSourceOpen(messageId);
}
}
};
const openSources = useCallback((messageId: string) => {
// Source-parts guard is handled at the call site (MessageItem only shows the button when sourceParts.length > 0),
// so we just toggle it here.
setIsSourceOpen((prev) => (prev === messageId ? null : messageId));
}, []);
// Memoize the last assistant message index to avoid recalculating in render
const lastAssistantMessageIndex = useMemo(() => {
return messages.findLastIndex((msg) => msg.role === 'assistant');
}, [messages]);
// Memoize whether this is the first conversation (2 or fewer messages)
const isFirstConversationMessage = useMemo(() => {
return messages.length <= 2;
}, [messages.length]);
// Calculer la hauteur pour le message de streaming
const calculateStreamingHeight = useCallback(() => {
@@ -645,319 +633,26 @@ export const Chat = ({
>
{messages.length > 0 && (
<Box>
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastAssistantMessageInConversation =
message.role === 'assistant' &&
index ===
messages.findLastIndex((msg) => msg.role === 'assistant');
const isFirstConversationMessage = messages.length <= 2;
const shouldApplyStreamingHeight =
isLastAssistantMessageInConversation &&
isLastMessage &&
streamingMessageHeight &&
!isFirstConversationMessage;
const isCurrentlyStreaming =
isLastAssistantMessageInConversation &&
(status === 'streaming' || status === 'submitted');
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
className={`chatMessage ${message.role === 'user' ? 'chatMessage--user' : 'chatMessage--assistant'}`}
style={
shouldApplyStreamingHeight
? { minHeight: `${streamingMessageHeight}px` }
: undefined
}
>
{/* Message content */}
{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>
) : (
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
[
rehypeShikiFromHighlighter,
highlighter,
{
theme: 'github-dark-dimmed',
fallbackLanguage: 'plaintext',
},
],
rehypeKatex,
]}
components={{
// Custom components for Markdown rendering
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
</MarkdownHooks>
)}
</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',
}}
>
<Loader />
<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 === 'tool-invocation')
.map(
(part: ToolInvocationUIPart, partIndex: number) =>
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="font-size: 12px;"
$direction="row"
$align="center"
className="clr-content-semantic-neutral-secondary"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$theme="neutral"
$variation="secondary"
$direction="row"
$align="center"
$gap="4px"
className="c__button c__button--brand c__button--brand--tertiary c__button--nano clr-content-semantic-neutral-secondary"
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"
$variation="550"
$size="16px"
/>
{!isMobile && (
<Text $theme="neutral" $variation="secondary">
{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 c__button--brand c__button--brand--tertiary c__button--nano ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => openSources(message.id)}
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
openSources(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">
{/* We should display the button, but disabled if no trace linked */}
{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>
);
})}
{messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
isLastMessage={index === messages.length - 1}
isLastAssistantMessage={
message.role === 'assistant' &&
index === lastAssistantMessageIndex
}
isFirstConversationMessage={isFirstConversationMessage}
streamingMessageHeight={streamingMessageHeight}
status={status}
conversationId={conversationId}
isSourceOpen={isSourceOpen}
isMobile={isMobile}
onCopyToClipboard={copyToClipboard}
onOpenSources={openSources}
getMetadata={getMetadata}
/>
))}
</Box>
)}
{(status !== 'ready' && status !== 'streaming' && status !== 'error') ||
@@ -213,39 +213,56 @@ export const InputChat = ({
}
}, [status, input]);
const handleFilesAccepted = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const dt = new DataTransfer();
const validateAndAddFiles = useCallback(
(filesToAdd: File[]) => {
const acceptedFiles: File[] = [];
const rejectedFiles: File[] = [];
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
filesToAdd.forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file);
}
// Add new files (avoiding duplicates)
acceptedFiles.forEach((f) => {
const isDuplicate = Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
);
if (!isDuplicate) {
dt.items.add(f);
}
});
return dt.files;
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
// Keep existing files
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
// Add new files (avoiding duplicates)
acceptedFiles.forEach((f) => {
const isDuplicate = Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
);
if (!isDuplicate) {
dt.items.add(f);
}
});
return dt.files;
});
}
},
[setFiles],
[isFileAccepted, showToastError, setFiles],
);
const { isDragActive } = useFileDragDrop({
enabled: fileUploadEnabled,
isFileAccepted,
onFilesAccepted: handleFilesAccepted,
onFilesAccepted: validateAndAddFiles,
onFilesRejected: () => showToastError(),
});
@@ -293,6 +310,41 @@ export const InputChat = ({
[],
);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!fileUploadEnabled) {
return;
}
const clipboardData = e.clipboardData;
if (!clipboardData) {
return;
}
// Due to browser limitations, only one file can be pasted at a time
// Check files first (for files from file system)
let file: File | null = null;
if (clipboardData.files && clipboardData.files.length > 0) {
file = clipboardData.files[0];
} else if (clipboardData.items) {
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.kind === 'file') {
file = item.getAsFile();
break;
}
}
}
if (file) {
e.preventDefault();
validateAndAddFiles([file]);
}
},
[fileUploadEnabled, validateAndAddFiles],
);
const handleAttachClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
@@ -309,46 +361,10 @@ export const InputChat = ({
return;
}
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length > 0) {
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
acceptedFiles.forEach((f) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
return dt.files;
});
}
validateAndAddFiles(Array.from(fileList));
e.target.value = '';
},
[isFileAccepted, showToastError, setFiles],
[validateAndAddFiles],
);
const handleAttachmentRemove = useCallback(
@@ -443,6 +459,7 @@ export const InputChat = ({
name="inputchat-textarea"
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onPaste={handlePaste}
disabled={isInputDisabled}
rows={1}
style={textareaStyle}
@@ -0,0 +1,89 @@
// Memoized components for a single completed markdown blocks - only re-renders when content changes
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import React, { use } from 'react';
import { Components, MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { Text } from '@/components';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
// Memoized markdown plugins - created once at module level
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const REMARK_PLUGINS: any[] = [remarkGfm, remarkMath];
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
import { getHighlighter } from '../utils/shiki';
const highlighterPromise = getHighlighter();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rehypePluginsPromise: Promise<any[]> = highlighterPromise.then(
(highlighter) => [
rehypeKatex,
[
rehypeShikiFromHighlighter,
highlighter,
{
theme: 'github-dark-dimmed',
fallbackLanguage: 'plaintext',
},
],
],
);
// Memoized markdown components - created once at module level
const MARKDOWN_COMPONENTS: Components = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
pre: ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
node,
children,
...props
}) => <CodeBlock {...props}>{children}</CodeBlock>,
};
export const CompletedMarkdownBlock = React.memo(
({ content }: { content: string }) => {
const rehypePlugins = use(rehypePluginsPromise);
return (
<MarkdownHooks
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={rehypePlugins}
components={MARKDOWN_COMPONENTS}
>
{content}
</MarkdownHooks>
);
},
(prev, next) => prev.content === next.content,
);
CompletedMarkdownBlock.displayName = 'CompletedMarkdownBlock';
export const RawTextBlock = ({ content }: { content: string }) => (
<Text
as="div"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{content}
</Text>
);
@@ -0,0 +1,554 @@
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Loader, Text } from '@/components';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import {
CompletedMarkdownBlock,
RawTextBlock,
} from '@/features/chat/components/MessageBlock';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem';
// Memoized blocks list to prevent parent re-renders from causing block remounts
const BlocksList = React.memo(
({ blocks, pending }: { blocks: string[]; pending: string }) => (
<div>
{/* key={index} is safe here: blocks are append-only during streaming
and a completed block's content never changes once finalized. */}
{blocks.map((block, index) => (
<CompletedMarkdownBlock key={index} content={block} />
))}
{pending && <RawTextBlock content={pending} />}
</div>
),
(prev, next) => {
const lengthChanged = prev.blocks.length !== next.blocks.length;
const pendingChanged = prev.pending !== next.pending;
let blocksChanged = false;
for (let i = 0; i < Math.min(prev.blocks.length, next.blocks.length); i++) {
if (prev.blocks[i] !== next.blocks[i]) {
blocksChanged = true;
}
}
if (lengthChanged || pendingChanged || blocksChanged) {
return false; // needs re-render
}
return true;
},
);
BlocksList.displayName = 'BlocksList';
export interface StreamingContent {
completedBlocks: string[];
pending: string;
}
/**
* Splits content into blocks by double newlines, respecting code fences.
* Code fences may contain double newlines, so we merge blocks until fences are balanced.
*/
export const splitIntoBlocks = (content: string): string[] => {
if (!content) {
return [];
}
const rawBlocks = content.split('\n\n');
const blocks: string[] = [];
let currentBlock = '';
let fenceCount = 0;
for (const rawBlock of rawBlocks) {
const fences = (rawBlock.match(/```/g) || []).length;
currentBlock = currentBlock ? currentBlock + '\n\n' + rawBlock : rawBlock;
fenceCount += fences;
// Balanced fences = complete block
if (fenceCount % 2 === 0) {
if (currentBlock.trim()) {
blocks.push(currentBlock);
}
currentBlock = '';
fenceCount = 0;
}
}
if (currentBlock.trim()) {
blocks.push(currentBlock);
}
return blocks;
};
/**
* Splits streaming content into completed blocks (safe and ready to render as markdown)
* + a pending content (still being streamed, rendered as raw text).
*
* A block is considered completed when followed by a double newline.
* Each block is returned separately to enable independent memoization.
* NB: it respects code fences (``` ... ```) that may contain double newlines.
*/
export const splitStreamingContent = (content: string): StreamingContent => {
if (!content) {
return { completedBlocks: [], pending: '' };
}
// Find all code fence positions
// Note: this counts all ``` occurrences including those inside inline code spans.
// In practice this is unlikely to cause issues since inline code rarely contains ```.
const fenceRegex = /```/g;
const fences: number[] = [];
let match;
while ((match = fenceRegex.exec(content)) !== null) {
fences.push(match.index);
}
// Check if we're inside an unclosed code fence
const isInsideCodeFence = fences.length % 2 === 1;
let completedContent: string;
let pendingContent: string;
if (isInsideCodeFence) {
// Find the last opening fence
const lastFenceStart = fences[fences.length - 1];
// Everything before the unclosed fence is potentially complete
const beforeFence = content.slice(0, lastFenceStart);
const fenceAndAfter = content.slice(lastFenceStart);
// Find the last complete block boundary before the fence
const lastDoubleNewline = beforeFence.lastIndexOf('\n\n');
if (lastDoubleNewline !== -1) {
completedContent = beforeFence.slice(0, lastDoubleNewline);
pendingContent = beforeFence.slice(lastDoubleNewline) + fenceAndAfter;
} else {
// No complete blocks before fence
return { completedBlocks: [], pending: content };
}
} else {
// Not inside a code fence - find the last double newline as block boundary
const lastDoubleNewline = content.lastIndexOf('\n\n');
if (lastDoubleNewline === -1) {
// No double newline yet - everything is pending
return { completedBlocks: [], pending: content };
}
// Content up to the last \n\n is complete
completedContent = content.slice(0, lastDoubleNewline);
// Content after the last \n\n is pending (may be empty if content ends with \n\n)
pendingContent = content.slice(lastDoubleNewline + 2);
}
const completedBlocks = splitIntoBlocks(completedContent);
return { completedBlocks, pending: pendingContent };
};
interface SourceMetadata {
title: string | null;
favicon: string | null;
loading: boolean;
error: boolean;
}
export interface MessageItemProps {
message: Message;
isLastMessage: boolean;
isLastAssistantMessage: boolean;
isFirstConversationMessage: boolean;
streamingMessageHeight: number | null;
status: 'submitted' | 'streaming' | 'ready' | 'error';
conversationId: string | undefined;
isSourceOpen: string | null;
isMobile: boolean;
onCopyToClipboard: (content: string) => void;
onOpenSources: (messageId: string) => void;
getMetadata: (url: string) => SourceMetadata | undefined;
}
const MessageItemComponent: React.FC<MessageItemProps> = ({
message,
isLastMessage,
isLastAssistantMessage,
isFirstConversationMessage,
streamingMessageHeight,
status,
conversationId,
isSourceOpen,
isMobile,
onCopyToClipboard,
onOpenSources,
getMetadata,
}) => {
const { t } = useTranslation();
const shouldApplyStreamingHeight =
isLastAssistantMessage &&
isLastMessage &&
streamingMessageHeight &&
!isFirstConversationMessage;
const isCurrentlyStreaming =
isLastAssistantMessage &&
(status === 'streaming' || status === 'submitted');
const sourceParts = React.useMemo(() => {
if (!message.parts) {
return [];
}
return message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
}, [message.parts]);
const toolInvocationParts = React.useMemo(() => {
if (!message.parts) {
return [];
}
return message.parts.filter(
(part): part is ToolInvocationUIPart => part.type === 'tool-invocation',
);
}, [message.parts]);
const hasNonDocumentParsingTool = React.useMemo(() => {
return toolInvocationParts.some(
(part) => part.toolInvocation.toolName !== 'document_parsing',
);
}, [toolInvocationParts]);
const activeToolInvocation = React.useMemo(() => {
const tool = toolInvocationParts.find(
(part) => part.toolInvocation.toolName !== 'document_parsing',
);
return tool?.toolInvocation;
}, [toolInvocationParts]);
const activeToolInvocationDisplayName = React.useMemo(() => {
if (!activeToolInvocation) {
return '';
}
if (activeToolInvocation.toolName === 'summarize') {
return t('Summarizing...');
}
if (activeToolInvocation.toolName === 'translate') {
return t('Translation in progress...');
}
return t('Search...');
}, [activeToolInvocation, t]);
// Memoize the streaming content split to avoid recreating components in JSX
const { completedBlocks, pending } = React.useMemo(() => {
// When not streaming, everything is completed as a single block array
if (!isCurrentlyStreaming) {
return {
completedBlocks: splitIntoBlocks(message.content),
pending: '',
};
}
return splitStreamingContent(message.content);
}, [isCurrentlyStreaming, message.content]);
const handleCopy = React.useCallback(() => {
onCopyToClipboard(message.content);
}, [onCopyToClipboard, message.content]);
const handleCopyKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopyToClipboard(message.content);
}
},
[onCopyToClipboard, message.content],
);
const handleOpenSources = React.useCallback(() => {
onOpenSources(message.id);
}, [onOpenSources, message.id]);
const handleOpenSourcesKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenSources(message.id);
}
},
[onOpenSources, message.id],
);
return (
<Box
data-message-id={message.id}
data-testid={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessage ? '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 */}
{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>
) : (
// Render completed blocks as markdown, pending block as plain text
<BlocksList blocks={completedBlocks} pending={pending} />
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessage &&
status === 'streaming' &&
hasNonDocumentParsingTool && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{activeToolInvocationDisplayName}
</Text>
</Box>
)}
{toolInvocationParts.map((part, partIndex) =>
isCurrentlyStreaming && isLastAssistantMessage ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(isLastAssistantMessage && 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={handleCopy}
onKeyDown={handleCopyKeyDown}
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>
{sourceParts.length > 0 && (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={handleOpenSources}
onKeyDown={handleOpenSourcesKeyDown}
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')} {sourceParts.length}{' '}
{sourceParts.length !== 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>
)}
{isSourceOpen === message.id && sourceParts.length > 0 && (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList parts={sourceParts} getMetadata={getMetadata} />
</Box>
)}
</Box>
</Box>
</Box>
);
};
MessageItemComponent.displayName = 'MessageItem';
// Custom comparison function for React.memo
// Only re-render when props that affect rendering change
const arePropsEqual = (
prevProps: MessageItemProps,
nextProps: MessageItemProps,
): boolean => {
// Always re-render if message content changed
if (prevProps.message.id !== nextProps.message.id) {
return false;
}
if (prevProps.message.content !== nextProps.message.content) {
return false;
}
if (prevProps.message.role !== nextProps.message.role) {
return false;
}
// Check parts changes (for streaming tool invocations and sources)
const prevPartsLength = prevProps.message.parts?.length ?? 0;
const nextPartsLength = nextProps.message.parts?.length ?? 0;
if (prevPartsLength !== nextPartsLength) {
return false;
}
// Check attachments
const prevAttachmentsLength =
prevProps.message.experimental_attachments?.length ?? 0;
const nextAttachmentsLength =
nextProps.message.experimental_attachments?.length ?? 0;
if (prevAttachmentsLength !== nextAttachmentsLength) {
return false;
}
// Check rendering flags
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
return false;
}
if (prevProps.isLastAssistantMessage !== nextProps.isLastAssistantMessage) {
return false;
}
if (
prevProps.isFirstConversationMessage !==
nextProps.isFirstConversationMessage
) {
return false;
}
if (prevProps.streamingMessageHeight !== nextProps.streamingMessageHeight) {
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isSourceOpen !== nextProps.isSourceOpen) {
return false;
}
if (prevProps.isMobile !== nextProps.isMobile) {
return false;
}
if (prevProps.conversationId !== nextProps.conversationId) {
return false;
}
return true;
};
export const MessageItem = React.memo(MessageItemComponent, arePropsEqual);
@@ -23,7 +23,6 @@ const SourceItemListComponent: React.FC<SourceItemListProps> = ({
if (parts.length === 0) {
return null;
}
return (
<Box
$direction="column"
@@ -3,13 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
const SUGGESTION_KEYS = [
'Ask a question',
'Turn this list into bullet points',
'Write a short product description',
'Find recent news about...',
] as const;
const SUGGESTIONS_COUNT = SUGGESTION_KEYS.length;
const SUGGESTIONS_COUNT = 4;
const WRAPPER_CSS = `position: absolute;
top: 1rem;
@@ -42,7 +36,15 @@ export const SuggestionCarousel = ({
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
const [isResetting, setIsResetting] = useState(false);
const suggestions = useMemo(() => SUGGESTION_KEYS.map((key) => t(key)), [t]);
const suggestions = useMemo(
() => [
t('Ask a question'),
t('Turn this list into bullet points'),
t('Write a short product description'),
t('Find recent news about...'),
],
[t],
);
const carouselSuggestions = useMemo(
() => [...suggestions, suggestions[0]],
[suggestions],
@@ -29,13 +29,15 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
?.documents;
const documentIdentifiers: string[] =
Array.isArray(documents) &&
documents.every(
(doc): doc is { identifier: string } =>
typeof doc === 'object' && doc !== null && 'identifier' in doc,
)
documents.every(
(doc): doc is { identifier: string } =>
typeof doc === 'object' && doc !== null && 'identifier' in doc,
)
? documents.map((doc) => doc.identifier)
: [];
const label = documentIdentifiers.length > 1 ? 'Extracting documents...' : 'Extracting document...';
return (
<Box
$direction="row"
@@ -45,17 +47,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
$width="100%"
$maxWidth="750px"
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
$background="var(--c--contextuals--background--surface--tertiary)"
$color="var(--c--contextuals--content--semantic--neutral--secondary)"
$padding={{ all: 'sm' }}
$radius="8px"
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
>
<Loader />
<Text $variation="600" $size="md">
{t('Extracting documents: {{documents}} ...', {
documents: documentIdentifiers.join(', '),
})}
{t(label, { documents: documentIdentifiers.join(', ') })}
</Text>
</Box>
);
@@ -0,0 +1,171 @@
/* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */
import { CunninghamProvider } from '@openfun/cunningham-react';
import { act, render, screen } from '@testing-library/react';
import { Suspense } from 'react';
import { CompletedMarkdownBlock, RawTextBlock } from '../MessageBlock';
// Mock react-markdown (ESM module not compatible with Jest)
jest.mock('react-markdown', () => ({
MarkdownHooks: ({ children }: { children: string }) => {
// Simple mock that renders markdown-like content
// This tests the component integration, not the markdown parsing itself
return <div data-testid="markdown-content">{children}</div>;
},
}));
// Mock rehype/remark plugins
jest.mock('@shikijs/rehype/core', () => () => {});
jest.mock('../../utils/shiki', () => ({
getHighlighter: () => Promise.resolve({}),
}));
jest.mock('rehype-katex', () => () => {});
jest.mock('remark-gfm', () => () => {});
jest.mock('remark-math', () => () => {});
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<Suspense fallback={null}>{ui}</Suspense>
</CunninghamProvider>,
);
};
describe('CompletedMarkdownBlock', () => {
it('renders content through MarkdownHooks', async () => {
await act(async () => {
renderWithProviders(<CompletedMarkdownBlock content="Hello world" />);
});
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('passes content to markdown renderer', async () => {
const content = '# Header Some **bold** text';
await act(async () => {
renderWithProviders(<CompletedMarkdownBlock content={content} />);
});
expect(screen.getByTestId('markdown-content')).toHaveTextContent(content);
});
it('does not re-render when content is the same (memoization)', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = renderWithProviders(
<CompletedMarkdownBlock content="Same content" />,
));
});
const firstRender = screen.getByTestId('markdown-content');
rerender!(
<CunninghamProvider>
<Suspense fallback={null}>
<CompletedMarkdownBlock content="Same content" />
</Suspense>
</CunninghamProvider>,
);
const secondRender = screen.getByTestId('markdown-content');
// The DOM element should be the same instance (not recreated)
expect(firstRender).toBe(secondRender);
});
it('re-renders when content changes', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = renderWithProviders(
<CompletedMarkdownBlock content="Original content" />,
));
});
expect(screen.getByText('Original content')).toBeInTheDocument();
rerender!(
<CunninghamProvider>
<Suspense fallback={null}>
<CompletedMarkdownBlock content="New content" />
</Suspense>
</CunninghamProvider>,
);
expect(screen.queryByText('Original content')).not.toBeInTheDocument();
expect(screen.getByText('New content')).toBeInTheDocument();
});
it('handles empty content', async () => {
let container: HTMLElement;
await act(async () => {
({ container } = renderWithProviders(
<CompletedMarkdownBlock content="" />,
));
});
expect(container!).toBeInTheDocument();
expect(screen.getByTestId('markdown-content')).toBeInTheDocument();
});
it('handles content with special characters', async () => {
await act(async () => {
renderWithProviders(
<CompletedMarkdownBlock content={`Special chars: < > & " '`} />,
);
});
expect(screen.getByText(/Special chars:/)).toBeInTheDocument();
});
});
describe('RawTextBlock', () => {
it('renders plain text content', () => {
renderWithProviders(<RawTextBlock content="Raw text content" />);
expect(screen.getByText('Raw text content')).toBeInTheDocument();
});
it('preserves whitespace and newlines', () => {
renderWithProviders(
<RawTextBlock content="Line 1\n Indented line\nLine 3" />,
);
const textElement = screen.getByText(/Line 1/);
expect(textElement).toHaveStyle('white-space: pre-wrap');
});
it('renders as a div element', () => {
renderWithProviders(<RawTextBlock content="Test content" />);
const element = screen.getByText('Test content');
// Text component renders with as="div", check it's in the DOM
expect(element).toBeInTheDocument();
});
it('does not parse markdown syntax', () => {
renderWithProviders(<RawTextBlock content="**not bold** *not italic*" />);
// Should render the raw markdown syntax, not parsed
expect(screen.getByText('**not bold** *not italic*')).toBeInTheDocument();
});
it('handles empty content', () => {
const { container } = renderWithProviders(<RawTextBlock content="" />);
expect(container).toBeInTheDocument();
});
it('handles content with code fence markers', () => {
renderWithProviders(
<RawTextBlock content="```python\npsquares = [x**2 for x in range(10)]" />,
);
expect(screen.getByText(/```python/)).toBeInTheDocument();
});
});
@@ -0,0 +1,536 @@
/* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */
import { CunninghamProvider } from '@openfun/cunningham-react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Suspense } from 'react';
import {
MessageItem,
splitIntoBlocks,
splitStreamingContent,
} from '../MessageItem';
// Mock react-markdown (ESM module)
jest.mock('react-markdown', () => ({
MarkdownHooks: ({ children }: { children: string }) => (
<div data-testid="markdown-content">{children}</div>
),
}));
jest.mock('@shikijs/rehype/core', () => () => {});
jest.mock('../../utils/shiki', () => ({
getHighlighter: () => Promise.resolve({}),
}));
jest.mock('rehype-katex', () => () => {});
jest.mock('remark-gfm', () => () => {});
jest.mock('remark-math', () => () => {});
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock child components
jest.mock('../AttachmentList', () => ({
AttachmentList: () => <div data-testid="attachment-list" />,
}));
jest.mock('../FeedbackButtons', () => ({
FeedbackButtons: () => <div data-testid="feedback-buttons" />,
}));
jest.mock('../SourceItemList', () => ({
SourceItemList: () => <div data-testid="source-item-list" />,
}));
jest.mock('../ToolInvocationItem', () => ({
ToolInvocationItem: () => <div data-testid="tool-invocation-item" />,
}));
describe('splitIntoBlocks', () => {
describe('basic splitting', () => {
it('returns empty array for empty content', () => {
expect(splitIntoBlocks('')).toEqual([]);
});
it('returns empty array for null/undefined content', () => {
expect(splitIntoBlocks(null as unknown as string)).toEqual([]);
expect(splitIntoBlocks(undefined as unknown as string)).toEqual([]);
});
it('returns single block for content without double newlines', () => {
expect(splitIntoBlocks('Hello world')).toEqual(['Hello world']);
});
it('splits content by double newlines', () => {
expect(splitIntoBlocks('Block 1\n\nBlock 2')).toEqual([
'Block 1',
'Block 2',
]);
});
it('splits multiple blocks', () => {
expect(splitIntoBlocks('Block 1\n\nBlock 2\n\nBlock 3')).toEqual([
'Block 1',
'Block 2',
'Block 3',
]);
});
it('ignores single newlines', () => {
expect(splitIntoBlocks('Line 1\nLine 2\n\nBlock 2')).toEqual([
'Line 1\nLine 2',
'Block 2',
]);
});
it('filters out empty blocks', () => {
expect(splitIntoBlocks('Block 1\n\n\n\nBlock 2')).toEqual([
'Block 1',
'Block 2',
]);
});
it('filters out whitespace-only blocks', () => {
expect(splitIntoBlocks('Block 1\n\n \n\nBlock 2')).toEqual([
'Block 1',
'Block 2',
]);
});
});
describe('code fence handling', () => {
it('keeps code block with internal double newlines as single block', () => {
const content = '```python\nline1\n\nline2\n```';
expect(splitIntoBlocks(content)).toEqual([content]);
});
it('keeps code block intact when followed by other content', () => {
const content = '```python\ncode\n```\n\nText after';
expect(splitIntoBlocks(content)).toEqual([
'```python\ncode\n```',
'Text after',
]);
});
it('keeps code block intact when preceded by other content', () => {
const content = 'Text before\n\n```python\ncode\n```';
expect(splitIntoBlocks(content)).toEqual([
'Text before',
'```python\ncode\n```',
]);
});
it('handles multiple code blocks', () => {
const content = '```js\ncode1\n```\n\nText\n\n```python\ncode2\n```';
expect(splitIntoBlocks(content)).toEqual([
'```js\ncode1\n```',
'Text',
'```python\ncode2\n```',
]);
});
it('handles code block with multiple double newlines inside', () => {
const content = '```\nline1\n\nline2\n\nline3\n```';
expect(splitIntoBlocks(content)).toEqual([content]);
});
it('handles nested backticks inside code block', () => {
const content = '```markdown\nSome `inline` code\n\nMore text\n```';
expect(splitIntoBlocks(content)).toEqual([content]);
});
it('handles unclosed code fence', () => {
const content = 'Text\n\n```python\ncode without closing';
const result = splitIntoBlocks(content);
// The unclosed fence should be kept with the text before it
expect(result).toEqual(['Text', '```python\ncode without closing']);
});
});
describe('edge cases', () => {
it('handles content ending with double newline', () => {
expect(splitIntoBlocks('Block 1\n\nBlock 2\n\n')).toEqual([
'Block 1',
'Block 2',
]);
});
it('handles content starting with double newline', () => {
expect(splitIntoBlocks('\n\nBlock 1\n\nBlock 2')).toEqual([
'Block 1',
'Block 2',
]);
});
it('handles markdown headers', () => {
expect(splitIntoBlocks('# Header\n\nParagraph')).toEqual([
'# Header',
'Paragraph',
]);
});
it('handles markdown lists', () => {
const content = '- Item 1\n- Item 2\n\nParagraph';
expect(splitIntoBlocks(content)).toEqual([
'- Item 1\n- Item 2',
'Paragraph',
]);
});
});
});
describe('splitStreamingContent', () => {
describe('basic splitting', () => {
it('returns empty blocks and empty pending for empty content', () => {
expect(splitStreamingContent('')).toEqual({
completedBlocks: [],
pending: '',
});
});
it('returns all content as pending when no double newline', () => {
expect(splitStreamingContent('Partial content')).toEqual({
completedBlocks: [],
pending: 'Partial content',
});
});
it('splits completed and pending content', () => {
expect(splitStreamingContent('Block 1\n\nPending')).toEqual({
completedBlocks: ['Block 1'],
pending: 'Pending',
});
});
it('handles multiple completed blocks with pending', () => {
expect(splitStreamingContent('Block 1\n\nBlock 2\n\nPending')).toEqual({
completedBlocks: ['Block 1', 'Block 2'],
pending: 'Pending',
});
});
});
describe('content ending with double newline (bug fix)', () => {
it('keeps blocks when content ends with double newline', () => {
// This was the bug: content ending with \n\n would return empty blocks
const result = splitStreamingContent('Block 1\n\nBlock 2\n\n');
expect(result.completedBlocks).toEqual(['Block 1', 'Block 2']);
expect(result.pending).toBe('');
});
it('keeps single block when content ends with double newline', () => {
const result = splitStreamingContent('Block 1\n\n');
expect(result.completedBlocks).toEqual(['Block 1']);
expect(result.pending).toBe('');
});
it('handles multiple trailing double newlines', () => {
// 'Block 1\n\n\n\n' - lastIndexOf('\n\n') finds the last pair
// completedContent = 'Block 1\n\n', pending = ''
const result = splitStreamingContent('Block 1\n\n\n\n');
expect(result.completedBlocks).toEqual(['Block 1']);
expect(result.pending).toBe('');
});
});
describe('code fence handling', () => {
it('treats unclosed code fence as pending', () => {
const result = splitStreamingContent('Text\n\n```python\ncode');
expect(result.completedBlocks).toEqual(['Text']);
expect(result.pending).toBe('\n\n```python\ncode');
});
it('handles closed code fence as completed', () => {
const result = splitStreamingContent('```python\ncode\n```\n\nPending');
expect(result.completedBlocks).toEqual(['```python\ncode\n```']);
expect(result.pending).toBe('Pending');
});
it('handles unclosed fence with no content before', () => {
const result = splitStreamingContent('```python\ncode');
expect(result.completedBlocks).toEqual([]);
expect(result.pending).toBe('```python\ncode');
});
it('handles unclosed fence with double newline inside', () => {
const result = splitStreamingContent('Text\n\n```python\nline1\n\nline2');
expect(result.completedBlocks).toEqual(['Text']);
expect(result.pending).toContain('```python');
});
it('handles multiple code blocks', () => {
const result = splitStreamingContent(
'```js\ncode1\n```\n\n```python\ncode2\n```\n\nPending',
);
expect(result.completedBlocks).toEqual([
'```js\ncode1\n```',
'```python\ncode2\n```',
]);
expect(result.pending).toBe('Pending');
});
});
describe('streaming simulation', () => {
it('maintains block stability as content grows', () => {
// Simulate streaming: content grows over time
const stream1 = 'Hello';
const stream2 = 'Hello world';
const stream3 = 'Hello world\n\n';
const stream4 = 'Hello world\n\nSecond';
const stream5 = 'Hello world\n\nSecond block\n\n';
const stream6 = 'Hello world\n\nSecond block\n\nThird';
// Initially all pending
expect(splitStreamingContent(stream1).completedBlocks).toEqual([]);
expect(splitStreamingContent(stream2).completedBlocks).toEqual([]);
// After first \n\n, first block is complete
const result3 = splitStreamingContent(stream3);
expect(result3.completedBlocks).toEqual(['Hello world']);
expect(result3.pending).toBe('');
// New content arrives
const result4 = splitStreamingContent(stream4);
expect(result4.completedBlocks).toEqual(['Hello world']);
expect(result4.pending).toBe('Second');
// Second block completes
const result5 = splitStreamingContent(stream5);
expect(result5.completedBlocks).toEqual(['Hello world', 'Second block']);
expect(result5.pending).toBe('');
// More content
const result6 = splitStreamingContent(stream6);
expect(result6.completedBlocks).toEqual(['Hello world', 'Second block']);
expect(result6.pending).toBe('Third');
});
it('block content remains stable during streaming', () => {
// The key test: first block content should not change as more content arrives
const stream1 = 'First block\n\nSecond';
const stream2 = 'First block\n\nSecond block\n\nThird';
const result1 = splitStreamingContent(stream1);
const result2 = splitStreamingContent(stream2);
// First block should be identical
expect(result1.completedBlocks[0]).toBe(result2.completedBlocks[0]);
expect(result1.completedBlocks[0]).toBe('First block');
});
});
describe('edge cases', () => {
it('handles only whitespace', () => {
expect(splitStreamingContent(' ')).toEqual({
completedBlocks: [],
pending: ' ',
});
});
it('handles only newlines', () => {
expect(splitStreamingContent('\n\n')).toEqual({
completedBlocks: [],
pending: '',
});
});
it('handles special characters', () => {
const result = splitStreamingContent('Special: < > & "\n\nMore');
expect(result.completedBlocks).toEqual(['Special: < > & "']);
expect(result.pending).toBe('More');
});
});
});
describe('MessageItem', () => {
const defaultProps = {
message: {
id: 'msg-1',
role: 'assistant' as const,
content: 'Hello world',
},
isLastMessage: false,
isLastAssistantMessage: false,
isFirstConversationMessage: false,
streamingMessageHeight: null,
status: 'ready' as const,
conversationId: 'conv-1',
isSourceOpen: null,
isMobile: false,
onCopyToClipboard: jest.fn(),
onOpenSources: jest.fn(),
getMetadata: jest.fn(),
};
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<Suspense fallback={null}>{ui}</Suspense>
</CunninghamProvider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders assistant message content', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(
screen.getByTestId('assistant-message-content'),
).toBeInTheDocument();
});
it('renders user message content', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
message={{ ...defaultProps.message, role: 'user' }}
/>,
);
});
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('renders message with data-message-id attribute', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.getByTestId('msg-1')).toBeInTheDocument();
});
it('renders copy button for non-streaming assistant messages', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.getByText('Copy')).toBeInTheDocument();
});
it('does not render copy button while streaming', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
status="streaming"
isLastAssistantMessage={true}
/>,
);
});
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
});
it('hides copy text on mobile', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} isMobile={true} />);
});
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('calls onCopyToClipboard when copy button is clicked', async () => {
const user = userEvent.setup();
const onCopyToClipboard = jest.fn();
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
onCopyToClipboard={onCopyToClipboard}
/>,
);
});
await user.click(screen.getByText('Copy'));
expect(onCopyToClipboard).toHaveBeenCalledWith('Hello world');
});
});
describe('streaming state', () => {
it('uses splitStreamingContent when streaming', async () => {
// When streaming, content should be split into blocks + pending
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
message={{
...defaultProps.message,
content: 'Block 1\n\nPending content',
}}
status="streaming"
isLastAssistantMessage={true}
/>,
);
});
// The markdown content should contain "Block 1" but not necessarily "Pending"
// since pending is rendered as raw text
expect(
screen.getByTestId('assistant-message-content'),
).toBeInTheDocument();
});
});
describe('attachments', () => {
it('renders AttachmentList when message has attachments', async () => {
const messageWithAttachments = {
...defaultProps.message,
experimental_attachments: [
{ url: 'https://example.com/file.pdf', name: 'file.pdf' },
],
};
await act(async () => {
renderWithProviders(
<MessageItem {...defaultProps} message={messageWithAttachments} />,
);
});
expect(screen.getByTestId('attachment-list')).toBeInTheDocument();
});
it('does not render AttachmentList when no attachments', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.queryByTestId('attachment-list')).not.toBeInTheDocument();
});
});
describe('feedback buttons', () => {
it('renders FeedbackButtons for trace messages', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
message={{ ...defaultProps.message, id: 'trace-123' }}
/>,
);
});
expect(screen.getByTestId('feedback-buttons')).toBeInTheDocument();
});
it('does not render FeedbackButtons for non-trace messages', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.queryByTestId('feedback-buttons')).not.toBeInTheDocument();
});
});
});
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import i18next from 'i18next';
import '@/i18n/initI18n';
@@ -9,8 +10,26 @@ describe('SuggestionCarousel', () => {
jest.useFakeTimers();
});
afterEach(() => {
afterEach(async () => {
jest.useRealTimers();
await i18next.changeLanguage('en');
});
it('should render all suggestions in french', async () => {
await i18next.changeLanguage('fr');
render(<SuggestionCarousel messagesLength={0} />);
expect(screen.getAllByText('Poser une question')).toHaveLength(2);
expect(
screen.getByText('Transformer cette liste en liste à puces...'),
).toBeInTheDocument();
expect(
screen.getByText('Écrire une description courte du produit...'),
).toBeInTheDocument();
expect(
screen.getByText('Trouver les dernières actualités concernant...'),
).toBeInTheDocument();
});
it('should render all suggestions', () => {
@@ -24,7 +24,10 @@ import { LaGaufre } from './LaGaufre';
export const Header = () => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { spacingsTokens, colorsTokens, componentTokens } =
useCunninghamTheme();
const showLaGaufre =
(componentTokens as Record<string, unknown>)['la-gaufre'] === true;
const { isDesktop } = useResponsiveStore();
const { setPanelOpen } = useChatPreferencesStore();
const { isAtTop } = useChatScroll();
@@ -95,7 +98,7 @@ export const Header = () => {
>
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
{showLaGaufre && <LaGaufre />}
</Box>
)}
</Box>
@@ -26,6 +26,8 @@ export const HomeHeader = () => {
const logo = (componentTokens as Record<string, unknown>).logo as
| { src: string; alt: string; widthHeader: string; widthFooter: string }
| undefined;
const showLaGaufre =
(componentTokens as Record<string, unknown>)['la-gaufre'] === true;
return (
<Box
@@ -79,7 +81,7 @@ export const HomeHeader = () => {
<Box $direction="row" $gap="1rem" $align="center">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
{showLaGaufre && <LaGaufre />}
</Box>
)}
</Box>
@@ -1,6 +1,15 @@
{
"de": { "translation": { "ABC-1234-XY": "ABC-1234-XY" } },
"en": { "translation": { "Login": "Login", "Logout": "Logout" } },
"de": {
"translation": {
"ABC-1234-XY": "ABC-1234-XY"
}
},
"en": {
"translation": {
"Login": "Login",
"Logout": "Logout"
}
},
"fr": {
"translation": {
"30 sec to tell us what you think or report a bug": "Prenez 30 secondes pour partager votre avis ou signaler un bug",
@@ -16,6 +25,7 @@
"An error occurred while renaming the conversation": "Une erreur est survenue lors du renommage de la conversation",
"An error occurred. Please try again.": "Une erreur s'est produite. Veuillez réessayer.",
"Are you sure you want to delete this conversation ?": "Êtes-vous sûr de vouloir supprimer cette conversation ?",
"Ask a question": "Poser une question",
"Assistant IA replied: ": "Assistant IA a répondu : ",
"Assistant is already available, log in to use it now.": "L'Assistant est déjà disponible, connectez-vous pour l'utiliser maintenant.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "L'assistant est en cours de développement : vos commentaires sont importants ! Choisissez comment partager vos avis :",
@@ -44,6 +54,7 @@
"Direct exchange with our team": "Échange direct avec notre équipe",
"Enter your message or a question": "Entrez votre message ou une question",
"Explore other LaSuite apps": "Explorer les autres applications de LaSuite",
"Extracting document: {{documents}} ...": "Extraction du document : {{documents}}...",
"Extracting documents: {{documents}} ...": "Extraction des documents : {{documents}}...",
"Failed to activate account. Please try again.": "Échec de l'activation du compte. Veuillez réessayer.",
"Failed to copy": "Échec de la copie",
@@ -55,6 +66,7 @@
"Feedback Négatif": "Retour négatif",
"Feedback positif": "Retour positif",
"File type not supported": "Type de fichier non pris en charge",
"Find recent news about...": "Trouver les dernières actualités concernant...",
"Get notified about the Public Beta.": "Soyez informé de la Bêta publique.",
"Get notified for the public beta": "Être notifié pour la bêta publique",
"Give a quick opinion": "Donner un avis rapide",
@@ -108,6 +120,7 @@
"Start conversation": "Entamer la conversation",
"Stop": "Stop",
"Summarizing...": "Résumé en cours...",
"Translation in progress...": "Traduction en cours...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "L'Assistant est une IA souveraine destinée aux fonctionnaires. Il les aide dans leurs tâches quotidiennes (reformulation, résumé, traduction, recherche d'informations). Vos données restent en France, sur une infrastructure sécurisée et conforme aux normes nationales, 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.",
@@ -115,6 +128,7 @@
"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.",
"Turn this list into bullet points": "Transformer cette liste en liste à puces...",
"Unlock access": "Déverrouiller l'accès",
"Unlocking...": "Déverrouillage en cours...",
"Untitled conversation": "Conversation sans titre",
@@ -124,6 +138,7 @@
"We'll email you when the public beta opens.": "Nous vous enverrons un email quand la bêta publique sera ouverte.",
"Web": "Web",
"What is on your mind?": "Quavez-vous en tête ?",
"Write a short product description": "Écrire une description courte du produit...",
"Write on Tchap": "Écrire sur Tchap",
"You are on the list": "Vous êtes dans la liste",
"You do not have permission to view this page.": "Vous navez pas la permission de voir cette page.",
@@ -151,6 +166,7 @@
"An error occurred while renaming the conversation": "Er is een fout opgetreden bij het hernoemen van het gesprek",
"An error occurred. Please try again.": "Er is een fout opgetreden. Probeer het opnieuw.",
"Are you sure you want to delete this conversation ?": "Weet u zeker dat u dit gesprek wilt verwijderen?",
"Ask a question": "Stel een vraag",
"Assistant IA replied: ": "AI Assistent antwoordde: ",
"Assistant is already available, log in to use it now.": "Assistent is al beschikbaar, log in om het nu te gebruiken.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Assistent is in ontwikkeling: uw feedback telt! Kies hoe u uw ideeën wilt delen:",
@@ -179,6 +195,7 @@
"Direct exchange with our team": "Directe uitwisseling met ons team",
"Enter your message or a question": "Voer uw bericht of een vraag in",
"Explore other LaSuite apps": "Ontdek andere LaSuite-apps",
"Extracting document: {{documents}} ...": "Document uitpakken: {{documents}}...",
"Extracting documents: {{documents}} ...": "Documenten uitpakken: {{documents}}...",
"Failed to activate account. Please try again.": "Account activeren mislukt. Probeer het opnieuw.",
"Failed to copy": "Kopiëren mislukt",
@@ -190,6 +207,7 @@
"Feedback Négatif": "Negatieve feedback",
"Feedback positif": "Positieve feedback",
"File type not supported": "Bestandstype niet ondersteund",
"Find recent news about...": "Vind het laatste nieuws over...",
"Get notified about the Public Beta.": "Ontvang een melding over de openbare bèta.",
"Get notified for the public beta": "Ontvang een melding voor de openbare bètaversie",
"Give a quick opinion": "Geef snel een mening",
@@ -243,6 +261,7 @@
"Start conversation": "Begin een gesprek",
"Stop": "Stop",
"Summarizing...": "Samenvatten...",
"Translation in progress...": "Vertaling bezig...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "De assistent is een soevereine AI voor overheidsambtenaren. Het helpt met dagelijkse taken (herformuleren, samenvatten, vertalen, informatiezoeken). Je gegevens blijven in Nederland op een veilige, door de staat compatibele infrastructuur en worden nooit voor commerciële doeleinden gebruikt.",
"The Assistant is in Beta": "De Assistent is in bèta",
"The conversation has been deleted.": "Het gesprek is verwijderd.",
@@ -250,6 +269,7 @@
"The summary feature is not supported yet.": "De samenvattingsfunctie wordt nog niet ondersteund.",
"Thinking...": "Denken...",
"To add a file to the conversation, drop it here.": "Als u een bestand aan het gesprek wilt toevoegen, sleept u het hierheen.",
"Turn this list into bullet points": "Zet deze lijst om in opsommingstekens",
"Unlock access": "Toegang ontgrendelen",
"Unlocking...": "Ontgrendelen...",
"Untitled conversation": "Ongetiteld gesprek",
@@ -259,6 +279,7 @@
"We'll email you when the public beta opens.": "We sturen u een e-mail zodra de openbare bètaversie beschikbaar is.",
"Web": "Internet",
"What is on your mind?": "Waar denk je aan?",
"Write a short product description": "Schrijf een korte productbeschrijving",
"Write on Tchap": "Schrijf op Tchap",
"You are on the list": "Je staat op de lijst",
"You do not have permission to view this page.": "U heeft geen toestemming om deze pagina te bekijken.",
@@ -286,6 +307,7 @@
"An error occurred while renaming the conversation": "Ошибка при переименовании беседы",
"An error occurred. Please try again.": "Произошла ошибка. Пожалуйста, повторите попытку.",
"Are you sure you want to delete this conversation ?": "Вы действительно хотите удалить эту беседу?",
"Ask a question": "Задать вопрос",
"Assistant IA replied: ": "Помощник ИИ ответил: ",
"Assistant is already available, log in to use it now.": "Помощник уже доступен, просто войдите в систему.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помощник находится в разработке: ваши отзывы важны! Выберите, как поделиться своими идеями:",
@@ -314,6 +336,7 @@
"Direct exchange with our team": "Прямое общение с нашей командой",
"Enter your message or a question": "Введите сообщение или вопрос",
"Explore other LaSuite apps": "Посмотреть другие приложения LaSuite",
"Extracting document: {{documents}} ...": "Извлечение документа: {{documents}}...",
"Extracting documents: {{documents}} ...": "Извлечение документов: {{documents}}...",
"Failed to activate account. Please try again.": "Не удалось активировать учётную запись. Пожалуйста, попробуйте снова.",
"Failed to copy": "Не удалось скопировать",
@@ -325,6 +348,7 @@
"Feedback Négatif": "Отрицательный отзыв",
"Feedback positif": "Положительный отзыв",
"File type not supported": "Тип файла не поддерживается",
"Find recent news about...": "Найти последние новости...",
"Get notified about the Public Beta.": "Получать уведомления о публичной бета-версии.",
"Get notified for the public beta": "Получать уведомления о публичной бета-версии",
"Give a quick opinion": "Оставить быстрый отзыв",
@@ -378,6 +402,7 @@
"Start conversation": "Начать беседу",
"Stop": "Остановить",
"Summarizing...": "Обобщение...",
"Translation in progress...": "Перевод выполняется...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помощник — независимый ИИ для государственных служащих. Он помогает в выполнении повседневных задач (перефразирование, резюмирование, перевод, поиск информации). Ваши данные хранятся во Франции на безопасной инфраструктуре, соответствующей государственным требованиям, и никогда не используются в коммерческих целях.",
"The Assistant is in Beta": "Помощник находится на этапе Бета-версии",
"The conversation has been deleted.": "Беседа была удалена.",
@@ -385,6 +410,7 @@
"The summary feature is not supported yet.": "Функция сводки пока не поддерживается.",
"Thinking...": "Размышление...",
"To add a file to the conversation, drop it here.": "Чтобы добавить файл в беседу, поместите его сюда.",
"Turn this list into bullet points": "Преобразовать этот список в маркированный",
"Unlock access": "Разблокировать",
"Unlocking...": "Разблокировка...",
"Untitled conversation": "Беседа без названия",
@@ -394,6 +420,7 @@
"We'll email you when the public beta opens.": "Мы отправим вам письмо, когда станет доступна публичная бета-версия.",
"Web": "Интернет",
"What is on your mind?": "О чём вы думаете?",
"Write a short product description": "Введите краткое описание продукта",
"Write on Tchap": "Написать в Tchap",
"You are on the list": "Вы в списке",
"You do not have permission to view this page.": "У вас недостаточно прав для просмотра этой страницы.",
@@ -421,6 +448,7 @@
"An error occurred while renaming the conversation": "Сталася помилка під час перейменування розмови",
"An error occurred. Please try again.": "Сталась помилка. Спробуйте ще раз.",
"Are you sure you want to delete this conversation ?": "Ви дійсно бажаєте видалити цю розмову?",
"Ask a question": "Задати питання",
"Assistant IA replied: ": "Відповідь помічника ШІ: ",
"Assistant is already available, log in to use it now.": "Помічник вже доступний, увійдіть щоб почати використання.",
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помічник в розробці: ваші відгуки мають значення! Оберіть, як поділитися своїми ідеями:",
@@ -449,6 +477,7 @@
"Direct exchange with our team": "Пряме спілкування з нашою командою",
"Enter your message or a question": "Введіть ваше повідомлення або питання",
"Explore other LaSuite apps": "Ознайомтесь з іншими застосунками LaSuite",
"Extracting document: {{documents}} ...": "Видобування документа: {{documents}}...",
"Extracting documents: {{documents}} ...": "Видобування документів: {{documents}}...",
"Failed to activate account. Please try again.": "Не вдалося активувати обліковий запис. Спробуйте ще раз.",
"Failed to copy": "Не вдалось скопіювати",
@@ -460,6 +489,7 @@
"Feedback Négatif": "Негативний відгук",
"Feedback positif": "Позитивний відгук",
"File type not supported": "Тип файлу не підтримується",
"Find recent news about...": "Знайти останні новини про...",
"Get notified about the Public Beta.": "Отримувати повідомлення про публічну бета-версію.",
"Get notified for the public beta": "Отримувати повідомлення про публічну бета-версію",
"Give a quick opinion": "Дати швидкий відгук",
@@ -513,6 +543,7 @@
"Start conversation": "Почати розмову",
"Stop": "Зупинити",
"Summarizing...": "Узагальнення...",
"Translation in progress...": "Переклад виконується...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помічник — це незалежний штучний інтелект для державних службовців. Він допомагає у виконанні щоденних завдань (перефразування, узагальнення, переклад, пошук інформації). Ваші дані зберігаються у Франції на безпечній інфраструктурі, що відповідає державним вимогам, і ніколи не використовуються в комерційних цілях.",
"The Assistant is in Beta": "Помічник у бета-версії",
"The conversation has been deleted.": "Розмова була видалена.",
@@ -520,6 +551,7 @@
"The summary feature is not supported yet.": "Функція узагальнення ще не підтримується.",
"Thinking...": "Мислення...",
"To add a file to the conversation, drop it here.": "Щоб додати файл до розмови, покладіть його сюди.",
"Turn this list into bullet points": "Перетворити цей список на маркований",
"Unlock access": "Розблокувати доступ",
"Unlocking...": "Розблоковування...",
"Untitled conversation": "Розмова без назви",
@@ -301,6 +301,40 @@ figure[data-rehype-pretty-code-figure] > pre > code {
display: block;
}
.mainContent-chat table {
display: block;
width: 100%;
margin-top: 32px;
overflow-x: scroll;
font-size: 0.875rem;
}
.mainContent-chat table th {
color: var(--c--contextuals--content--semantic--neutral--secondary);
font-weight: 500;
font-size: 0.75rem;
}
.mainContent-chat table tr,
.mainContent-chat table th {
display: flex;
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
}
.mainContent-chat tbody tr,
.mainContent-chat thead th {
padding: 8px;
}
.mainContent-chat table tr:last-child {
border-bottom: none;
}
.mainContent-chat table td,
.mainContent-chat table th {
min-width: 200px;
}
.sr-only {
position: absolute;
width: 1px;
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/home/');
});
@@ -69,4 +71,71 @@ test.describe('Chat page', () => {
await chatHistoryLink.click();
await expect(messageContent).toBeVisible();
});
test('the user can paste a document into the chat input', async ({ page }) => {
await overrideConfig(page, {
FEATURE_FLAGS: {
'document-upload': 'enabled',
'web-search': 'enabled',
},
});
await page.goto('/');
const chatInput = page.getByRole('textbox', {
name: 'Enter your message or a',
});
await expect(chatInput).toBeVisible();
await chatInput.click();
// Create a test file content
const fileContent = 'Test document content for paste';
const fileName = 'test-document.txt';
const fileType = 'text/plain';
// Simulate paste event with file
await page.evaluate(
({ content, name, type }) => {
const textarea = document.querySelector(
'textarea[name="inputchat-textarea"]',
) as HTMLTextAreaElement;
if (!textarea) return;
// Create a File object
const file = new File([content], name, { type });
// Create a DataTransfer object to simulate clipboard
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
// Create a paste event - ClipboardEvent constructor doesn't accept clipboardData
// so we create a regular Event and add clipboardData property
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
}) as unknown as ClipboardEvent;
// Define clipboardData property to make it accessible
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
files: dataTransfer.files,
items: dataTransfer.items,
types: Array.from(dataTransfer.types),
getData: () => '',
setData: () => {},
},
writable: false,
configurable: true,
});
textarea.dispatchEvent(pasteEvent);
},
{ content: fileContent, name: fileName, type: fileType },
);
// Wait for the file to be processed and appear in the attachment list
// The attachment should be visible with the file name
const attachment = page.getByText(fileName, { exact: false }).first();
await expect(attachment).toBeVisible({ timeout: 5000 });
});
});
+2 -2
View File
@@ -234,10 +234,10 @@ backend:
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for backend readiness probe
probes:
liveness:
path: /__heartbeat__
path: /__lbheartbeat__
initialDelaySeconds: 10
readiness:
path: /__lbheartbeat__
path: /__heartbeat__
initialDelaySeconds: 10
## @param backend.resources Resource requirements for the backend container