Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88be0c94dd | |||
| 00f28f4647 | |||
| 34a619a54f | |||
| bd4b312bba | |||
| ba712af899 | |||
| e60c938d44 | |||
| 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,7 +200,7 @@ jobs:
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
sudo cp $GITHUB_WORKSPACE/docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
+23
-4
@@ -8,6 +8,25 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(waffle) hide the waffle if not fr theme
|
||||
- ✨(front) allow pasting an attachment from clipboard
|
||||
- ✨(array) temporarily adjust array
|
||||
- ✨(tools) add basic translate tool
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(front) optimize streaming markdown rendering performance
|
||||
- ⬆️(back) update pydantic-ai
|
||||
- ♻️(chat) refactor AIAgentService for readability and maintainability
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
|
||||
- 🐛(front) fix math formulas and carousel translations
|
||||
- 🐛(helm) reverse liveness and readiness for backend deployment
|
||||
|
||||
## [0.0.13] - 2026-02-09
|
||||
|
||||
### Added
|
||||
@@ -18,17 +37,17 @@ and this project adheres to
|
||||
- 🧱(files) allow to use S3 storage without external access #849
|
||||
- ✨(backend) add FindRagBackend #209
|
||||
- ⬆️(back) update dependencies
|
||||
- ✨(back) Use adaptive parsing for pdf documents
|
||||
- ✨(back) use adaptive parsing for pdf documents
|
||||
|
||||
### Changed
|
||||
|
||||
- 💄(darkmode) change color feedback butto
|
||||
- 💄(darkmode) change color feedback button
|
||||
- 🏗️(back) migrate to uv
|
||||
- ♻️(front) optimize syntax highlighting bundle size
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) Cast collection Ids to API expected types
|
||||
- 🐛(back) cast collection Ids to API expected types
|
||||
|
||||
## [0.0.12] - 2026-01-27
|
||||
|
||||
@@ -46,7 +65,7 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
- ✨(chat) generate and edit conversation title
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -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
@@ -0,0 +1,27 @@
|
||||
"""Build the translation agent."""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .base import BaseAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class TranslationAgent(BaseAgent):
|
||||
"""Create a Pydantic AI translation Agent instance with the configured settings"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the agent with the configured model."""
|
||||
super().__init__(
|
||||
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
|
||||
output_type=str,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_tools(self) -> list:
|
||||
"""Translation does not need any tools."""
|
||||
return []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,13 @@ import core.urls
|
||||
import chat.views
|
||||
import conversations.urls
|
||||
from chat.agents.summarize import SummarizationAgent
|
||||
from chat.agents.translate import TranslationAgent
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(name="today_promt_date")
|
||||
@pytest.fixture(name="today_prompt_date")
|
||||
def today_prompt_date_fixture():
|
||||
"""Fixture to mock date the system prompt when useless to test it."""
|
||||
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
|
||||
@@ -91,6 +92,34 @@ def mock_summarization_agent_fixture():
|
||||
yield _mock_agent
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_translation_agent")
|
||||
def mock_translation_agent_fixture():
|
||||
"""Fixture to mock TranslationAgent with a custom model."""
|
||||
|
||||
@contextmanager
|
||||
def _mock_agent(model):
|
||||
"""Context manager to mock TranslationAgent with a custom model."""
|
||||
with ExitStack() as stack:
|
||||
|
||||
class TranslationAgentMock(TranslationAgent):
|
||||
"""Mocked TranslationAgent to override the model."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
logger.info("Overriding TranslationAgent model with %s", model)
|
||||
self._model = model # pylint: disable=protected-access
|
||||
|
||||
stack.enter_context(
|
||||
patch("chat.agents.translate.TranslationAgent", new=TranslationAgentMock)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("chat.tools.document_translate.TranslationAgent", new=TranslationAgentMock)
|
||||
)
|
||||
yield
|
||||
|
||||
yield _mock_agent
|
||||
|
||||
|
||||
PIXEL_PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
|
||||
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Tests for document_translate functionality."""
|
||||
|
||||
import io
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pytest
|
||||
from pydantic_ai import ModelResponse, RunContext, TextPart
|
||||
from pydantic_ai.exceptions import ModelRetry
|
||||
from pydantic_ai.models.function import FunctionModel
|
||||
from pydantic_ai.usage import RunUsage
|
||||
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
from chat.tools.document_translate import document_translate
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixture_translation_agent_config(settings):
|
||||
"""Fixture to set used settings for agent configuration."""
|
||||
settings.TRANSLATION_MAX_CHARS = 100_000
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
settings.LLM_DEFAULT_MODEL_HRID: LLModel(
|
||||
hrid="mistral-model",
|
||||
model_name="mistral-medium-2508",
|
||||
human_readable_name="Mistral Medium 2508",
|
||||
profile=None,
|
||||
provider=LLMProvider(
|
||||
hrid="mistral-medium-2508",
|
||||
kind="mistral",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key="testkey",
|
||||
),
|
||||
is_active=True,
|
||||
system_prompt="direct",
|
||||
tools=[],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="mocked_context")
|
||||
def fixture_mocked_context():
|
||||
"""Fixture for a mocked RunContext."""
|
||||
mock_ctx = mock.Mock(spec=RunContext)
|
||||
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
|
||||
mock_ctx.max_retries = 2
|
||||
mock_ctx.retries = {}
|
||||
return mock_ctx
|
||||
|
||||
|
||||
def _mock_attachments_queryset(attachment):
|
||||
"""Create a mock queryset that chains .filter().order_by().afirst() returning the attachment."""
|
||||
mock_qs = mock.Mock()
|
||||
mock_qs.order_by.return_value = mock_qs
|
||||
mock_qs.afirst = mock.AsyncMock(return_value=attachment)
|
||||
|
||||
mock_attachments = mock.Mock()
|
||||
mock_attachments.filter.return_value = mock_qs
|
||||
return mock_attachments
|
||||
|
||||
|
||||
def mocked_translation(_messages, _info=None):
|
||||
"""Mocked translation response."""
|
||||
return ModelResponse(parts=[TextPart(content="Ceci est une traduction du document.")])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_single_document(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate with a single document."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "test_doc.txt"
|
||||
mock_attachment.file_name = "test_doc.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "This is a test document. " * 20
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
def mocked_translate_full(_message, _info=None):
|
||||
"""Mocked translation for full flow."""
|
||||
return ModelResponse(parts=[TextPart(content="Ceci est un document de test.")])
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translate_full)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert "Ceci est un document de test." in result.return_value
|
||||
assert result.metadata["sources"] == {"test_doc.txt"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_uses_last_document(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate uses the last uploaded document."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "latest_doc.txt"
|
||||
mock_attachment.file_name = "latest_doc.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "Content of the latest document."
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translation)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert result.metadata["sources"] == {"latest_doc.txt"}
|
||||
# Verify order_by was called with -created_at
|
||||
mock_conversation.attachments.filter.return_value.order_by.assert_called_once_with(
|
||||
"-created_at"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_with_custom_instructions(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate with custom instructions."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "test.txt"
|
||||
mock_attachment.file_name = "test.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "Test content " * 20
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
captured_prompts = []
|
||||
|
||||
def mocked_translate_with_instructions(messages, _info=None):
|
||||
"""Mocked translation that captures prompt."""
|
||||
messages_text = messages[0].parts[-1].content
|
||||
captured_prompts.append(messages_text)
|
||||
return ModelResponse(parts=[TextPart(content="Traduction formelle")])
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translate_with_instructions)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions="Use formal tone"
|
||||
)
|
||||
|
||||
assert result.return_value is not None
|
||||
assert len(captured_prompts) == 1
|
||||
assert "Use formal tone" in captured_prompts[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target_language", [None, ""])
|
||||
async def test_document_translate_no_target_language(
|
||||
target_language, mocked_context, mock_translation_agent
|
||||
):
|
||||
"""Test document_translate asks the user for language when target_language is not specified."""
|
||||
mocked_context.deps = mock.Mock()
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translation)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language=target_language, instructions=None
|
||||
)
|
||||
|
||||
assert "target language is not specified" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_no_text_attachments(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate returns error message when no text documents found."""
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(None)
|
||||
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translation)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert "No text documents found in the conversation" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_error_reading_document(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate handles errors when reading documents."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "test.txt"
|
||||
mock_attachment.file_name = "test.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translation)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert "An unexpected error occurred during document translation" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_error_during_translation(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate handles ModelRetry during translation."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "test.txt"
|
||||
mock_attachment.file_name = "test.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "Test content " * 20
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
def mocked_translate_error(_messages, _info=None):
|
||||
"""Mocked translation that raises an error."""
|
||||
raise ValueError("Translation error")
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translate_error)):
|
||||
with pytest.raises(ModelRetry):
|
||||
await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_too_large(settings, mocked_context, mock_translation_agent):
|
||||
"""Test document_translate rejects documents exceeding max chars."""
|
||||
settings.TRANSLATION_MAX_CHARS = 100 # Very small limit
|
||||
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "large_doc.txt"
|
||||
mock_attachment.file_name = "large_doc.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "This is a word. " * 100 # Much larger than 100 chars
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_translation)):
|
||||
result = await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert "too large to translate" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_translate_empty_result(mocked_context, mock_translation_agent):
|
||||
"""Test document_translate raises ModelRetry when translation produces empty result."""
|
||||
mock_attachment = mock.Mock()
|
||||
mock_attachment.key = "test.txt"
|
||||
mock_attachment.file_name = "test.txt"
|
||||
mock_attachment.content_type = "text/plain"
|
||||
mock_attachment.size = None
|
||||
|
||||
mock_conversation = mock.Mock()
|
||||
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
|
||||
|
||||
file_content = "Test content " * 20
|
||||
|
||||
with mock.patch.object(
|
||||
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
|
||||
):
|
||||
mocked_context.deps = mock.Mock()
|
||||
mocked_context.deps.conversation = mock_conversation
|
||||
|
||||
def mocked_empty_translation(_messages, _info=None):
|
||||
"""Mocked translation that returns empty."""
|
||||
return ModelResponse(parts=[TextPart(content=" ")])
|
||||
|
||||
with mock_translation_agent(FunctionModel(mocked_empty_translation)):
|
||||
with pytest.raises(ModelRetry) as exc_info:
|
||||
await document_translate(
|
||||
mocked_context, target_language="French", instructions=None
|
||||
)
|
||||
|
||||
assert "produced an empty result" in str(exc_info.value)
|
||||
@@ -190,6 +190,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -198,15 +199,29 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -317,6 +332,7 @@ def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
"Answer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -325,15 +341,29 @@ def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -438,6 +468,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -446,15 +477,29 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -624,6 +669,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -644,15 +690,29 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "I see a cat in the picture.", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "I see a cat in the picture.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -790,6 +850,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -798,23 +859,31 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -834,6 +903,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
|
||||
@@ -845,17 +915,29 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The current weather in Paris is nice",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -992,6 +1074,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -1000,23 +1083,31 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1036,6 +1127,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": "Unknown tool name: 'get_current_weather'. No tools available.",
|
||||
@@ -1046,17 +1138,29 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "I cannot give you an answer to that.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "I cannot give you an answer to that.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1302,6 +1406,7 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Why the sky is blue?"],
|
||||
@@ -1310,10 +1415,12 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1321,12 +1428,18 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"Rayleigh scattering.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-92c413bb5a45426299335d0621324654",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
"provider_url": "https://www.external-ai-service.com",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
@@ -1442,6 +1555,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -1450,15 +1564,29 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1582,6 +1710,7 @@ async def test_post_conversation_async_triggers_keepalive(
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -1590,15 +1719,29 @@ async def test_post_conversation_async_triggers_keepalive(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": ANY,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": ANY,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
+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,
|
||||
|
||||
@@ -4,7 +4,6 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import semchunk
|
||||
from asgiref.sync import sync_to_async
|
||||
@@ -14,18 +13,11 @@ from pydantic_ai.messages import ToolReturn
|
||||
|
||||
from chat.agents.summarize import SummarizationAgent
|
||||
from chat.tools.exceptions import ModelCannotRetry
|
||||
from chat.tools.utils import last_model_retry_soft_fail
|
||||
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@sync_to_async
|
||||
def read_document_content(doc):
|
||||
"""Read document content asynchronously."""
|
||||
with default_storage.open(doc.key) as f:
|
||||
return doc.file_name, f.read().decode("utf-8")
|
||||
|
||||
|
||||
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
|
||||
"""Summarize a single chunk of text."""
|
||||
sum_prompt = (
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Translation tool used for uploaded documents."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from pydantic_ai import RunContext
|
||||
from pydantic_ai.exceptions import ModelRetry
|
||||
from pydantic_ai.messages import ToolReturn
|
||||
|
||||
from chat.agents.translate import TranslationAgent
|
||||
from chat.tools.exceptions import ModelCannotRetry
|
||||
from chat.tools.utils import last_model_retry_soft_fail, read_document_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def document_translate(
|
||||
ctx: RunContext,
|
||||
*,
|
||||
target_language: str | None = None,
|
||||
instructions: str | None = None,
|
||||
) -> ToolReturn:
|
||||
"""
|
||||
Translate the full content of the last uploaded document into the specified target language.
|
||||
Preserve the original markdown formatting unless the instructions say otherwise.
|
||||
Return this translation directly to the user WITHOUT any modification
|
||||
or additional summarization.
|
||||
The translation is already complete and MUST be presented as-is in the final response.
|
||||
|
||||
If target_language isn't specified or unknown, the target language should be asked
|
||||
to the user.
|
||||
|
||||
Instructions are optional but should reflect the user's request.
|
||||
|
||||
Examples:
|
||||
"Translate this doc to English" -> target_language = "English", instructions = ""
|
||||
"Translate to Spanish, in formal tone" -> target_language = "Spanish",
|
||||
instructions = "Use formal tone"
|
||||
"Traduis ce document en français" -> target_language = "French", instructions = ""
|
||||
"Translate to German, keep technical terms in English" -> target_language = "German",
|
||||
instructions = "Keep technical terms in English"
|
||||
"Translate this" -> ask the user: "Which language would you like the document
|
||||
translated into?"
|
||||
|
||||
Args:
|
||||
target_language (str | None): The language to translate the document into.
|
||||
If None, ask the user.
|
||||
instructions (str | None): Optional instructions for the translation style or preferences
|
||||
"""
|
||||
try:
|
||||
if not target_language:
|
||||
raise ModelCannotRetry(
|
||||
"The target language is not specified. "
|
||||
"You must ask the user which language they want the document translated into."
|
||||
)
|
||||
instructions_hint = (
|
||||
f"Follow these instructions: {instructions.strip()}" if instructions else ""
|
||||
)
|
||||
translation_agent = TranslationAgent()
|
||||
|
||||
# Get the last uploaded text document
|
||||
last_attachment = await (
|
||||
ctx.deps.conversation.attachments.filter(
|
||||
content_type__startswith="text/",
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.afirst()
|
||||
)
|
||||
|
||||
if not last_attachment:
|
||||
raise ModelCannotRetry(
|
||||
"No text documents found in the conversation. "
|
||||
"You must explain this to the user and ask them to provide documents."
|
||||
)
|
||||
|
||||
doc_name, content = await read_document_content(last_attachment)
|
||||
|
||||
max_chars = settings.TRANSLATION_MAX_CHARS
|
||||
if len(content) > max_chars:
|
||||
raise ModelCannotRetry(
|
||||
f"The document is too large to translate ({len(content):,} characters, "
|
||||
f"limit is {max_chars:,}). "
|
||||
"You must explain this to the user, without providing numerical details. "
|
||||
"Suggest them to reduce the document size by summarizing it or "
|
||||
"by splitting it into smaller parts. "
|
||||
"Also offer them to summarize the document in the target language instead, "
|
||||
"which can be a good alternative to translation for large documents."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[translate] translating '%s', %s chars, target_language='%s', instructions='%s'",
|
||||
doc_name,
|
||||
len(content),
|
||||
target_language,
|
||||
instructions_hint,
|
||||
)
|
||||
|
||||
# Translate the document directly
|
||||
translate_prompt = (
|
||||
f"You are an agent specializing in text translation. "
|
||||
f"Translate the following document to {target_language}. "
|
||||
f"Preserve all markdown formatting exactly as-is. "
|
||||
f"{instructions_hint}\n\n"
|
||||
f"'''\n{content}\n'''\n\n"
|
||||
f"Respond directly with the translated text only, no commentary."
|
||||
)
|
||||
|
||||
logger.debug("[translate] prompt for '%s'=> %s", doc_name, translate_prompt[:100] + "...")
|
||||
|
||||
try:
|
||||
resp = await translation_agent.run(translate_prompt, usage=ctx.usage)
|
||||
except Exception as exc:
|
||||
logger.warning("Error during translation of '%s': %s", doc_name, exc, exc_info=True)
|
||||
raise ModelRetry(f"An error occurred while translating document '{doc_name}'.") from exc
|
||||
|
||||
translated_text = (resp.output or "").strip()
|
||||
if not translated_text:
|
||||
raise ModelRetry(f"The translation of '{doc_name}' produced an empty result.")
|
||||
|
||||
logger.debug("[translate] final translation length: %s chars", len(translated_text))
|
||||
|
||||
return ToolReturn(
|
||||
return_value=translated_text,
|
||||
metadata={"sources": {doc_name}},
|
||||
)
|
||||
|
||||
except (ModelCannotRetry, ModelRetry):
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error in document_translate: %s", exc)
|
||||
raise ModelCannotRetry(
|
||||
f"An unexpected error occurred during document translation: {type(exc).__name__}. "
|
||||
"You must explain this to the user and not try to answer based on your knowledge."
|
||||
) from exc
|
||||
@@ -4,6 +4,9 @@ import functools
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from pydantic_ai import ModelRetry, RunContext
|
||||
|
||||
from chat.tools.exceptions import ModelCannotRetry
|
||||
@@ -48,3 +51,10 @@ def last_model_retry_soft_fail(
|
||||
raise # Re-raise to allow retrying
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@sync_to_async
|
||||
def read_document_content(doc):
|
||||
"""Read document content asynchronously."""
|
||||
with default_storage.open(doc.key) as f:
|
||||
return doc.file_name, f.read().decode("utf-8")
|
||||
|
||||
@@ -883,6 +883,13 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Translation
|
||||
TRANSLATION_MAX_CHARS = values.PositiveIntegerValue(
|
||||
default=100_000, # ~100k characters, roughly half a 128k context window
|
||||
environ_name="TRANSLATION_MAX_CHARS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Tavily API
|
||||
TAVILY_API_KEY = values.Value(
|
||||
None, # Tavily API key is not set by default
|
||||
|
||||
@@ -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,554 @@
|
||||
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Loader, Text } from '@/components';
|
||||
import { AttachmentList } from '@/features/chat/components/AttachmentList';
|
||||
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
|
||||
import {
|
||||
CompletedMarkdownBlock,
|
||||
RawTextBlock,
|
||||
} from '@/features/chat/components/MessageBlock';
|
||||
import { SourceItemList } from '@/features/chat/components/SourceItemList';
|
||||
import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem';
|
||||
|
||||
// Memoized blocks list to prevent parent re-renders from causing block remounts
|
||||
const BlocksList = React.memo(
|
||||
({ blocks, pending }: { blocks: string[]; pending: string }) => (
|
||||
<div>
|
||||
{/* key={index} is safe here: blocks are append-only during streaming
|
||||
and a completed block's content never changes once finalized. */}
|
||||
{blocks.map((block, index) => (
|
||||
<CompletedMarkdownBlock key={index} content={block} />
|
||||
))}
|
||||
{pending && <RawTextBlock content={pending} />}
|
||||
</div>
|
||||
),
|
||||
(prev, next) => {
|
||||
const lengthChanged = prev.blocks.length !== next.blocks.length;
|
||||
const pendingChanged = prev.pending !== next.pending;
|
||||
|
||||
let blocksChanged = false;
|
||||
for (let i = 0; i < Math.min(prev.blocks.length, next.blocks.length); i++) {
|
||||
if (prev.blocks[i] !== next.blocks[i]) {
|
||||
blocksChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (lengthChanged || pendingChanged || blocksChanged) {
|
||||
return false; // needs re-render
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
BlocksList.displayName = 'BlocksList';
|
||||
|
||||
export interface StreamingContent {
|
||||
completedBlocks: string[];
|
||||
pending: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits content into blocks by double newlines, respecting code fences.
|
||||
* Code fences may contain double newlines, so we merge blocks until fences are balanced.
|
||||
*/
|
||||
export const splitIntoBlocks = (content: string): string[] => {
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawBlocks = content.split('\n\n');
|
||||
const blocks: string[] = [];
|
||||
let currentBlock = '';
|
||||
let fenceCount = 0;
|
||||
|
||||
for (const rawBlock of rawBlocks) {
|
||||
const fences = (rawBlock.match(/```/g) || []).length;
|
||||
|
||||
currentBlock = currentBlock ? currentBlock + '\n\n' + rawBlock : rawBlock;
|
||||
fenceCount += fences;
|
||||
|
||||
// Balanced fences = complete block
|
||||
if (fenceCount % 2 === 0) {
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
currentBlock = '';
|
||||
fenceCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits streaming content into completed blocks (safe and ready to render as markdown)
|
||||
* + a pending content (still being streamed, rendered as raw text).
|
||||
*
|
||||
* A block is considered completed when followed by a double newline.
|
||||
* Each block is returned separately to enable independent memoization.
|
||||
* NB: it respects code fences (``` ... ```) that may contain double newlines.
|
||||
*/
|
||||
export const splitStreamingContent = (content: string): StreamingContent => {
|
||||
if (!content) {
|
||||
return { completedBlocks: [], pending: '' };
|
||||
}
|
||||
|
||||
// Find all code fence positions
|
||||
// Note: this counts all ``` occurrences including those inside inline code spans.
|
||||
// In practice this is unlikely to cause issues since inline code rarely contains ```.
|
||||
const fenceRegex = /```/g;
|
||||
const fences: number[] = [];
|
||||
let match;
|
||||
while ((match = fenceRegex.exec(content)) !== null) {
|
||||
fences.push(match.index);
|
||||
}
|
||||
|
||||
// Check if we're inside an unclosed code fence
|
||||
const isInsideCodeFence = fences.length % 2 === 1;
|
||||
|
||||
let completedContent: string;
|
||||
let pendingContent: string;
|
||||
|
||||
if (isInsideCodeFence) {
|
||||
// Find the last opening fence
|
||||
const lastFenceStart = fences[fences.length - 1];
|
||||
// Everything before the unclosed fence is potentially complete
|
||||
const beforeFence = content.slice(0, lastFenceStart);
|
||||
const fenceAndAfter = content.slice(lastFenceStart);
|
||||
|
||||
// Find the last complete block boundary before the fence
|
||||
const lastDoubleNewline = beforeFence.lastIndexOf('\n\n');
|
||||
if (lastDoubleNewline !== -1) {
|
||||
completedContent = beforeFence.slice(0, lastDoubleNewline);
|
||||
pendingContent = beforeFence.slice(lastDoubleNewline) + fenceAndAfter;
|
||||
} else {
|
||||
// No complete blocks before fence
|
||||
return { completedBlocks: [], pending: content };
|
||||
}
|
||||
} else {
|
||||
// Not inside a code fence - find the last double newline as block boundary
|
||||
const lastDoubleNewline = content.lastIndexOf('\n\n');
|
||||
if (lastDoubleNewline === -1) {
|
||||
// No double newline yet - everything is pending
|
||||
return { completedBlocks: [], pending: content };
|
||||
}
|
||||
|
||||
// Content up to the last \n\n is complete
|
||||
completedContent = content.slice(0, lastDoubleNewline);
|
||||
// Content after the last \n\n is pending (may be empty if content ends with \n\n)
|
||||
pendingContent = content.slice(lastDoubleNewline + 2);
|
||||
}
|
||||
|
||||
const completedBlocks = splitIntoBlocks(completedContent);
|
||||
return { completedBlocks, pending: pendingContent };
|
||||
};
|
||||
|
||||
interface SourceMetadata {
|
||||
title: string | null;
|
||||
favicon: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface MessageItemProps {
|
||||
message: Message;
|
||||
isLastMessage: boolean;
|
||||
isLastAssistantMessage: boolean;
|
||||
isFirstConversationMessage: boolean;
|
||||
streamingMessageHeight: number | null;
|
||||
status: 'submitted' | 'streaming' | 'ready' | 'error';
|
||||
conversationId: string | undefined;
|
||||
isSourceOpen: string | null;
|
||||
isMobile: boolean;
|
||||
onCopyToClipboard: (content: string) => void;
|
||||
onOpenSources: (messageId: string) => void;
|
||||
getMetadata: (url: string) => SourceMetadata | undefined;
|
||||
}
|
||||
|
||||
const MessageItemComponent: React.FC<MessageItemProps> = ({
|
||||
message,
|
||||
isLastMessage,
|
||||
isLastAssistantMessage,
|
||||
isFirstConversationMessage,
|
||||
streamingMessageHeight,
|
||||
status,
|
||||
conversationId,
|
||||
isSourceOpen,
|
||||
isMobile,
|
||||
onCopyToClipboard,
|
||||
onOpenSources,
|
||||
getMetadata,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldApplyStreamingHeight =
|
||||
isLastAssistantMessage &&
|
||||
isLastMessage &&
|
||||
streamingMessageHeight &&
|
||||
!isFirstConversationMessage;
|
||||
|
||||
const isCurrentlyStreaming =
|
||||
isLastAssistantMessage &&
|
||||
(status === 'streaming' || status === 'submitted');
|
||||
|
||||
const sourceParts = React.useMemo(() => {
|
||||
if (!message.parts) {
|
||||
return [];
|
||||
}
|
||||
return message.parts.filter(
|
||||
(part): part is SourceUIPart => part.type === 'source',
|
||||
);
|
||||
}, [message.parts]);
|
||||
|
||||
const toolInvocationParts = React.useMemo(() => {
|
||||
if (!message.parts) {
|
||||
return [];
|
||||
}
|
||||
return message.parts.filter(
|
||||
(part): part is ToolInvocationUIPart => part.type === 'tool-invocation',
|
||||
);
|
||||
}, [message.parts]);
|
||||
|
||||
const hasNonDocumentParsingTool = React.useMemo(() => {
|
||||
return toolInvocationParts.some(
|
||||
(part) => part.toolInvocation.toolName !== 'document_parsing',
|
||||
);
|
||||
}, [toolInvocationParts]);
|
||||
|
||||
const activeToolInvocation = React.useMemo(() => {
|
||||
const tool = toolInvocationParts.find(
|
||||
(part) => part.toolInvocation.toolName !== 'document_parsing',
|
||||
);
|
||||
return tool?.toolInvocation;
|
||||
}, [toolInvocationParts]);
|
||||
|
||||
const activeToolInvocationDisplayName = React.useMemo(() => {
|
||||
if (!activeToolInvocation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (activeToolInvocation.toolName === 'summarize') {
|
||||
return t('Summarizing...');
|
||||
}
|
||||
if (activeToolInvocation.toolName === 'translate') {
|
||||
return t('Translation in progress...');
|
||||
}
|
||||
|
||||
return t('Search...');
|
||||
}, [activeToolInvocation, t]);
|
||||
|
||||
// Memoize the streaming content split to avoid recreating components in JSX
|
||||
const { completedBlocks, pending } = React.useMemo(() => {
|
||||
// When not streaming, everything is completed as a single block array
|
||||
if (!isCurrentlyStreaming) {
|
||||
return {
|
||||
completedBlocks: splitIntoBlocks(message.content),
|
||||
pending: '',
|
||||
};
|
||||
}
|
||||
return splitStreamingContent(message.content);
|
||||
}, [isCurrentlyStreaming, message.content]);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
onCopyToClipboard(message.content);
|
||||
}, [onCopyToClipboard, message.content]);
|
||||
|
||||
const handleCopyKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCopyToClipboard(message.content);
|
||||
}
|
||||
},
|
||||
[onCopyToClipboard, message.content],
|
||||
);
|
||||
|
||||
const handleOpenSources = React.useCallback(() => {
|
||||
onOpenSources(message.id);
|
||||
}, [onOpenSources, message.id]);
|
||||
|
||||
const handleOpenSourcesKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpenSources(message.id);
|
||||
}
|
||||
},
|
||||
[onOpenSources, message.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-message-id={message.id}
|
||||
data-testid={message.id}
|
||||
$css={`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-bottom: ${isLastAssistantMessage ? '30px' : '0px'};
|
||||
color: var(--c--theme--colors--greyscale-850);
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
max-width: 750px;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$display="block"
|
||||
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
|
||||
>
|
||||
{message.experimental_attachments &&
|
||||
message.experimental_attachments.length > 0 && (
|
||||
<Box>
|
||||
<AttachmentList
|
||||
attachments={message.experimental_attachments}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$radius="8px"
|
||||
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
|
||||
$maxWidth="100%"
|
||||
$padding={`${message.role === 'user' ? '12px' : '0'}`}
|
||||
$margin={{ vertical: 'base' }}
|
||||
$background={`${message.role === 'user' ? '#EEF1F4' : 'white'}`}
|
||||
$css={`
|
||||
display: inline-block;
|
||||
float: right;
|
||||
${shouldApplyStreamingHeight ? `min-height: ${streamingMessageHeight}px;` : ''}`}
|
||||
>
|
||||
{/* Message content */}
|
||||
{message.content && (
|
||||
<Box
|
||||
className="mainContent-chat"
|
||||
data-testid={
|
||||
message.role === 'assistant'
|
||||
? 'assistant-message-content'
|
||||
: undefined
|
||||
}
|
||||
$padding={{ all: 'xxs' }}
|
||||
>
|
||||
<p className="sr-only">
|
||||
{message.role === 'user'
|
||||
? t('You said: ')
|
||||
: t('Assistant IA replied: ')}
|
||||
</p>
|
||||
{message.role === 'user' ? (
|
||||
<Text
|
||||
as="p"
|
||||
$css="white-space: pre-wrap; display: block;"
|
||||
$theme="greyscale"
|
||||
$variation="850"
|
||||
>
|
||||
{message.content}
|
||||
</Text>
|
||||
) : (
|
||||
// Render completed blocks as markdown, pending block as plain text
|
||||
<BlocksList blocks={completedBlocks} pending={pending} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box $direction="column" $gap="2">
|
||||
{isCurrentlyStreaming &&
|
||||
isLastAssistantMessage &&
|
||||
status === 'streaming' &&
|
||||
hasNonDocumentParsingTool && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="6px"
|
||||
$width="100%"
|
||||
$maxWidth="750px"
|
||||
$margin={{
|
||||
all: 'auto',
|
||||
top: 'base',
|
||||
bottom: 'md',
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
<Text $variation="600" $size="md">
|
||||
{activeToolInvocationDisplayName}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{toolInvocationParts.map((part, partIndex) =>
|
||||
isCurrentlyStreaming && isLastAssistantMessage ? (
|
||||
<ToolInvocationItem
|
||||
key={`tool-invocation-${partIndex}`}
|
||||
toolInvocation={part.toolInvocation}
|
||||
status={status}
|
||||
hideSearchLoader={true}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{message.role === 'assistant' &&
|
||||
!(isLastAssistantMessage && status === 'streaming') && (
|
||||
<Box
|
||||
$css="color: #222631; font-size: 12px;"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$gap="6px"
|
||||
$margin={{ top: 'base' }}
|
||||
>
|
||||
<Box $direction="row" $gap="4px">
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
className="c__button--neutral action-chat-button"
|
||||
onClick={handleCopy}
|
||||
onKeyDown={handleCopyKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon
|
||||
iconName="content_copy"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size="16px"
|
||||
className="action-chat-button-icon"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text $theme="greyscale" $variation="550">
|
||||
{t('Copy')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{sourceParts.length > 0 && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
|
||||
onClick={handleOpenSources}
|
||||
onKeyDown={handleOpenSourcesKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon
|
||||
iconName="book"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size="16px"
|
||||
className="action-chat-button-icon"
|
||||
/>
|
||||
<Text
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$weight="500"
|
||||
$size="12px"
|
||||
>
|
||||
{t('Show')} {sourceParts.length}{' '}
|
||||
{sourceParts.length !== 1 ? t('sources') : t('source')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box $direction="row" $gap="4px">
|
||||
{conversationId &&
|
||||
message.id &&
|
||||
message.id.startsWith('trace-') && (
|
||||
<FeedbackButtons
|
||||
conversationId={conversationId}
|
||||
messageId={message.id}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isSourceOpen === message.id && sourceParts.length > 0 && (
|
||||
<Box
|
||||
$css={`
|
||||
animation: fade-in 0.2s ease-out;
|
||||
`}
|
||||
>
|
||||
<SourceItemList parts={sourceParts} getMetadata={getMetadata} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
MessageItemComponent.displayName = 'MessageItem';
|
||||
|
||||
// Custom comparison function for React.memo
|
||||
// Only re-render when props that affect rendering change
|
||||
const arePropsEqual = (
|
||||
prevProps: MessageItemProps,
|
||||
nextProps: MessageItemProps,
|
||||
): boolean => {
|
||||
// Always re-render if message content changed
|
||||
if (prevProps.message.id !== nextProps.message.id) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.message.content !== nextProps.message.content) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.message.role !== nextProps.message.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check parts changes (for streaming tool invocations and sources)
|
||||
const prevPartsLength = prevProps.message.parts?.length ?? 0;
|
||||
const nextPartsLength = nextProps.message.parts?.length ?? 0;
|
||||
if (prevPartsLength !== nextPartsLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check attachments
|
||||
const prevAttachmentsLength =
|
||||
prevProps.message.experimental_attachments?.length ?? 0;
|
||||
const nextAttachmentsLength =
|
||||
nextProps.message.experimental_attachments?.length ?? 0;
|
||||
if (prevAttachmentsLength !== nextAttachmentsLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check rendering flags
|
||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isLastAssistantMessage !== nextProps.isLastAssistantMessage) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
prevProps.isFirstConversationMessage !==
|
||||
nextProps.isFirstConversationMessage
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.streamingMessageHeight !== nextProps.streamingMessageHeight) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isSourceOpen !== nextProps.isSourceOpen) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isMobile !== nextProps.isMobile) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.conversationId !== nextProps.conversationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const MessageItem = React.memo(MessageItemComponent, arePropsEqual);
|
||||
@@ -23,7 +23,6 @@ const SourceItemListComponent: React.FC<SourceItemListProps> = ({
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="column"
|
||||
|
||||
+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],
|
||||
|
||||
+7
-12
@@ -29,13 +29,15 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
?.documents;
|
||||
const documentIdentifiers: string[] =
|
||||
Array.isArray(documents) &&
|
||||
documents.every(
|
||||
(doc): doc is { identifier: string } =>
|
||||
typeof doc === 'object' && doc !== null && 'identifier' in doc,
|
||||
)
|
||||
documents.every(
|
||||
(doc): doc is { identifier: string } =>
|
||||
typeof doc === 'object' && doc !== null && 'identifier' in doc,
|
||||
)
|
||||
? documents.map((doc) => doc.identifier)
|
||||
: [];
|
||||
|
||||
const label = documentIdentifiers.length > 1 ? 'Extracting documents...' : 'Extracting document...';
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
@@ -45,17 +47,10 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
$width="100%"
|
||||
$maxWidth="750px"
|
||||
$margin={{ all: 'auto', top: 'base', bottom: 'md' }}
|
||||
$background="var(--c--contextuals--background--surface--tertiary)"
|
||||
$color="var(--c--contextuals--content--semantic--neutral--secondary)"
|
||||
$padding={{ all: 'sm' }}
|
||||
$radius="8px"
|
||||
$css="font-size: 0.9em; width: 100%; white-space: pre-wrap; word-wrap: break-word;"
|
||||
>
|
||||
<Loader />
|
||||
<Text $variation="600" $size="md">
|
||||
{t('Extracting documents: {{documents}} ...', {
|
||||
documents: documentIdentifiers.join(', '),
|
||||
})}
|
||||
{t(label, { documents: documentIdentifiers.join(', ') })}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
+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 :",
|
||||
@@ -44,6 +54,7 @@
|
||||
"Direct exchange with our team": "Échange direct avec notre équipe",
|
||||
"Enter your message or a question": "Entrez votre message ou une question",
|
||||
"Explore other LaSuite apps": "Explorer les autres applications de LaSuite",
|
||||
"Extracting document: {{documents}} ...": "Extraction du document : {{documents}}...",
|
||||
"Extracting documents: {{documents}} ...": "Extraction des documents : {{documents}}...",
|
||||
"Failed to activate account. Please try again.": "Échec de l'activation du compte. Veuillez réessayer.",
|
||||
"Failed to copy": "Échec de la copie",
|
||||
@@ -55,6 +66,7 @@
|
||||
"Feedback Négatif": "Retour négatif",
|
||||
"Feedback positif": "Retour positif",
|
||||
"File type not supported": "Type de fichier non pris en charge",
|
||||
"Find recent news about...": "Trouver les dernières actualités concernant...",
|
||||
"Get notified about the Public Beta.": "Soyez informé de la Bêta publique.",
|
||||
"Get notified for the public beta": "Être notifié pour la bêta publique",
|
||||
"Give a quick opinion": "Donner un avis rapide",
|
||||
@@ -108,6 +120,7 @@
|
||||
"Start conversation": "Entamer la conversation",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Résumé en cours...",
|
||||
"Translation in progress...": "Traduction en cours...",
|
||||
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "L'Assistant est une IA souveraine destinée aux fonctionnaires. Il les aide dans leurs tâches quotidiennes (reformulation, résumé, traduction, recherche d'informations). Vos données restent en France, sur une infrastructure sécurisée et conforme aux normes nationales, et ne sont jamais utilisées à des fins commerciales.",
|
||||
"The Assistant is in Beta": "L'Assistant est en Bêta",
|
||||
"The conversation has been deleted.": "La conversation a été supprimée.",
|
||||
@@ -115,6 +128,7 @@
|
||||
"The summary feature is not supported yet.": "La fonctionnalité de résumé n'est pas encore prise en charge.",
|
||||
"Thinking...": "Réflexion...",
|
||||
"To add a file to the conversation, drop it here.": "Pour ajouter un fichier à la conversation, déposez-le ici.",
|
||||
"Turn this list into bullet points": "Transformer cette liste en liste à puces...",
|
||||
"Unlock access": "Déverrouiller l'accès",
|
||||
"Unlocking...": "Déverrouillage en cours...",
|
||||
"Untitled conversation": "Conversation sans titre",
|
||||
@@ -124,6 +138,7 @@
|
||||
"We'll email you when the public beta opens.": "Nous vous enverrons un email quand la bêta publique sera ouverte.",
|
||||
"Web": "Web",
|
||||
"What is on your mind?": "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 +166,7 @@
|
||||
"An error occurred while renaming the conversation": "Er is een fout opgetreden bij het hernoemen van het gesprek",
|
||||
"An error occurred. Please try again.": "Er is een fout opgetreden. Probeer het opnieuw.",
|
||||
"Are you sure you want to delete this conversation ?": "Weet u zeker dat u dit gesprek wilt verwijderen?",
|
||||
"Ask a question": "Stel een vraag",
|
||||
"Assistant IA replied: ": "AI Assistent antwoordde: ",
|
||||
"Assistant is already available, log in to use it now.": "Assistent is al beschikbaar, log in om het nu te gebruiken.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Assistent is in ontwikkeling: uw feedback telt! Kies hoe u uw ideeën wilt delen:",
|
||||
@@ -179,6 +195,7 @@
|
||||
"Direct exchange with our team": "Directe uitwisseling met ons team",
|
||||
"Enter your message or a question": "Voer uw bericht of een vraag in",
|
||||
"Explore other LaSuite apps": "Ontdek andere LaSuite-apps",
|
||||
"Extracting document: {{documents}} ...": "Document uitpakken: {{documents}}...",
|
||||
"Extracting documents: {{documents}} ...": "Documenten uitpakken: {{documents}}...",
|
||||
"Failed to activate account. Please try again.": "Account activeren mislukt. Probeer het opnieuw.",
|
||||
"Failed to copy": "Kopiëren mislukt",
|
||||
@@ -190,6 +207,7 @@
|
||||
"Feedback Négatif": "Negatieve feedback",
|
||||
"Feedback positif": "Positieve feedback",
|
||||
"File type not supported": "Bestandstype niet ondersteund",
|
||||
"Find recent news about...": "Vind het laatste nieuws over...",
|
||||
"Get notified about the Public Beta.": "Ontvang een melding over de openbare bèta.",
|
||||
"Get notified for the public beta": "Ontvang een melding voor de openbare bètaversie",
|
||||
"Give a quick opinion": "Geef snel een mening",
|
||||
@@ -243,6 +261,7 @@
|
||||
"Start conversation": "Begin een gesprek",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Samenvatten...",
|
||||
"Translation in progress...": "Vertaling bezig...",
|
||||
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "De assistent is een soevereine AI voor overheidsambtenaren. Het helpt met dagelijkse taken (herformuleren, samenvatten, vertalen, informatiezoeken). Je gegevens blijven in Nederland op een veilige, door de staat compatibele infrastructuur en worden nooit voor commerciële doeleinden gebruikt.",
|
||||
"The Assistant is in Beta": "De Assistent is in bèta",
|
||||
"The conversation has been deleted.": "Het gesprek is verwijderd.",
|
||||
@@ -250,6 +269,7 @@
|
||||
"The summary feature is not supported yet.": "De samenvattingsfunctie wordt nog niet ondersteund.",
|
||||
"Thinking...": "Denken...",
|
||||
"To add a file to the conversation, drop it here.": "Als u een bestand aan het gesprek wilt toevoegen, sleept u het hierheen.",
|
||||
"Turn this list into bullet points": "Zet deze lijst om in opsommingstekens",
|
||||
"Unlock access": "Toegang ontgrendelen",
|
||||
"Unlocking...": "Ontgrendelen...",
|
||||
"Untitled conversation": "Ongetiteld gesprek",
|
||||
@@ -259,6 +279,7 @@
|
||||
"We'll email you when the public beta opens.": "We sturen u een e-mail zodra de openbare bètaversie beschikbaar is.",
|
||||
"Web": "Internet",
|
||||
"What is on your mind?": "Waar denk je aan?",
|
||||
"Write a short product description": "Schrijf een korte productbeschrijving",
|
||||
"Write on Tchap": "Schrijf op Tchap",
|
||||
"You are on the list": "Je staat op de lijst",
|
||||
"You do not have permission to view this page.": "U heeft geen toestemming om deze pagina te bekijken.",
|
||||
@@ -286,6 +307,7 @@
|
||||
"An error occurred while renaming the conversation": "Ошибка при переименовании беседы",
|
||||
"An error occurred. Please try again.": "Произошла ошибка. Пожалуйста, повторите попытку.",
|
||||
"Are you sure you want to delete this conversation ?": "Вы действительно хотите удалить эту беседу?",
|
||||
"Ask a question": "Задать вопрос",
|
||||
"Assistant IA replied: ": "Помощник ИИ ответил: ",
|
||||
"Assistant is already available, log in to use it now.": "Помощник уже доступен, просто войдите в систему.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помощник находится в разработке: ваши отзывы важны! Выберите, как поделиться своими идеями:",
|
||||
@@ -314,6 +336,7 @@
|
||||
"Direct exchange with our team": "Прямое общение с нашей командой",
|
||||
"Enter your message or a question": "Введите сообщение или вопрос",
|
||||
"Explore other LaSuite apps": "Посмотреть другие приложения LaSuite",
|
||||
"Extracting document: {{documents}} ...": "Извлечение документа: {{documents}}...",
|
||||
"Extracting documents: {{documents}} ...": "Извлечение документов: {{documents}}...",
|
||||
"Failed to activate account. Please try again.": "Не удалось активировать учётную запись. Пожалуйста, попробуйте снова.",
|
||||
"Failed to copy": "Не удалось скопировать",
|
||||
@@ -325,6 +348,7 @@
|
||||
"Feedback Négatif": "Отрицательный отзыв",
|
||||
"Feedback positif": "Положительный отзыв",
|
||||
"File type not supported": "Тип файла не поддерживается",
|
||||
"Find recent news about...": "Найти последние новости...",
|
||||
"Get notified about the Public Beta.": "Получать уведомления о публичной бета-версии.",
|
||||
"Get notified for the public beta": "Получать уведомления о публичной бета-версии",
|
||||
"Give a quick opinion": "Оставить быстрый отзыв",
|
||||
@@ -378,6 +402,7 @@
|
||||
"Start conversation": "Начать беседу",
|
||||
"Stop": "Остановить",
|
||||
"Summarizing...": "Обобщение...",
|
||||
"Translation in progress...": "Перевод выполняется...",
|
||||
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помощник — независимый ИИ для государственных служащих. Он помогает в выполнении повседневных задач (перефразирование, резюмирование, перевод, поиск информации). Ваши данные хранятся во Франции на безопасной инфраструктуре, соответствующей государственным требованиям, и никогда не используются в коммерческих целях.",
|
||||
"The Assistant is in Beta": "Помощник находится на этапе Бета-версии",
|
||||
"The conversation has been deleted.": "Беседа была удалена.",
|
||||
@@ -385,6 +410,7 @@
|
||||
"The summary feature is not supported yet.": "Функция сводки пока не поддерживается.",
|
||||
"Thinking...": "Размышление...",
|
||||
"To add a file to the conversation, drop it here.": "Чтобы добавить файл в беседу, поместите его сюда.",
|
||||
"Turn this list into bullet points": "Преобразовать этот список в маркированный",
|
||||
"Unlock access": "Разблокировать",
|
||||
"Unlocking...": "Разблокировка...",
|
||||
"Untitled conversation": "Беседа без названия",
|
||||
@@ -394,6 +420,7 @@
|
||||
"We'll email you when the public beta opens.": "Мы отправим вам письмо, когда станет доступна публичная бета-версия.",
|
||||
"Web": "Интернет",
|
||||
"What is on your mind?": "О чём вы думаете?",
|
||||
"Write a short product description": "Введите краткое описание продукта",
|
||||
"Write on Tchap": "Написать в Tchap",
|
||||
"You are on the list": "Вы в списке",
|
||||
"You do not have permission to view this page.": "У вас недостаточно прав для просмотра этой страницы.",
|
||||
@@ -421,6 +448,7 @@
|
||||
"An error occurred while renaming the conversation": "Сталася помилка під час перейменування розмови",
|
||||
"An error occurred. Please try again.": "Сталась помилка. Спробуйте ще раз.",
|
||||
"Are you sure you want to delete this conversation ?": "Ви дійсно бажаєте видалити цю розмову?",
|
||||
"Ask a question": "Задати питання",
|
||||
"Assistant IA replied: ": "Відповідь помічника ШІ: ",
|
||||
"Assistant is already available, log in to use it now.": "Помічник вже доступний, увійдіть щоб почати використання.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помічник в розробці: ваші відгуки мають значення! Оберіть, як поділитися своїми ідеями:",
|
||||
@@ -449,6 +477,7 @@
|
||||
"Direct exchange with our team": "Пряме спілкування з нашою командою",
|
||||
"Enter your message or a question": "Введіть ваше повідомлення або питання",
|
||||
"Explore other LaSuite apps": "Ознайомтесь з іншими застосунками LaSuite",
|
||||
"Extracting document: {{documents}} ...": "Видобування документа: {{documents}}...",
|
||||
"Extracting documents: {{documents}} ...": "Видобування документів: {{documents}}...",
|
||||
"Failed to activate account. Please try again.": "Не вдалося активувати обліковий запис. Спробуйте ще раз.",
|
||||
"Failed to copy": "Не вдалось скопіювати",
|
||||
@@ -460,6 +489,7 @@
|
||||
"Feedback Négatif": "Негативний відгук",
|
||||
"Feedback positif": "Позитивний відгук",
|
||||
"File type not supported": "Тип файлу не підтримується",
|
||||
"Find recent news about...": "Знайти останні новини про...",
|
||||
"Get notified about the Public Beta.": "Отримувати повідомлення про публічну бета-версію.",
|
||||
"Get notified for the public beta": "Отримувати повідомлення про публічну бета-версію",
|
||||
"Give a quick opinion": "Дати швидкий відгук",
|
||||
@@ -513,6 +543,7 @@
|
||||
"Start conversation": "Почати розмову",
|
||||
"Stop": "Зупинити",
|
||||
"Summarizing...": "Узагальнення...",
|
||||
"Translation in progress...": "Переклад виконується...",
|
||||
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помічник — це незалежний штучний інтелект для державних службовців. Він допомагає у виконанні щоденних завдань (перефразування, узагальнення, переклад, пошук інформації). Ваші дані зберігаються у Франції на безпечній інфраструктурі, що відповідає державним вимогам, і ніколи не використовуються в комерційних цілях.",
|
||||
"The Assistant is in Beta": "Помічник у бета-версії",
|
||||
"The conversation has been deleted.": "Розмова була видалена.",
|
||||
@@ -520,6 +551,7 @@
|
||||
"The summary feature is not supported yet.": "Функція узагальнення ще не підтримується.",
|
||||
"Thinking...": "Мислення...",
|
||||
"To add a file to the conversation, drop it here.": "Щоб додати файл до розмови, покладіть його сюди.",
|
||||
"Turn this list into bullet points": "Перетворити цей список на маркований",
|
||||
"Unlock access": "Розблокувати доступ",
|
||||
"Unlocking...": "Розблоковування...",
|
||||
"Untitled conversation": "Розмова без назви",
|
||||
|
||||
@@ -301,6 +301,40 @@ figure[data-rehype-pretty-code-figure] > pre > code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mainContent-chat table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 32px;
|
||||
overflow-x: scroll;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mainContent-chat table th {
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mainContent-chat table tr,
|
||||
.mainContent-chat table th {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
}
|
||||
|
||||
.mainContent-chat tbody tr,
|
||||
.mainContent-chat thead th {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mainContent-chat table tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mainContent-chat table td,
|
||||
.mainContent-chat table th {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { overrideConfig } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/home/');
|
||||
});
|
||||
@@ -69,4 +71,71 @@ test.describe('Chat page', () => {
|
||||
await chatHistoryLink.click();
|
||||
await expect(messageContent).toBeVisible();
|
||||
});
|
||||
|
||||
test('the user can paste a document into the chat input', async ({ page }) => {
|
||||
await overrideConfig(page, {
|
||||
FEATURE_FLAGS: {
|
||||
'document-upload': 'enabled',
|
||||
'web-search': 'enabled',
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const chatInput = page.getByRole('textbox', {
|
||||
name: 'Enter your message or a',
|
||||
});
|
||||
await expect(chatInput).toBeVisible();
|
||||
await chatInput.click();
|
||||
|
||||
// Create a test file content
|
||||
const fileContent = 'Test document content for paste';
|
||||
const fileName = 'test-document.txt';
|
||||
const fileType = 'text/plain';
|
||||
|
||||
// Simulate paste event with file
|
||||
await page.evaluate(
|
||||
({ content, name, type }) => {
|
||||
const textarea = document.querySelector(
|
||||
'textarea[name="inputchat-textarea"]',
|
||||
) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
|
||||
// Create a File object
|
||||
const file = new File([content], name, { type });
|
||||
|
||||
// Create a DataTransfer object to simulate clipboard
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
// Create a paste event - ClipboardEvent constructor doesn't accept clipboardData
|
||||
// so we create a regular Event and add clipboardData property
|
||||
const pasteEvent = new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}) as unknown as ClipboardEvent;
|
||||
|
||||
// Define clipboardData property to make it accessible
|
||||
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||
value: {
|
||||
files: dataTransfer.files,
|
||||
items: dataTransfer.items,
|
||||
types: Array.from(dataTransfer.types),
|
||||
getData: () => '',
|
||||
setData: () => {},
|
||||
},
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(pasteEvent);
|
||||
},
|
||||
{ content: fileContent, name: fileName, type: fileType },
|
||||
);
|
||||
|
||||
// Wait for the file to be processed and appear in the attachment list
|
||||
// The attachment should be visible with the file name
|
||||
const attachment = page.getByText(fileName, { exact: false }).first();
|
||||
await expect(attachment).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,10 +234,10 @@ backend:
|
||||
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for backend readiness probe
|
||||
probes:
|
||||
liveness:
|
||||
path: /__heartbeat__
|
||||
path: /__lbheartbeat__
|
||||
initialDelaySeconds: 10
|
||||
readiness:
|
||||
path: /__lbheartbeat__
|
||||
path: /__heartbeat__
|
||||
initialDelaySeconds: 10
|
||||
|
||||
## @param backend.resources Resource requirements for the backend container
|
||||
|
||||
Reference in New Issue
Block a user