Compare commits

...

8 Commits

Author SHA1 Message Date
Laurent Paoletti e8989d7f43 ⬆️(back) test
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-19 21:58:15 +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
33 changed files with 4740 additions and 1017 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,
+2 -1
View File
@@ -200,13 +200,14 @@ 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
- name: Run tests
run: uv run pytest -n 2
timeout-minutes: 30
security-trivy-critical:
permissions:
+20 -4
View File
@@ -8,6 +8,22 @@ 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
### Changed
- ⚡️(front) optimize streaming markdown rendering performance
- ⬆️(back) update pydantic-ai
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
- 🐛(front) fix math formulas and carousel translations
## [0.0.13] - 2026-02-09
### Added
@@ -18,17 +34,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 +62,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
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ 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)
@@ -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,
-30
View File
@@ -1,30 +0,0 @@
"""
Unit tests for the User model
"""
import pytest
from conversations.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
+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,541 @@
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]);
// 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">
{activeToolInvocation?.toolName === 'summarize'
? t('Summarizing...')
: t('Search...')}
</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],
@@ -45,11 +45,6 @@ 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">
@@ -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 :",
@@ -55,6 +65,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",
@@ -115,6 +126,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 +136,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 +164,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:",
@@ -190,6 +204,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",
@@ -250,6 +265,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 +275,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 +303,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:": "Помощник находится в разработке: ваши отзывы важны! Выберите, как поделиться своими идеями:",
@@ -325,6 +343,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": "Оставить быстрый отзыв",
@@ -385,6 +404,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 +414,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 +442,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:": "Помічник в розробці: ваші відгуки мають значення! Оберіть, як поділитися своїми ідеями:",
@@ -460,6 +482,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": "Дати швидкий відгук",
@@ -520,6 +543,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 });
});
});