From 270d87b0a488d2a0202a77b3571693ae62eceb84 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:55:27 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5(project)=20remove=20previous=20AI?= =?UTF-8?q?=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We replace the previous AI feature with a new one that uses the BlockNote AI service. We can remove the dead codes. --- src/backend/core/api/serializers.py | 31 -- src/backend/core/api/viewsets.py | 57 --- src/backend/core/services/ai_services.py | 75 ---- .../test_api_documents_ai_transform.py | 362 ----------------- .../test_api_documents_ai_translate.py | 384 ------------------ .../documents/test_api_documents_retrieve.py | 8 - .../documents/test_api_documents_trashbin.py | 2 - .../core/tests/test_models_documents.py | 20 - .../core/tests/test_services_ai_services.py | 57 +-- .../__tests__/app-impress/doc-editor.spec.ts | 134 ------ .../src/features/docs/doc-editor/api/index.ts | 2 - .../docs/doc-editor/api/useDocAITransform.tsx | 48 --- .../docs/doc-editor/api/useDocAITranslate.tsx | 40 -- .../components/BlockNoteToolBar/AIButton.tsx | 371 ----------------- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 6 +- .../features/docs/doc-management/types.tsx | 2 - .../service-worker/plugins/ApiPlugin.ts | 2 - .../src/api/collaborationBackend.ts | 2 - 18 files changed, 33 insertions(+), 1570 deletions(-) delete mode 100644 src/backend/core/tests/documents/test_api_documents_ai_transform.py delete mode 100644 src/backend/core/tests/documents/test_api_documents_ai_translate.py delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index dbbf11e2..0f78faf5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -17,7 +17,6 @@ from rest_framework import serializers from core import choices, enums, models, utils, validators from core.services import mime_types -from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, Converter, @@ -792,36 +791,6 @@ class VersionFilterSerializer(serializers.Serializer): ) -class AITransformSerializer(serializers.Serializer): - """Serializer for AI transform requests.""" - - action = serializers.ChoiceField(choices=AI_ACTIONS, required=True) - text = serializers.CharField(required=True) - - def validate_text(self, value): - """Ensure the text field is not empty.""" - - if len(value.strip()) == 0: - raise serializers.ValidationError("Text field cannot be empty.") - return value - - -class AITranslateSerializer(serializers.Serializer): - """Serializer for AI translate requests.""" - - language = serializers.ChoiceField( - choices=tuple(enums.ALL_LANGUAGES.items()), required=True - ) - text = serializers.CharField(required=True) - - def validate_text(self, value): - """Ensure the text field is not empty.""" - - if len(value.strip()) == 0: - raise serializers.ValidationError("Text field cannot be empty.") - return value - - class AIProxySerializer(serializers.Serializer): """Serializer for AI proxy requests.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 85875ceb..720d4b88 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -378,7 +378,6 @@ class DocumentViewSet( throttle_scope = "document" queryset = models.Document.objects.select_related("creator").all() serializer_class = serializers.DocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer all_serializer_class = serializers.ListDocumentSerializer children_serializer_class = serializers.ListDocumentSerializer descendants_serializer_class = serializers.ListDocumentSerializer @@ -1654,62 +1653,6 @@ class DocumentViewSet( response = AIService().proxy(request.data) return drf.response.Response(response, status=drf.status.HTTP_200_OK) - @drf.decorators.action( - detail=True, - methods=["post"], - name="Apply a transformation action on a piece of text with AI", - url_path="ai-transform", - throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], - ) - def ai_transform(self, request, *args, **kwargs): - """ - POST /api/v1.0/documents//ai-transform - with expected data: - - text: str - - action: str [prompt, correct, rephrase, summarize] - Return JSON response with the processed text. - """ - # Check permissions first - self.get_object() - - serializer = serializers.AITransformSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - text = serializer.validated_data["text"] - action = serializer.validated_data["action"] - - response = AIService().transform(text, action) - - return drf.response.Response(response, status=drf.status.HTTP_200_OK) - - @drf.decorators.action( - detail=True, - methods=["post"], - name="Translate a piece of text with AI", - url_path="ai-translate", - throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], - ) - def ai_translate(self, request, *args, **kwargs): - """ - POST /api/v1.0/documents//ai-translate - with expected data: - - text: str - - language: str [settings.LANGUAGES] - Return JSON response with the translated text. - """ - # Check permissions first - self.get_object() - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - text = serializer.validated_data["text"] - language = serializer.validated_data["language"] - - response = AIService().translate(text, language) - - return drf.response.Response(response, status=drf.status.HTTP_200_OK) - def _reject_invalid_ips(self, ips): """ Check if an IP address is safe from SSRF attacks. diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index 68e20b9e..4f78249b 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -5,8 +5,6 @@ import logging from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from core import enums - if settings.LANGFUSE_PUBLIC_KEY: from langfuse.openai import OpenAI else: @@ -15,51 +13,6 @@ else: log = logging.getLogger(__name__) -AI_ACTIONS = { - "prompt": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "correct": ( - "Correct grammar and spelling of the markdown text, " - "preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "rephrase": ( - "Rephrase the given markdown text, " - "preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "summarize": ( - "Summarize the markdown text, preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "beautify": ( - "Add formatting to the text to make it more readable. " - "Do not provide any other information. " - "Preserve the language." - ), - "emojify": ( - "Add emojis to the important parts of the text. " - "Do not provide any other information. " - "Preserve the language." - ), -} - -AI_TRANSLATE = ( - "Keep the same html structure and formatting. " - "Translate the content in the html to the specified language {language:s}. " - "Check the translation for accuracy and make any necessary corrections. " - "Do not provide any other information." -) - - class AIService: """Service class for AI-related operations.""" @@ -73,34 +26,6 @@ class AIService: raise ImproperlyConfigured("AI configuration not set") self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY) - def call_ai_api(self, system_content, text): - """Helper method to call the OpenAI API and process the response.""" - response = self.client.chat.completions.create( - model=settings.AI_MODEL, - messages=[ - {"role": "system", "content": system_content}, - {"role": "user", "content": text}, - ], - ) - - content = response.choices[0].message.content - - if not content: - raise RuntimeError("AI response does not contain an answer") - - return {"answer": content} - - def transform(self, text, action): - """Transform text based on specified action.""" - system_content = AI_ACTIONS[action] - return self.call_ai_api(system_content, text) - - def translate(self, text, language): - """Translate text to a specified language.""" - language_display = enums.ALL_LANGUAGES.get(language, language) - system_content = AI_TRANSLATE.format(language=language_display) - return self.call_ai_api(system_content, text) - def proxy(self, data: dict) -> dict: """Proxy AI API requests to the configured AI provider.""" data["stream"] = False diff --git a/src/backend/core/tests/documents/test_api_documents_ai_transform.py b/src/backend/core/tests/documents/test_api_documents_ai_transform.py deleted file mode 100644 index d047839c..00000000 --- a/src/backend/core/tests/documents/test_api_documents_ai_transform.py +++ /dev/null @@ -1,362 +0,0 @@ -""" -Test AI transform API endpoint for users in impress's core app. -""" - -import random -from unittest.mock import MagicMock, patch - -from django.test import override_settings - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.tests.conftest import TEAM, USER, VIA - -pytestmark = pytest.mark.django_db - - -@pytest.fixture -def ai_settings(): - """Fixture to set AI settings.""" - with override_settings( - AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama" - ): - yield - - -@override_settings( - AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) -) -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("authenticated", "editor"), - ("public", "reader"), - ], -) -def test_api_documents_ai_transform_anonymous_forbidden(reach, role): - """ - Anonymous users should not be able to request AI transform if the link reach - and role don't allow it. - """ - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "hello", "action": "prompt"}) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -@override_settings(AI_ALLOW_REACH_FROM="public") -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_anonymous_success(mock_create): - """ - Anonymous users should be able to request AI transform to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Summarize the markdown text, preserving language and markdown formatting. " - "Do not provide any other information. Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create): - """ - Anonymous users should be able to request AI transform to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - answer = '{"answer": "Salut"}' - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content=answer))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 401 - - -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("public", "reader"), - ], -) -def test_api_documents_ai_transform_authenticated_forbidden(reach, role): - """ - Users who are not related to a document can't request AI transform if the - link reach and role don't allow it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize( - "reach, role", - [ - ("authenticated", "editor"), - ("public", "editor"), - ], -) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role): - """ - Authenticated who are not related to a document should be able to request AI transform - if the link reach and role permit it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@pytest.mark.parametrize("via", VIA) -def test_api_documents_ai_transform_reader(via, mock_user_teams): - """ - Users who are simple readers on a document should not be allowed to request AI transform. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_role="reader") - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role="reader") - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role="reader" - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) -@pytest.mark.parametrize("via", VIA) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams): - """ - Editors, administrators and owners of a document should be able to request AI transform. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory() - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role=role) - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role=role - ) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -def test_api_documents_ai_transform_empty_text(): - """The text should not be empty when requesting AI transform.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": " ", "action": "prompt"}) - - assert response.status_code == 400 - assert response.json() == {"text": ["This field may not be blank."]} - - -def test_api_documents_ai_transform_invalid_action(): - """The action should valid when requesting AI transform.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "invalid"}) - - assert response.status_code == 400 - assert response.json() == {"action": ['"invalid" is not a valid choice.']} - - -@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_throttling_document(mock_create): - """ - Throttling per document should be triggered on the AI transform endpoint. - For full throttle class test see: `test_api_utils_ai_document_rate_throttles` - """ - client = APIClient() - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - for _ in range(3): - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "action": "summarize"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } - - -@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_throttling_user(mock_create): - """ - Throttling per user should be triggered on the AI transform endpoint. - For full throttle class test see: `test_api_utils_ai_user_rate_throttles` - """ - user = factories.UserFactory() - client = APIClient() - client.force_login(user) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - for _ in range(3): - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "summarize"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } diff --git a/src/backend/core/tests/documents/test_api_documents_ai_translate.py b/src/backend/core/tests/documents/test_api_documents_ai_translate.py deleted file mode 100644 index f0d7978c..00000000 --- a/src/backend/core/tests/documents/test_api_documents_ai_translate.py +++ /dev/null @@ -1,384 +0,0 @@ -""" -Test AI translate API endpoint for users in impress's core app. -""" - -import random -from unittest.mock import MagicMock, patch - -from django.test import override_settings - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.tests.conftest import TEAM, USER, VIA - -pytestmark = pytest.mark.django_db - - -@pytest.fixture -def ai_settings(): - """Fixture to set AI settings.""" - with override_settings( - AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama" - ): - yield - - -def test_api_documents_ai_translate_viewset_options_metadata(): - """The documents endpoint should give us the list of available languages.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - factories.DocumentFactory(link_reach="public", link_role="editor") - - response = APIClient().options("/api/v1.0/documents/") - - assert response.status_code == 200 - metadata = response.json() - assert metadata["name"] == "Document List" - assert metadata["actions"]["POST"]["language"]["choices"][0] == { - "value": "af", - "display_name": "Afrikaans", - } - - -@override_settings( - AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) -) -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("authenticated", "editor"), - ("public", "reader"), - ], -) -def test_api_documents_ai_translate_anonymous_forbidden(reach, role): - """ - Anonymous users should not be able to request AI translate if the link reach - and role don't allow it. - """ - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = APIClient().post(url, {"text": "hello", "language": "es"}) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -@override_settings(AI_ALLOW_REACH_FROM="public") -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_anonymous_success(mock_create): - """ - Anonymous users should be able to request AI translate to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Ola"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = APIClient().post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Ola"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Keep the same html structure and formatting. " - "Translate the content in the html to the specified language Spanish. " - "Check the translation for accuracy and make any necessary corrections. " - "Do not provide any other information." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create): - """ - Anonymous users should be able to request AI translate to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - answer = '{"answer": "Salut"}' - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content=answer))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = APIClient().post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 401 - - -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("public", "reader"), - ], -) -def test_api_documents_ai_translate_authenticated_forbidden(reach, role): - """ - Users who are not related to a document can't request AI translate if the - link reach and role don't allow it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize( - "reach, role", - [ - ("authenticated", "editor"), - ("public", "editor"), - ], -) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role): - """ - Authenticated who are not related to a document should be able to request AI translate - if the link reach and role permit it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es-co"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Keep the same html structure and formatting. " - "Translate the content in the html to the " - "specified language Colombian Spanish. " - "Check the translation for accuracy and make any necessary corrections. " - "Do not provide any other information." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@pytest.mark.parametrize("via", VIA) -def test_api_documents_ai_translate_reader(via, mock_user_teams): - """ - Users who are simple readers on a document should not be allowed to request AI translate. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_role="reader") - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role="reader") - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role="reader" - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) -@pytest.mark.parametrize("via", VIA) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams): - """ - Editors, administrators and owners of a document should be able to request AI translate. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory() - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role=role) - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role=role - ) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es-co"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Keep the same html structure and formatting. " - "Translate the content in the html to the " - "specified language Colombian Spanish. " - "Check the translation for accuracy and make any necessary corrections. " - "Do not provide any other information." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -def test_api_documents_ai_translate_empty_text(): - """The text should not be empty when requesting AI translate.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": " ", "language": "es"}) - - assert response.status_code == 400 - assert response.json() == {"text": ["This field may not be blank."]} - - -def test_api_documents_ai_translate_invalid_action(): - """The action should valid when requesting AI translate.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "invalid"}) - - assert response.status_code == 400 - assert response.json() == {"language": ['"invalid" is not a valid choice.']} - - -@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_throttling_document(mock_create): - """ - Throttling per document should be triggered on the AI translate endpoint. - For full throttle class test see: `test_api_utils_ai_document_rate_throttles` - """ - client = APIClient() - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - for _ in range(3): - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "language": "es"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } - - -@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_translate_throttling_user(mock_create): - """ - Throttling per user should be triggered on the AI translate endpoint. - For full throttle class test see: `test_api_utils_ai_user_rate_throttles` - """ - user = factories.UserFactory() - client = APIClient() - client.force_login(user) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - for _ in range(3): - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 4f6e38a3..97c5471c 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -30,8 +30,6 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "accesses_manage": False, "accesses_view": False, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": document.link_role == "editor", "can_edit": document.link_role == "editor", "children_create": False, @@ -109,8 +107,6 @@ def test_api_documents_retrieve_anonymous_public_parent(): "accesses_manage": False, "accesses_view": False, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", "can_edit": grand_parent.link_role == "editor", "children_create": False, @@ -218,8 +214,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "accesses_manage": False, "accesses_view": False, "ai_proxy": document.link_role == "editor", - "ai_transform": document.link_role == "editor", - "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", "can_edit": document.link_role == "editor", "children_create": document.link_role == "editor", @@ -304,8 +298,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "accesses_manage": False, "accesses_view": False, "ai_proxy": grand_parent.link_role == "editor", - "ai_transform": grand_parent.link_role == "editor", - "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", "can_edit": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 7cb9ac53..a7604099 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -73,8 +73,6 @@ def test_api_documents_trashbin_format(): "accesses_manage": False, "accesses_view": False, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": False, "can_edit": False, "children_create": False, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 5c70ddc8..01352e2e 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -156,8 +156,6 @@ def test_models_documents_get_abilities_forbidden( "accesses_manage": False, "accesses_view": False, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": False, "can_edit": False, "children_create": False, @@ -222,8 +220,6 @@ def test_models_documents_get_abilities_reader( "accesses_manage": False, "accesses_view": False, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": False, "can_edit": False, "children_create": False, @@ -360,8 +356,6 @@ def test_models_documents_get_abilities_editor( "accesses_manage": False, "accesses_view": False, "ai_proxy": is_authenticated, - "ai_transform": is_authenticated, - "ai_translate": is_authenticated, "attachment_upload": True, "can_edit": True, "children_create": is_authenticated, @@ -417,8 +411,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "accesses_manage": True, "accesses_view": True, "ai_proxy": True, - "ai_transform": True, - "ai_translate": True, "attachment_upload": True, "can_edit": True, "children_create": True, @@ -506,8 +498,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "accesses_manage": True, "accesses_view": True, "ai_proxy": True, - "ai_transform": True, - "ai_translate": True, "attachment_upload": True, "can_edit": True, "children_create": True, @@ -563,8 +553,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "accesses_manage": False, "accesses_view": True, "ai_proxy": True, - "ai_transform": True, - "ai_translate": True, "attachment_upload": True, "can_edit": True, "children_create": True, @@ -627,8 +615,6 @@ def test_models_documents_get_abilities_reader_user( # If you get your editor rights from the link role and not your access role # You should not access AI if it's restricted to users with specific access "ai_proxy": access_from_link and ai_access_setting != "restricted", - "ai_transform": access_from_link and ai_access_setting != "restricted", - "ai_translate": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, "can_edit": access_from_link, "children_create": access_from_link, @@ -755,8 +741,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "accesses_manage": False, "accesses_view": True, "ai_proxy": False, - "ai_transform": False, - "ai_translate": False, "attachment_upload": False, "can_edit": False, "children_create": False, @@ -887,8 +871,6 @@ def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, abilities = document.get_abilities(user) assert abilities["ai_proxy"] is True - assert abilities["ai_transform"] is True - assert abilities["ai_translate"] is True @override_settings(AI_ALLOW_REACH_FROM="authenticated") @@ -907,8 +889,6 @@ def test_models_document_get_abilities_ai_access_public(is_authenticated, reach) abilities = document.get_abilities(user) assert abilities["ai_proxy"] == is_authenticated - assert abilities["ai_transform"] == is_authenticated - assert abilities["ai_translate"] == is_authenticated def test_models_documents_get_versions_slice_pagination(settings): diff --git a/src/backend/core/tests/test_services_ai_services.py b/src/backend/core/tests/test_services_ai_services.py index ffa5c170..f6fa3703 100644 --- a/src/backend/core/tests/test_services_ai_services.py +++ b/src/backend/core/tests/test_services_ai_services.py @@ -43,29 +43,11 @@ def test_api_ai__client_error(mock_create): mock_create.side_effect = OpenAIError("Mocked client error") - with pytest.raises( - OpenAIError, - match="Mocked client error", - ): - AIService().transform("hello", "prompt") - - -@override_settings( - AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model" -) -@patch("openai.resources.chat.completions.Completions.create") -def test_api_ai__client_invalid_response(mock_create): - """Fail when the client response is invalid""" - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content=None))] - ) - with pytest.raises( RuntimeError, - match="AI response does not contain an answer", + match="Failed to proxy AI request: Mocked client error", ): - AIService().transform("hello", "prompt") + AIService().proxy({"messages": [{"role": "user", "content": "hello"}]}) @override_settings( @@ -75,10 +57,35 @@ def test_api_ai__client_invalid_response(mock_create): def test_api_ai__success(mock_create): """The AI request should work as expect when called with valid arguments.""" - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Salut"}, + "finish_reason": "stop", + } + ], + } + mock_create.return_value = mock_response - response = AIService().transform("hello", "prompt") + response = AIService().proxy({"messages": [{"role": "user", "content": "hello"}]}) - assert response == {"answer": "Salut"} + expected_response = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Salut"}, + "finish_reason": "stop", + } + ], + } + assert response == expected_response diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 911e67db..a67e3143 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable playwright/no-conditional-expect */ import path from 'path'; import { expect, test } from '@playwright/test'; @@ -496,139 +495,6 @@ test.describe('Doc Editor', () => { await expect(page.getByText('Write with AI')).toBeVisible(); }); - test('it checks the AI buttons', async ({ page, browserName }) => { - await page.route(/.*\/ai-translate\//, async (route) => { - const request = route.request(); - if (request.method().includes('POST')) { - await route.fulfill({ - json: { - answer: 'Bonjour le monde', - }, - }); - } else { - await route.continue(); - } - }); - - await createDoc(page, 'doc-ai', browserName, 1); - - await page.locator('.bn-block-outer').last().fill('Hello World'); - - const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); - - await page.getByRole('button', { name: 'AI' }).click(); - - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Rephrase' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Summarize' }), - ).toBeVisible(); - await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'Language' }).hover(); - await expect( - page.getByRole('menuitem', { name: 'English', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'French', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'German', exact: true }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'English', exact: true }).click(); - - await expect(editor.getByText('Bonjour le monde')).toBeVisible(); - }); - - [ - { ai_transform: false, ai_translate: false }, - { ai_transform: true, ai_translate: false }, - { ai_transform: false, ai_translate: true }, - ].forEach(({ ai_transform, ai_translate }) => { - test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({ - page, - browserName, - }) => { - await mockedDocument(page, { - accesses: [ - { - id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', - role: 'owner', - user: { - email: 'super@owner.com', - full_name: 'Super Owner', - }, - }, - ], - abilities: { - destroy: true, // Means owner - link_configuration: true, - ai_transform, - ai_translate, - accesses_manage: true, - accesses_view: true, - update: true, - partial_update: true, - retrieve: true, - }, - link_reach: 'restricted', - link_role: 'editor', - created_at: '2021-09-01T09:00:00Z', - title: '', - }); - - const [randomDoc] = await createDoc( - page, - 'doc-editor-ai', - browserName, - 1, - ); - - await verifyDocName(page, randomDoc); - - await page.locator('.bn-block-outer').last().fill('Hello World'); - - const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); - - if (!ai_transform && !ai_translate) { - await expect(page.getByRole('button', { name: 'AI' })).toBeHidden(); - return; - } - - await page.getByRole('button', { name: 'AI' }).click(); - - if (ai_transform) { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - } else { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeHidden(); - } - - if (ai_translate) { - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeVisible(); - } else { - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeHidden(); - } - }); - }); - test('it downloads unsafe files', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts index 040f6c7c..5157ad45 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/api/index.ts @@ -1,4 +1,2 @@ export * from './checkDocMediaStatus'; export * from './useCreateDocUpload'; -export * from './useDocAITransform'; -export * from './useDocAITranslate'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx deleted file mode 100644 index cd8dfbfc..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITransform.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -export type AITransformActions = - | 'correct' - | 'prompt' - | 'rephrase' - | 'summarize' - | 'beautify' - | 'emojify'; - -export type DocAITransform = { - docId: string; - text: string; - action: AITransformActions; -}; - -export type DocAITransformResponse = { - answer: string; -}; - -export const docAITransform = async ({ - docId, - ...params -}: DocAITransform): Promise => { - const response = await fetchAPI(`documents/${docId}/ai-transform/`, { - method: 'POST', - body: JSON.stringify({ - ...params, - }), - }); - - if (!response.ok) { - throw new APIError( - 'Failed to request ai transform', - await errorCauses(response), - ); - } - - return response.json() as Promise; -}; - -export function useDocAITransform() { - return useMutation({ - mutationFn: docAITransform, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx deleted file mode 100644 index 504d79b3..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -export type DocAITranslate = { - docId: string; - text: string; - language: string; -}; - -export type DocAITranslateResponse = { - answer: string; -}; - -export const docAITranslate = async ({ - docId, - ...params -}: DocAITranslate): Promise => { - const response = await fetchAPI(`documents/${docId}/ai-translate/`, { - method: 'POST', - body: JSON.stringify({ - ...params, - }), - }); - - if (!response.ok) { - throw new APIError( - 'Failed to request ai translate', - await errorCauses(response), - ); - } - - return response.json() as Promise; -}; - -export function useDocAITranslate() { - return useMutation({ - mutationFn: docAITranslate, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx deleted file mode 100644 index f5cbd625..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/AIButton.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { Block } from '@blocknote/core'; -import { - ComponentProps, - useBlockNoteEditor, - useComponentsContext, - useSelectedBlocks, -} from '@blocknote/react'; -import { - Loader, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { PropsWithChildren, ReactNode, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { isAPIError } from '@/api'; -import { Box, Icon } from '@/components'; -import { useDocOptions, useDocStore } from '@/docs/doc-management/'; - -import { - AITransformActions, - useDocAITransform, - useDocAITranslate, -} from '../../api'; - -type LanguageTranslate = { - value: string; - display_name: string; -}; - -const sortByPopularLanguages = ( - languages: LanguageTranslate[], - popularLanguages: string[], -) => { - languages.sort((a, b) => { - const indexA = popularLanguages.indexOf(a.value); - const indexB = popularLanguages.indexOf(b.value); - - // If both languages are in the popular list, sort based on their order in popularLanguages - if (indexA !== -1 && indexB !== -1) { - return indexA - indexB; - } - - // If only a is in the popular list, it should come first - if (indexA !== -1) { - return -1; - } - - // If only b is in the popular list, it should come first - if (indexB !== -1) { - return 1; - } - - // If neither a nor b is in the popular list, maintain their relative order - return 0; - }); -}; - -export function AIGroupButton() { - const editor = useBlockNoteEditor(); - const Components = useComponentsContext(); - const selectedBlocks = useSelectedBlocks(editor); - const { t } = useTranslation(); - const { currentDoc } = useDocStore(); - const { data: docOptions } = useDocOptions(); - - const languages = useMemo(() => { - const languages = docOptions?.actions.POST.language.choices; - - if (!languages) { - return; - } - - sortByPopularLanguages(languages, [ - 'fr', - 'en', - 'de', - 'es', - 'it', - 'pt', - 'nl', - 'pl', - ]); - - return languages; - }, [docOptions?.actions.POST.language.choices]); - - const show = useMemo(() => { - return !!selectedBlocks.find((block) => block.content !== undefined); - }, [selectedBlocks]); - - if (!show || !editor.isEditable || !Components || !currentDoc || !languages) { - return null; - } - - const canAITransform = currentDoc.abilities.ai_transform; - const canAITranslate = currentDoc.abilities.ai_translate; - - if (!canAITransform && !canAITranslate) { - return null; - } - - return ( - - - } - /> - - - {canAITransform && ( - <> - } - > - {t('Use as prompt')} - - } - > - {t('Rephrase')} - - } - > - {t('Summarize')} - - } - > - {t('Correct')} - - } - > - {t('Beautify')} - - } - > - {t('Emojify')} - - - )} - {canAITranslate && ( - - - - - - {t('Language')} - - - - - {languages.map((language) => ( - - {language.display_name} - - ))} - - - )} - - - ); -} - -/** - * Item is derived from Mantime, some props seem lacking or incorrect. - */ -type ItemDefault = ComponentProps['Generic']['Menu']['Item']; -type ItemProps = Omit & { - rightSection?: ReactNode; - closeMenuOnClick?: boolean; - onClick: (e: React.MouseEvent) => void; -}; - -interface AIMenuItemTransformProps { - action: AITransformActions; - docId: string; - icon?: ReactNode; -} - -const AIMenuItemTransform = ({ - docId, - action, - children, - icon, -}: PropsWithChildren) => { - const { mutateAsync: requestAI, isPending } = useDocAITransform(); - const editor = useBlockNoteEditor(); - - const requestAIAction = async (selectedBlocks: Block[]) => { - const text = await editor.blocksToMarkdownLossy(selectedBlocks); - - const responseAI = await requestAI({ - text, - action, - docId, - }); - - if (!responseAI?.answer) { - throw new Error('No response from AI'); - } - - const markdown = await editor.tryParseMarkdownToBlocks(responseAI.answer); - editor.replaceBlocks(selectedBlocks, markdown); - }; - - return ( - - {children} - - ); -}; - -interface AIMenuItemTranslateProps { - language: string; - docId: string; - icon?: ReactNode; -} - -const AIMenuItemTranslate = ({ - children, - docId, - icon, - language, -}: PropsWithChildren) => { - const { mutateAsync: requestAI, isPending } = useDocAITranslate(); - const editor = useBlockNoteEditor(); - - const requestAITranslate = async (selectedBlocks: Block[]) => { - let fullHtml = ''; - for (const block of selectedBlocks) { - if (Array.isArray(block.content) && block.content.length === 0) { - fullHtml += '


'; - continue; - } - - fullHtml += await editor.blocksToHTMLLossy([block]); - } - - const responseAI = await requestAI({ - text: fullHtml, - language, - docId, - }); - - if (!responseAI || !responseAI.answer) { - throw new Error('No response from AI'); - } - - try { - const blocks = await editor.tryParseHTMLToBlocks(responseAI.answer); - editor.replaceBlocks(selectedBlocks, blocks); - } catch { - editor.replaceBlocks(selectedBlocks, selectedBlocks); - } - }; - - return ( - - {children} - - ); -}; - -interface AIMenuItemProps { - requestAI: (blocks: Block[]) => Promise; - isPending: boolean; - icon?: ReactNode; -} - -const AIMenuItem = ({ - requestAI, - isPending, - children, - icon, -}: PropsWithChildren) => { - const Components = useComponentsContext(); - const { toast } = useToastProvider(); - const { t } = useTranslation(); - - const editor = useBlockNoteEditor(); - const handleAIError = useHandleAIError(); - - const handleAIAction = async () => { - const selectedBlocks = editor.getSelection()?.blocks ?? [ - editor.getTextCursorPosition().block, - ]; - - if (!selectedBlocks?.length) { - toast(t('No text selected'), VariantType.WARNING); - return; - } - - try { - await requestAI(selectedBlocks); - } catch (error) { - handleAIError(error); - } - }; - - if (!Components) { - return null; - } - - const Item = Components.Generic.Menu.Item as React.FC; - - return ( - { - e.stopPropagation(); - void handleAIAction(); - }} - rightSection={isPending ? : undefined} - > - {children} - - ); -}; - -const useHandleAIError = () => { - const { toast } = useToastProvider(); - const { t } = useTranslation(); - - return (error: unknown) => { - if (isAPIError(error) && error.status === 429) { - toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR); - return; - } - - toast(t('AI seems busy! Please try again.'), VariantType.ERROR); - }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index d3a89a44..7fc4cf2c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -10,11 +10,10 @@ import { useTranslation } from 'react-i18next'; import { useConfig } from '@/core/config/api'; -import { AIToolbarButton } from '../AI/AIToolbarButton'; +import { AIToolbarButton } from '../AI'; import { CommentToolbarButton } from '../comments/CommentToolbarButton'; import { getCalloutFormattingToolbarItems } from '../custom-blocks'; -import { AIGroupButton } from './AIButton'; import { FileDownloadButton } from './FileDownloadButton'; import { MarkdownButton } from './MarkdownButton'; import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe'; @@ -76,9 +75,6 @@ export const BlockNoteToolbar = () => { {toolbarItems} - {/* Extra button to do some AI powered actions */} - {conf?.AI_FEATURE_ENABLED && } - {/* Extra button to convert from markdown to json */} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index 80a6ee64..07bc516c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -75,8 +75,6 @@ export interface Doc { accesses_manage: boolean; accesses_view: boolean; ai_proxy: boolean; - ai_transform: boolean; - ai_translate: boolean; attachment_upload: boolean; children_create: boolean; children_list: boolean; diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index ecb64dd1..6d4209b2 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -183,8 +183,6 @@ export class ApiPlugin implements WorkboxPlugin { accesses_manage: true, accesses_view: true, ai_proxy: true, - ai_transform: true, - ai_translate: true, attachment_upload: true, children_create: true, children_list: true, diff --git a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts b/src/frontend/servers/y-provider/src/api/collaborationBackend.ts index 4a32a78a..f9ff6358 100644 --- a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts +++ b/src/frontend/servers/y-provider/src/api/collaborationBackend.ts @@ -30,8 +30,6 @@ interface Doc { accesses_manage: boolean; accesses_view: boolean; ai_proxy: boolean; - ai_transform: boolean; - ai_translate: boolean; attachment_upload: boolean; children_create: boolean; children_list: boolean;