From ed536bc6b6d11c779855095030e7c0dd04d78dbb Mon Sep 17 00:00:00 2001 From: Maxence Haouari Date: Thu, 9 Apr 2026 19:04:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(docs)=20integrate=20"Edit=20in=20Docs?= =?UTF-8?q?"=20feature=20with=20API=20updates=20and=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create DocsClient to call Docs external api. Add button EditInDocs --- env.d/development/common.dist | 11 + src/backend/chat/docs_client.py | 47 ++++ src/backend/chat/serializers.py | 6 + src/backend/chat/tests/test_docs_client.py | 164 ++++++++++++ .../chat/conversations/test_export_to_docs.py | 251 ++++++++++++++++++ src/backend/chat/views.py | 73 ++++- src/backend/conversations/settings.py | 12 + .../conversations/tests/test_settings_docs.py | 60 +++++ src/backend/core/api/viewsets.py | 3 + src/backend/core/tests/test_api_config.py | 20 ++ .../src/core/config/api/useConfig.tsx | 1 + .../src/features/chat/api/useEditInDocs.tsx | 32 +++ .../features/chat/components/MessageItem.tsx | 12 +- .../chat/components/MoreActionsButton.tsx | 142 ++++++++++ 14 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 src/backend/chat/docs_client.py create mode 100644 src/backend/chat/tests/test_docs_client.py create mode 100644 src/backend/chat/tests/views/chat/conversations/test_export_to_docs.py create mode 100644 src/backend/conversations/tests/test_settings_docs.py create mode 100644 src/frontend/apps/conversations/src/features/chat/api/useEditInDocs.tsx create mode 100644 src/frontend/apps/conversations/src/features/chat/components/MoreActionsButton.tsx diff --git a/env.d/development/common.dist b/env.d/development/common.dist index dee107a8..ffb10e4d 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -53,3 +53,14 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} # AI_BASE_URL=https://openaiendpoint.com AI_API_KEY=password # AI_MODEL=llama + +## --- Addition Docs Resource Server Config +# Docs integration — "Edit in Docs" export feature +# DOCS_BASE_URL=http://docs +# DOCS_API_TIMEOUT=30 + +# Store OIDC tokens in the session +# OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session +# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session +# Required for refresh token encryption +# OIDC_STORE_REFRESH_TOKEN_KEY = "" # Must be a valid Fernet key (32 url-safe base64-encoded bytes) diff --git a/src/backend/chat/docs_client.py b/src/backend/chat/docs_client.py new file mode 100644 index 00000000..352f84ae --- /dev/null +++ b/src/backend/chat/docs_client.py @@ -0,0 +1,47 @@ +"""Implementation of the La Suite Docs External API.""" + +from io import BytesIO +from urllib.parse import urljoin + +from django.conf import settings +from django.core.exceptions import PermissionDenied + +import requests + + +class DocsClient: + """ + Client for interacting with the La Suite Docs External API. + It provides methods to: + - Create a new document in Docs + - ... more methods can be added here as needed. + """ + + def __init__(self): + """Initialize the DocsClient with necessary configuration. + The API URL and timeout are set based on Django settings.""" + self.timeout = settings.DOCS_API_TIMEOUT + self.api_url = urljoin(settings.DOCS_BASE_URL, "external_api/v1.0/") + + def get_access_token(self, session): + """Retrieve the OIDC access token from the user's session.""" + access_token = session.get("oidc_access_token") + if not access_token: + raise PermissionDenied("User is not authenticated with OIDC.") + return access_token + + def create_document(self, title: str, content: str, session, **kwargs) -> dict: + """ + POST /external_api/v1.0/documents/ with markdown file upload (multipart). + """ + access_token = self.get_access_token(session) + file = BytesIO(content.encode("utf-8")) + file.name = f"{title}.md" + response = requests.post( + urljoin(self.api_url, "documents/"), + headers={"Authorization": f"Bearer {access_token}"}, + files={"file": (file.name, file, "text/markdown")}, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() diff --git a/src/backend/chat/serializers.py b/src/backend/chat/serializers.py index 23d9e250..c2d4d636 100644 --- a/src/backend/chat/serializers.py +++ b/src/backend/chat/serializers.py @@ -162,6 +162,12 @@ class ChatMessageCategoricalScoreSerializer(serializers.Serializer): # pylint: ) +class ExportMessageToDocsSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for exporting a single assistant message to Docs.""" + + message_id = serializers.CharField(help_text="ID of the assistant message to export.") + + class LLMConfigurationSerializer(serializers.Serializer): # pylint: disable=abstract-method """Serializer for LLM configuration.""" diff --git a/src/backend/chat/tests/test_docs_client.py b/src/backend/chat/tests/test_docs_client.py new file mode 100644 index 00000000..9b951f52 --- /dev/null +++ b/src/backend/chat/tests/test_docs_client.py @@ -0,0 +1,164 @@ +"""Tests for the DocsClient.""" + +from unittest.mock import MagicMock, patch + +from django.core.exceptions import PermissionDenied + +import pytest +import requests + +from chat.docs_client import DocsClient + + +@pytest.fixture(name="docs_client") +def docs_client_fixture(settings): + """Instantiate a DocsClient with test settings.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.DOCS_API_TIMEOUT = 10 + return DocsClient() + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +def test_docs_client_init_api_url(settings): + """api_url must be the base URL joined with the versioned API path.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.DOCS_API_TIMEOUT = 15 + client = DocsClient() + assert client.api_url == "http://docs.example.com/external_api/v1.0/" + + +def test_docs_client_init_api_url_without_trailing_slash(settings): + """DOCS_BASE_URL without a trailing slash must still produce a correct api_url.""" + settings.DOCS_BASE_URL = "http://docs.example.com" + settings.DOCS_API_TIMEOUT = 10 + client = DocsClient() + # urljoin("http://docs.example.com", "external_api/v1.0/") → + # "http://docs.example.com/external_api/v1.0/" + assert client.api_url == "http://docs.example.com/external_api/v1.0/" + + +def test_docs_client_init_timeout(settings): + """timeout must be taken from DOCS_API_TIMEOUT.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.DOCS_API_TIMEOUT = 42 + client = DocsClient() + assert client.timeout == 42 + + +# --------------------------------------------------------------------------- +# get_access_token +# --------------------------------------------------------------------------- + + +def test_get_access_token_returns_token(docs_client): + """get_access_token returns the token stored in the session.""" + session = {"oidc_access_token": "my-secret-token"} + assert docs_client.get_access_token(session) == "my-secret-token" + + +def test_get_access_token_raises_when_missing(docs_client): + """get_access_token raises PermissionDenied when the session has no token.""" + with pytest.raises(PermissionDenied): + docs_client.get_access_token({}) + + +def test_get_access_token_raises_when_none(docs_client): + """get_access_token raises PermissionDenied when the token is None.""" + with pytest.raises(PermissionDenied): + docs_client.get_access_token({"oidc_access_token": None}) + + +# --------------------------------------------------------------------------- +# create_document +# --------------------------------------------------------------------------- + + +def test_create_document_success(docs_client): + """create_document posts to the correct URL and returns parsed JSON.""" + session = {"oidc_access_token": "tok123"} + fake_response = MagicMock() + fake_response.json.return_value = {"id": "doc-abc", "url": "http://docs.example.com/doc-abc"} + + with patch("requests.post", return_value=fake_response) as mock_post: + result = docs_client.create_document( + title="My Document", content="# Hello\n\nWorld", session=session + ) + + assert result == {"id": "doc-abc", "url": "http://docs.example.com/doc-abc"} + mock_post.assert_called_once() + + +def test_create_document_posts_to_correct_url(docs_client): + """create_document POSTs to documents/.""" + session = {"oidc_access_token": "tok123"} + fake_response = MagicMock() + fake_response.json.return_value = {"id": "doc-abc"} + + with patch("requests.post", return_value=fake_response) as mock_post: + docs_client.create_document(title="Title", content="body", session=session) + + call_args = mock_post.call_args + assert call_args.args[0] == "http://docs.example.com/external_api/v1.0/documents/" + + +def test_create_document_sends_bearer_token(docs_client): + """create_document sends the OIDC token as a Bearer Authorization header.""" + session = {"oidc_access_token": "bearer-xyz"} + fake_response = MagicMock() + fake_response.json.return_value = {"id": "doc-abc"} + + with patch("requests.post", return_value=fake_response) as mock_post: + docs_client.create_document(title="Title", content="body", session=session) + + headers = mock_post.call_args.kwargs["headers"] + assert headers["Authorization"] == "Bearer bearer-xyz" + + +def test_create_document_sends_markdown_file(docs_client): + """create_document uploads the content as a .md file with text/markdown MIME type.""" + session = {"oidc_access_token": "tok"} + fake_response = MagicMock() + fake_response.json.return_value = {"id": "doc-abc"} + + with patch("requests.post", return_value=fake_response) as mock_post: + docs_client.create_document(title="My Doc", content="# Hello", session=session) + + files = mock_post.call_args.kwargs["files"] + assert "file" in files + filename, fileobj, mimetype = files["file"] + assert filename == "My Doc.md" + assert mimetype == "text/markdown" + assert fileobj.read() == b"# Hello" + + +def test_create_document_uses_configured_timeout(docs_client): + """create_document passes the configured timeout to requests.post.""" + session = {"oidc_access_token": "tok"} + fake_response = MagicMock() + fake_response.json.return_value = {"id": "doc-abc"} + + with patch("requests.post", return_value=fake_response) as mock_post: + docs_client.create_document(title="T", content="c", session=session) + + assert mock_post.call_args.kwargs["timeout"] == 10 + + +def test_create_document_raises_on_http_error(docs_client): + """create_document propagates HTTPError from raise_for_status.""" + session = {"oidc_access_token": "tok"} + fake_response = MagicMock() + fake_response.raise_for_status.side_effect = requests.exceptions.HTTPError("403 Forbidden") + + with patch("requests.post", return_value=fake_response): + with pytest.raises(requests.exceptions.HTTPError): + docs_client.create_document(title="T", content="c", session=session) + + +def test_create_document_raises_when_no_oidc_token(docs_client): + """create_document raises PermissionDenied when the session has no OIDC token.""" + with pytest.raises(PermissionDenied): + docs_client.create_document(title="T", content="c", session={}) diff --git a/src/backend/chat/tests/views/chat/conversations/test_export_to_docs.py b/src/backend/chat/tests/views/chat/conversations/test_export_to_docs.py new file mode 100644 index 00000000..bbc6fbb7 --- /dev/null +++ b/src/backend/chat/tests/views/chat/conversations/test_export_to_docs.py @@ -0,0 +1,251 @@ +"""Tests for the export_to_docs view action.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests +from rest_framework import status + +from core.factories import UserFactory + +from chat.ai_sdk_types import TextUIPart, UIMessage +from chat.factories import ChatConversationFactory + +pytestmark = pytest.mark.django_db + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +@pytest.fixture(name="assistant_message") +def assistant_message_fixture(): + """Build a UIMessage with a single TextUIPart for use in conversation.messages.""" + return UIMessage( + id="assistant-message-1", + role="assistant", + content="Assistant text", + parts=[TextUIPart(type="text", text="Assistant text")], + ) + + +@pytest.fixture(name="user_message") +def user_message_fixture(): + """Build a user UIMessage.""" + return UIMessage( + id="user-message-1", + role="user", + content="User text", + parts=[TextUIPart(type="text", text="User text")], + ) + + +@pytest.fixture(name="assistant_message_with_multiple_text_parts") +def assistant_message_with_multiple_text_parts_fixture(): + """Build a UIMessage with a multi Part TextUIPart for use in conversation.messages.""" + return UIMessage( + id="assistant-message-2", + role="assistant", + content="Assistant text", + parts=[ + TextUIPart(type="text", text="Assistant text part 1"), + TextUIPart(type="text", text="Assistant text part 2"), + ], + ) + + +@pytest.fixture(name="conversation_with_messages") +def conversation_with_messages_fixture(assistant_message, user_message): + """Create a ChatConversation with the given messages.""" + conversation = ChatConversationFactory() + conversation.messages = [assistant_message, user_message] + conversation.save() + return conversation + + +def _mock_docs_create(doc_id="doc-123"): + """Return a patch context for DocsClient.create_document.""" + mock = MagicMock(return_value={"id": doc_id}) + return patch("chat.views.DocsClient.create_document", mock), mock + + +# --------------------------------------------------------------------------- +# Authentication / authorisation +# --------------------------------------------------------------------------- +def test_export_to_docs_unauthenticated(api_client): + """Unauthenticated requests must receive HTTP 401.""" + conversation = ChatConversationFactory() + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + response = api_client.post(url, data={"message_id": "assistant-message-1"}, format="json") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_export_to_docs_other_users_conversation(api_client): + """A user cannot export from another user's conversation — expects HTTP 404.""" + conversation = ChatConversationFactory() + api_client.force_login(UserFactory()) + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + response = api_client.post(url, data={"message_id": "assistant-message-1"}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_export_to_docs_nonexistent_conversation(api_client): + """Exporting from a nonexistent conversation must return HTTP 404.""" + api_client.force_login(UserFactory()) + url = "/api/v1.0/chats/99999/export-to-docs/" + + response = api_client.post(url, data={"message_id": "assistant-message-1"}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --------------------------------------------------------------------------- +# Request validation +# --------------------------------------------------------------------------- +def test_export_to_docs_missing_message_id(api_client): + """Missing message_id in request body must return HTTP 400.""" + conversation = ChatConversationFactory() + api_client.force_login(conversation.owner) + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + response = api_client.post(url, data={}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_export_to_docs_message_not_found(api_client, conversation_with_messages): + """An unknown message_id must return HTTP 400 with a descriptive error.""" + conversation = conversation_with_messages + api_client.force_login(conversation.owner) + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + response = api_client.post(url, data={"message_id": "msg-does-not-exist"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "message_id" in response.json() + + +def test_export_to_docs_user_message_not_exportable(api_client, conversation_with_messages): + """Attempting to export a user message (not assistant) must return HTTP 400.""" + conversation = conversation_with_messages + api_client.force_login(conversation.owner) + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + response = api_client.post(url, data={"message_id": "user-message-1"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# --------------------------------------------------------------------------- +# Successful export +# --------------------------------------------------------------------------- +def test_export_to_docs_success(api_client, settings, conversation_with_messages): + """A valid request creates a Docs document and returns 201 with docId and docUrl.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.OIDC_STORE_ACCESS_TOKEN = True + + conversation = conversation_with_messages + api_client.force_login(conversation.owner) + + # Inject an OIDC token into the session via a force-login session override + session = api_client.session + session["oidc_access_token"] = "test-token" + session.save() + + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + ctx, mock_create = _mock_docs_create("doc-abc") + with ctx: + response = api_client.post(url, data={"message_id": "assistant-message-1"}, format="json") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["docId"] == "doc-abc" + assert data["docUrl"] == "http://docs.example.com/doc-abc" + mock_create.assert_called_once() + + +def test_export_to_docs_content_joins_text_parts( + api_client, settings, assistant_message_with_multiple_text_parts +): + """Content passed to create_document must join all text parts with double newlines.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.OIDC_STORE_ACCESS_TOKEN = True + + conversation = ChatConversationFactory() + conversation.messages = [assistant_message_with_multiple_text_parts] + conversation.save() + api_client.force_login(conversation.owner) + + session = api_client.session + session["oidc_access_token"] = "test-token" + session.save() + + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + ctx, mock_create = _mock_docs_create() + with ctx: + api_client.post(url, data={"message_id": "assistant-message-2"}, format="json") + + call_kwargs = mock_create.call_args + assert call_kwargs.kwargs["content"] == "Assistant text part 1\n\nAssistant text part 2" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- +def test_export_to_docs_empty_content_returns_400(api_client, settings): + """An assistant message with no text parts must return HTTP 400.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.OIDC_STORE_ACCESS_TOKEN = True + + # Message has no text parts — only a source-type part + message = UIMessage( + id="assistant-message-no-text", + role="assistant", + content="", + parts=[], + ) + conversation = ChatConversationFactory() + conversation.messages = [message] + conversation.save() + api_client.force_login(conversation.owner) + + session = api_client.session + session["oidc_access_token"] = "test-token" + session.save() + + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + response = api_client.post(url, data={"message_id": "assistant-message-no-text"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "message_id" in response.json() + + +def test_export_to_docs_docs_service_unavailable_returns_503( + api_client, settings, conversation_with_messages +): + """When the Docs service raises a network error, the view must return HTTP 503.""" + settings.DOCS_BASE_URL = "http://docs.example.com/" + settings.OIDC_STORE_ACCESS_TOKEN = True + + conversation = conversation_with_messages + api_client.force_login(conversation.owner) + + session = api_client.session + session["oidc_access_token"] = "test-token" + session.save() + + url = f"/api/v1.0/chats/{conversation.pk}/export-to-docs/" + + with patch( + "chat.views.DocsClient.create_document", + side_effect=requests.exceptions.ConnectionError("Docs unreachable"), + ): + response = api_client.post(url, data={"message_id": "assistant-message-1"}, format="json") + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert "detail" in response.json() diff --git a/src/backend/chat/views.py b/src/backend/chat/views.py index 17297423..f7e5a812 100644 --- a/src/backend/chat/views.py +++ b/src/backend/chat/views.py @@ -2,6 +2,7 @@ import logging import os +from urllib.parse import urljoin from uuid import UUID, uuid4 from django.conf import settings @@ -14,6 +15,7 @@ import langfuse import magic import posthog +import requests as http_requests from drf_spectacular.utils import extend_schema from lasuite.malware_detection import malware_detection from lasuite.oidc_login.decorators import refresh_oidc_access_token @@ -33,6 +35,7 @@ from activation_codes.permissions import IsActivatedUser from chat import models, serializers from chat.clients.pydantic_ai import AIAgentService +from chat.docs_client import DocsClient from chat.keepalive import stream_with_keepalive_async, stream_with_keepalive_sync from chat.serializers import ChatConversationRequestSerializer @@ -41,7 +44,7 @@ def conditional_refresh_oidc_token(func): """ - Conditionally apply refresh_oidc_access_token decorator. + Conditionally apply refresh_access_token decorator. The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning we can actually refresh something. Broader settings checks are done in settings.py. @@ -399,6 +402,74 @@ def post_score_message(self, request, pk): # pylint: disable=unused-argument return Response({"status": "OK"}, status=status.HTTP_200_OK) + @conditional_refresh_oidc_token + @decorators.action( + methods=["post"], detail=True, url_path="export-to-docs", url_name="export-to-docs" + ) + def export_to_docs(self, request, pk): # pylint: disable=unused-argument + """Export a single assistant message to a new Docs document. + + Finds the assistant message by ID within the conversation's message array, + extracts its markdown text content, and creates a document in Docs. + + Args: + request: The HTTP request containing message_id in the body. + pk: The primary key of the chat conversation. + + Returns: + 201 with doc_id and doc_url on success. + 400 if message_id is missing or the message is not found. + 503 if the Docs service is unreachable. + """ + conversation = self.get_object() + + serializer = serializers.ExportMessageToDocsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + message_id = serializer.validated_data["message_id"] + + # Find the assistant message by ID in the conversation's message array + message = next( + (m for m in conversation.messages if m.id == message_id and m.role == "assistant"), + None, + ) + if message is None: + raise ValidationError({"message_id": "Assistant message not found."}) + + # Content is already markdown — just join the text parts + content = "\n\n".join(part.text for part in message.parts if part.type == "text") + + if not content: + raise ValidationError({"message_id": "Message has no exportable text content."}) + + # Use the first non-empty line as title, stripped of leading '#' + first_line = next( + (line.lstrip("# ").strip() for line in content.splitlines() if line.strip()), + None, + ) + title = first_line or "Exported from Conversations" + + docs_client = DocsClient() + try: + doc_data = docs_client.create_document( + title=title, + content=content, + session=request.session, # for OIDC token + ) + except http_requests.exceptions.RequestException as exc: + logger.exception("Docs service error during export: %s", exc) + return Response( + {"detail": "Docs service is currently unavailable. Please try again later."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response( + { + "docId": doc_data["id"], + "docUrl": urljoin(settings.DOCS_BASE_URL, f"{doc_data['id']}"), + }, + status=status.HTTP_201_CREATED, + ) + class LLMConfigurationView(APIView): """View for listing available LLM models.""" diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index a8ed73da..93924b47 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -939,6 +939,11 @@ class Base(BraveSettings, Configuration): environ_name="FIND_API_TIMEOUT", environ_prefix=None, ) + # Docs + DOCS_BASE_URL = values.Value(None, environ_name="DOCS_BASE_URL", environ_prefix=None) + DOCS_API_TIMEOUT = values.PositiveIntegerValue( + default=30, environ_name="DOCS_API_TIMEOUT", environ_prefix=None + ) # Logging # We want to make it easy to log to console but by default we log production @@ -1170,6 +1175,13 @@ def post_setup(cls): "OIDC_STORE_ACCESS_TOKEN and OIDC_STORE_REFRESH_TOKEN to be set." ) + # Docs configuration + if cls.DOCS_BASE_URL and not cls.OIDC_STORE_ACCESS_TOKEN: + raise ValueError( + "DOCS_BASE_URL is set but OIDC_STORE_ACCESS_TOKEN is not enabled. " + "The Docs integration requires OIDC access tokens to be stored in the session." + ) + # OCR configuration validation # Note: we call load_llm_configuration directly because LLM_CONFIGURATIONS is a # @property returning a lazy object, which cannot be accessed via cls in a classmethod. diff --git a/src/backend/conversations/tests/test_settings_docs.py b/src/backend/conversations/tests/test_settings_docs.py new file mode 100644 index 00000000..d647c5d0 --- /dev/null +++ b/src/backend/conversations/tests/test_settings_docs.py @@ -0,0 +1,60 @@ +"""Tests for the Docs integration configuration validation in post_setup.""" + +import pytest + +from conversations.settings import Base + + +def test_docs_base_url_without_oidc_store_access_token_raises(): + """DOCS_BASE_URL set without OIDC_STORE_ACCESS_TOKEN enabled must raise ValueError.""" + + class TestSettings(Base): + """Fake test settings with Docs enabled but OIDC token storage off.""" + + DOCS_BASE_URL = "http://docs.example.com/" + OIDC_STORE_ACCESS_TOKEN = False + + with pytest.raises(ValueError) as excinfo: + TestSettings().post_setup() + + assert "DOCS_BASE_URL" in str(excinfo.value) + assert "OIDC_STORE_ACCESS_TOKEN" in str(excinfo.value) + + +def test_docs_base_url_with_oidc_store_access_token_does_not_raise(): + """DOCS_BASE_URL set with OIDC_STORE_ACCESS_TOKEN enabled must not raise.""" + + class TestSettings(Base): + """Fake test settings with Docs enabled and OIDC token storage on.""" + + DOCS_BASE_URL = "http://docs.example.com/" + OIDC_STORE_ACCESS_TOKEN = True + + # Should not raise + TestSettings().post_setup() + + +def test_docs_base_url_none_does_not_raise(): + """DOCS_BASE_URL=None (default) must never raise regardless of OIDC settings.""" + + class TestSettings(Base): + """Fake test settings with Docs disabled.""" + + DOCS_BASE_URL = None + OIDC_STORE_ACCESS_TOKEN = False + + # Should not raise + TestSettings().post_setup() + + +def test_docs_base_url_empty_string_does_not_raise(): + """An empty DOCS_BASE_URL is falsy and must not trigger the validation error.""" + + class TestSettings(Base): + """Fake test settings with empty Docs URL.""" + + DOCS_BASE_URL = "" + OIDC_STORE_ACCESS_TOKEN = False + + # Should not raise — empty string is falsy, same as None + TestSettings().post_setup() diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d4b8f99..1ec51ef2 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -225,6 +225,9 @@ def get(self, request): dict_settings["chat_upload_accept"] = ",".join(settings.RAG_FILES_ACCEPTED_FORMATS) + # Expose Docs integration URL so the frontend can show/hide the "Edit in Docs" button + dict_settings["DOCS_BASE_URL"] = settings.DOCS_BASE_URL + return drf.response.Response(dict_settings) def _load_theme_customization(self): diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 0571e513..bc020fe5 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -45,6 +45,7 @@ def test_api_config(is_authenticated): assert response.json() == { "ACTIVATION_REQUIRED": False, "CRISP_WEBSITE_ID": "123", + "DOCS_BASE_URL": None, "ENVIRONMENT": "test", "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, "FILE_UPLOAD_MODE": "presigned_url", @@ -67,6 +68,24 @@ def test_api_config(is_authenticated): } +@override_settings(DOCS_BASE_URL="http://docs.example.com/") +def test_api_config_exposes_docs_base_url(): + """DOCS_BASE_URL must be present in the config response when configured.""" + client = APIClient() + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + assert response.json()["DOCS_BASE_URL"] == "http://docs.example.com/" + + +@override_settings(DOCS_BASE_URL=None) +def test_api_config_docs_base_url_none_when_not_configured(): + """DOCS_BASE_URL must be null in the config response when not configured.""" + client = APIClient() + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + assert response.json()["DOCS_BASE_URL"] is None + + @override_settings( THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json", ) @@ -188,6 +207,7 @@ async def test_api_config_async(is_authenticated): assert response.json() == { "ACTIVATION_REQUIRED": False, "CRISP_WEBSITE_ID": "123", + "DOCS_BASE_URL": None, "ENVIRONMENT": "test", "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, "FILE_UPLOAD_MODE": "presigned_url", diff --git a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx index 5e2c8015..74988b03 100644 --- a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx @@ -37,6 +37,7 @@ export interface ConfigResponse { FILE_UPLOAD_MODE?: string; theme_customization?: ThemeCustomization; chat_upload_accept?: string; + DOCS_BASE_URL?: string; } const LOCAL_STORAGE_KEY = 'conversations_config'; diff --git a/src/frontend/apps/conversations/src/features/chat/api/useEditInDocs.tsx b/src/frontend/apps/conversations/src/features/chat/api/useEditInDocs.tsx new file mode 100644 index 00000000..4d4fdbaf --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/api/useEditInDocs.tsx @@ -0,0 +1,32 @@ +import { APIError, errorCauses, fetchAPI } from '@/api'; + +interface EditInDocsParams { + conversationId: string; + message_id: string; +} + +export interface EditInDocsResponse { + docId: string; + docUrl: string; +} + +export const editInDocs = async ({ + conversationId, + message_id, +}: EditInDocsParams): Promise => { + const response = await fetchAPI(`chats/${conversationId}/export-to-docs/`, { + method: 'POST', + body: JSON.stringify({ + message_id, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to export message to Docs', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; diff --git a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx index c51ba27d..3ff571cc 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx @@ -2,9 +2,11 @@ import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils'; import { Button } from '@gouvfr-lasuite/cunningham-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; - +import { useConfig } from '@/core/config'; +import { MoreActionsButton } from '@/features/chat/components/MoreActionsButton'; import { Box, Icon, Loader, Text, useToast } from '@/components'; import { AttachmentList } from '@/features/chat/components/AttachmentList'; + import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons'; import { CompletedMarkdownBlock, @@ -188,6 +190,8 @@ const MessageItemComponent: React.FC = ({ }) => { const { t } = useTranslation(); const { showToast } = useToast(); + const { data: config } = useConfig(); + const docsBaseUrl = config?.DOCS_BASE_URL; const contentRef = React.useRef(null); const shouldApplyStreamingHeight = @@ -399,6 +403,12 @@ const MessageItemComponent: React.FC = ({ icon={} className="c__button--neutral action-chat-button" > + {docsBaseUrl && conversationId && message.id && message.content && ( + + )} {sourceParts.length > 0 && (