Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8989d7f43 | |||
| 5d895d15f9 | |||
| 96a2963adf | |||
| 224e6f83fd | |||
| af6facf4a3 | |||
| 9d16460777 | |||
| 09dceb508d | |||
| 63e0e6c383 |
@@ -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,
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+35
-7
@@ -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,
|
||||
|
||||
+35
-10
@@ -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,
|
||||
|
||||
+223
-90
@@ -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,
|
||||
|
||||
+34
-11
@@ -92,6 +92,7 @@ def test_post_conversation_with_local_image_url(
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -110,6 +111,7 @@ def test_post_conversation_with_local_image_url(
|
||||
instructions="You are a helpful test assistant :)\n\nToday is "
|
||||
f"{formatted_date}.\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -181,6 +183,7 @@ def test_post_conversation_with_local_image_url(
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -199,17 +202,26 @@ def test_post_conversation_with_local_image_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{"content": "This is an image of a single pixel.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "This is an image of a single pixel.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -229,7 +241,7 @@ def test_post_conversation_with_local_image_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_local_image_wrong_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -275,7 +287,8 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
],
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
timestamp=timezone.now(),
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
@@ -314,7 +327,7 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_remote_image_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -361,8 +374,9 @@ def test_post_conversation_with_remote_image_url(
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -432,7 +446,7 @@ def test_post_conversation_with_remote_image_url(
|
||||
@freeze_time("2025-10-18T20:48:20.286204Z")
|
||||
def test_post_conversation_with_local_image_url_in_history(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -475,7 +489,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
],
|
||||
pydantic_messages=[
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -547,6 +561,8 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -559,11 +575,11 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
identifier="sample.png",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
),
|
||||
ModelResponse(
|
||||
parts=[TextPart(content="This is an image of a single pixel.")],
|
||||
@@ -577,12 +593,13 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
content=[
|
||||
"Give more details about this image.",
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
)
|
||||
],
|
||||
run_id=messages[2].run_id,
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
]
|
||||
yield "This is an image of square, very small and nice."
|
||||
@@ -681,7 +698,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -728,6 +745,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Give more details about this image."],
|
||||
@@ -736,21 +754,26 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is an image of square, very small and nice.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -1,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. "
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
Generated
+70
-42
@@ -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"
|
||||
|
||||
+10
-8
@@ -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">
|
||||
|
||||
+171
@@ -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();
|
||||
});
|
||||
});
|
||||
+536
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
-1
@@ -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?": "Qu’avez-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 n’avez 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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user