From e94efcf995698199544ca94909b9db60696017e7 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:06:30 +0200 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8(backend)=20add=20ai=5Fproxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI proxy to handle AI related requests to the AI service. --- src/backend/core/api/serializers.py | 35 + src/backend/core/api/viewsets.py | 42 +- src/backend/core/models.py | 3 +- src/backend/core/services/ai_services.py | 11 + .../documents/test_api_documents_ai_proxy.py | 686 ++++++++++++++++++ .../documents/test_api_documents_retrieve.py | 5 + .../documents/test_api_documents_trashbin.py | 1 + .../core/tests/test_models_documents.py | 10 + .../features/docs/doc-management/types.tsx | 1 + .../service-worker/plugins/ApiPlugin.ts | 1 + .../servers/y-provider/src/api/getDoc.ts | 1 + 11 files changed, 779 insertions(+), 17 deletions(-) create mode 100644 src/backend/core/tests/documents/test_api_documents_ai_proxy.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 8a0d3d9ef..7fa25282e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -748,6 +748,41 @@ def validate_text(self, value): return value +class AIProxySerializer(serializers.Serializer): + """Serializer for AI proxy requests.""" + + messages = serializers.ListField( + required=True, + child=serializers.DictField( + child=serializers.CharField(required=True), + ), + allow_empty=False, + ) + model = serializers.CharField(required=True) + + def validate_messages(self, messages): + """Validate messages structure.""" + # Ensure each message has the required fields + for message in messages: + if ( + not isinstance(message, dict) + or "role" not in message + or "content" not in message + ): + raise serializers.ValidationError( + "Each message must have 'role' and 'content' fields" + ) + + return messages + + def validate_model(self, value): + """Validate model value is the same than settings.AI_MODEL""" + if value != settings.AI_MODEL: + raise serializers.ValidationError(f"{value} is not a valid model") + + return value + + class MoveDocumentSerializer(serializers.Serializer): """ Serializer for validating input data to move a document within the tree structure. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 78fc02428..4bba622b5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -387,21 +387,8 @@ class DocumentViewSet( 9. **Media Auth**: Authorize access to document media. Example: GET /documents/media-auth/ - 10. **AI Transform**: Apply a transformation action on a piece of text with AI. - Example: POST /documents/{id}/ai-transform/ - Expected data: - - text (str): The input text. - - action (str): The transformation type, one of [prompt, correct, rephrase, summarize]. - Returns: JSON response with the processed text. - Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. - - 11. **AI Translate**: Translate a piece of text with AI. - Example: POST /documents/{id}/ai-translate/ - Expected data: - - text (str): The input text. - - language (str): The target language, chosen from settings.LANGUAGES. - Returns: JSON response with the translated text. - Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. + 10. **AI Proxy**: Proxy an AI request to an external AI service. + Example: POST /api/v1.0/documents//ai-proxy ### Ordering: created_at, updated_at, is_favorite, title @@ -1353,6 +1340,31 @@ def media_check(self, request, *args, **kwargs): return drf.response.Response(body, status=drf.status.HTTP_200_OK) + @drf.decorators.action( + detail=True, + methods=["post"], + name="Proxy AI requests to the AI provider", + url_path="ai-proxy", + throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], + ) + def ai_proxy(self, request, *args, **kwargs): + """ + POST /api/v1.0/documents//ai-proxy + Proxy AI requests to the configured AI provider. + This endpoint forwards requests to the AI provider and returns the complete response. + """ + # Check permissions first + self.get_object() + + if not settings.AI_FEATURE_ENABLED: + raise ValidationError("AI feature is not enabled.") + + serializer = serializers.AIProxySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + response = AIService().proxy(request.data) + return drf.response.Response(response, status=drf.status.HTTP_200_OK) + @drf.decorators.action( detail=True, methods=["post"], diff --git a/src/backend/core/models.py b/src/backend/core/models.py index fb3443ce9..9c83ce867 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -832,8 +832,7 @@ def get_abilities(self, user, ancestors_links=None): return { "accesses_manage": is_owner_or_admin, "accesses_view": has_access_role, - "ai_transform": ai_access, - "ai_translate": ai_access, + "ai_proxy": ai_access, "attachment_upload": can_update, "media_check": can_get, "children_list": can_get, diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index 97ad583d8..d7245d4f1 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -1,5 +1,7 @@ """AI services.""" +import logging + from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -7,6 +9,8 @@ from core import enums +log = logging.getLogger(__name__) + AI_ACTIONS = { "prompt": ( "Answer the prompt in markdown format. " @@ -91,3 +95,10 @@ def translate(self, text, 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 + + response = self.client.chat.completions.create(**data) + return response.model_dump() diff --git a/src/backend/core/tests/documents/test_api_documents_ai_proxy.py b/src/backend/core/tests/documents/test_api_documents_ai_proxy.py new file mode 100644 index 000000000..055dcf5bf --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_ai_proxy.py @@ -0,0 +1,686 @@ +""" +Test AI proxy 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(autouse=True) +def ai_settings(settings): + """Fixture to set AI settings.""" + settings.AI_MODEL = "llama" + settings.AI_BASE_URL = "http://example.com" + settings.AI_API_KEY = "test-key" + settings.AI_FEATURE_ENABLED = True + + +@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_proxy_anonymous_forbidden(reach, role): + """ + Anonymous users should not be able to request AI proxy 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-proxy/" + response = APIClient().post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@override_settings(AI_ALLOW_REACH_FROM="public") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_anonymous_success(mock_create): + """ + Anonymous users should be able to request AI proxy to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "llama", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?", + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21}, + } + mock_create.return_value = mock_response + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = APIClient().post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["id"] == "chatcmpl-123" + assert response_data["model"] == "llama" + assert len(response_data["choices"]) == 1 + assert ( + response_data["choices"][0]["message"]["content"] + == "Hello! How can I help you?" + ) + + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "Hello"}], + model="llama", + stream=False, + ) + + +@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_anonymous_limited_by_setting(mock_create): + """ + Anonymous users should not be able to request AI proxy to a document + if AI_ALLOW_REACH_FROM setting restricts it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_response = MagicMock() + mock_response.model_dump.return_value = {"content": "Hello!"} + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = APIClient().post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "reach, role", + [ + ("restricted", "reader"), + ("restricted", "editor"), + ("authenticated", "reader"), + ("public", "reader"), + ], +) +def test_api_documents_ai_proxy_authenticated_forbidden(reach, role): + """ + Users who are not related to a document can't request AI proxy 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-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "reach, role", + [ + ("authenticated", "editor"), + ("public", "editor"), + ], +) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_authenticated_success(mock_create, reach, role): + """ + Authenticated users should be able to request AI proxy to a document + 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_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-456", + "object": "chat.completion", + "model": "llama", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hi there!"}, + "finish_reason": "stop", + } + ], + } + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["id"] == "chatcmpl-456" + assert response_data["choices"][0]["message"]["content"] == "Hi there!" + + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "Hello"}], + model="llama", + stream=False, + ) + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_ai_proxy_reader(via, mock_user_teams): + """Users with reader access should not be able to request AI proxy.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + 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-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_success(mock_create, via, role, mock_user_teams): + """Users with sufficient permissions should be able to request AI proxy.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + 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_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-789", + "object": "chat.completion", + "model": "llama", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Success!"}, + "finish_reason": "stop", + } + ], + } + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Test message"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["id"] == "chatcmpl-789" + assert response_data["choices"][0]["message"]["content"] == "Success!" + + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "Test message"}], + model="llama", + stream=False, + ) + + +def test_api_documents_ai_proxy_empty_messages(): + """The messages should not be empty when requesting AI proxy.""" + 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-proxy/" + response = client.post(url, {"messages": [], "model": "llama"}, format="json") + + assert response.status_code == 400 + assert response.json() == {"messages": ["This list may not be empty."]} + + +def test_api_documents_ai_proxy_missing_model(): + """The model should be required when requesting AI proxy.""" + 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-proxy/" + response = client.post( + url, {"messages": [{"role": "user", "content": "Hello"}]}, format="json" + ) + + assert response.status_code == 400 + assert response.json() == {"model": ["This field is required."]} + + +def test_api_documents_ai_proxy_invalid_message_format(): + """Messages should have the correct format when requesting AI proxy.""" + 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-proxy/" + + # Test with invalid message format (missing role) + response = client.post( + url, + { + "messages": [{"content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "messages": ["Each message must have 'role' and 'content' fields"] + } + + # Test with invalid message format (missing content) + response = client.post( + url, + { + "messages": [{"role": "user"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "messages": ["Each message must have 'role' and 'content' fields"] + } + + # Test with non-dict message + response = client.post( + url, + { + "messages": ["invalid"], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "messages": {"0": ['Expected a dictionary of items but got type "str".']} + } + + +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_stream_disabled(mock_create): + """Stream should be automatically disabled in AI proxy requests.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_response = MagicMock() + mock_response.model_dump.return_value = {"content": "Success!"} + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + "stream": True, # This should be overridden to False + }, + format="json", + ) + + assert response.status_code == 200 + # Verify that stream was set to False + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "Hello"}], + model="llama", + stream=False, + ) + + +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_additional_parameters(mock_create): + """AI proxy should pass through additional parameters to the AI service.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_response = MagicMock() + mock_response.model_dump.return_value = {"content": "Success!"} + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + "temperature": 0.7, + "max_tokens": 100, + "top_p": 0.9, + }, + format="json", + ) + + assert response.status_code == 200 + # Verify that additional parameters were passed through + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "Hello"}], + model="llama", + temperature=0.7, + max_tokens=100, + top_p=0.9, + stream=False, + ) + + +@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_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_response = MagicMock() + mock_response.model_dump.return_value = {"content": "Success!"} + mock_create.return_value = mock_response + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + for _ in range(3): + user = factories.UserFactory() + client.force_login(user) + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Test message"}], + "model": "llama", + }, + format="json", + ) + assert response.status_code == 200 + assert response.json() == {"content": "Success!"} + + user = factories.UserFactory() + client.force_login(user) + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Test message"}], + "model": "llama", + }, + ) + + assert response.status_code == 429 + assert response.json() == { + "detail": "Request was throttled. Expected available in 60 seconds." + } + + +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_complex_conversation(mock_create): + """AI proxy should handle complex conversations with multiple messages.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-complex", + "object": "chat.completion", + "model": "llama", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I understand your question about Python.", + }, + "finish_reason": "stop", + } + ], + } + mock_create.return_value = mock_response + + complex_messages = [ + {"role": "system", "content": "You are a helpful programming assistant."}, + {"role": "user", "content": "How do I write a for loop in Python?"}, + { + "role": "assistant", + "content": "You can write a for loop using: for item in iterable:", + }, + {"role": "user", "content": "Can you give me a concrete example?"}, + ] + + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": complex_messages, + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["id"] == "chatcmpl-complex" + assert ( + response_data["choices"][0]["message"]["content"] + == "I understand your question about Python." + ) + + mock_create.assert_called_once_with( + messages=complex_messages, + model="llama", + stream=False, + ) + + +@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_proxy_throttling_user(mock_create): + """ + Throttling per user should be triggered on the AI proxy endpoint. + For full throttle class test see: `test_api_utils_ai_user_rate_throttles` + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + mock_response = MagicMock() + mock_response.model_dump.return_value = {"content": "Success!"} + mock_create.return_value = mock_response + + for _ in range(3): + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + assert response.status_code == 200 + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/" + response = client.post( + url, + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + 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": 10, "hour": 6, "day": 10}) +def test_api_documents_ai_proxy_different_models(): + """AI proxy should work with different AI models.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + models_to_test = ["gpt-3.5-turbo", "gpt-4", "claude-3", "llama-2"] + + for model_name in models_to_test: + response = client.post( + f"/api/v1.0/documents/{document.id!s}/ai-proxy/", + { + "messages": [{"role": "user", "content": "Hello"}], + "model": model_name, + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == {"model": [f"{model_name} is not a valid model"]} + + +def test_api_documents_ai_proxy_ai_feature_disabled(settings): + """When the settings AI_FEATURE_ENABLED is set to False, the endpoint is not reachable.""" + settings.AI_FEATURE_ENABLED = False + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/ai-proxy/", + { + "messages": [{"role": "user", "content": "Hello"}], + "model": "llama", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == ["AI feature is not enabled."] 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 91e6ca0e5..f686cb06b 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -28,6 +28,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "abilities": { "accesses_manage": False, "accesses_view": False, + "ai_proxy": False, "ai_transform": False, "ai_translate": False, "attachment_upload": document.link_role == "editor", @@ -96,6 +97,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "abilities": { "accesses_manage": False, "accesses_view": False, + "ai_proxy": False, "ai_transform": False, "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", @@ -193,6 +195,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "abilities": { "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", @@ -268,6 +271,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "abilities": { "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", @@ -449,6 +453,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "abilities": { "accesses_manage": access.role in ["administrator", "owner"], "accesses_view": True, + "ai_proxy": access.role != "reader", "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", 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 4e4eb2769..c7037a443 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -72,6 +72,7 @@ def test_api_documents_trashbin_format(): "abilities": { "accesses_manage": True, "accesses_view": True, + "ai_proxy": True, "ai_transform": True, "ai_translate": True, "attachment_upload": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ae10fb55b..b6db6777b 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -152,6 +152,7 @@ def test_models_documents_get_abilities_forbidden( expected_abilities = { "accesses_manage": False, "accesses_view": False, + "ai_proxy": False, "ai_transform": False, "ai_translate": False, "attachment_upload": False, @@ -213,6 +214,7 @@ def test_models_documents_get_abilities_reader( expected_abilities = { "accesses_manage": False, "accesses_view": False, + "ai_proxy": False, "ai_transform": False, "ai_translate": False, "attachment_upload": False, @@ -276,6 +278,7 @@ def test_models_documents_get_abilities_editor( expected_abilities = { "accesses_manage": False, "accesses_view": False, + "ai_proxy": is_authenticated, "ai_transform": is_authenticated, "ai_translate": is_authenticated, "attachment_upload": True, @@ -328,6 +331,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): expected_abilities = { "accesses_manage": True, "accesses_view": True, + "ai_proxy": True, "ai_transform": True, "ai_translate": True, "attachment_upload": True, @@ -377,6 +381,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) expected_abilities = { "accesses_manage": True, "accesses_view": True, + "ai_proxy": True, "ai_transform": True, "ai_translate": True, "attachment_upload": True, @@ -429,6 +434,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): expected_abilities = { "accesses_manage": False, "accesses_view": True, + "ai_proxy": True, "ai_transform": True, "ai_translate": True, "attachment_upload": True, @@ -488,6 +494,7 @@ def test_models_documents_get_abilities_reader_user( "accesses_view": True, # 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, @@ -545,6 +552,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): assert abilities == { "accesses_manage": False, "accesses_view": True, + "ai_proxy": False, "ai_transform": False, "ai_translate": False, "attachment_upload": False, @@ -592,6 +600,7 @@ def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, document = factories.DocumentFactory(link_reach=reach, link_role="editor") abilities = document.get_abilities(user) + assert abilities["ai_proxy"] is True assert abilities["ai_transform"] is True assert abilities["ai_translate"] is True @@ -611,6 +620,7 @@ def test_models_document_get_abilities_ai_access_public(is_authenticated, reach) document = factories.DocumentFactory(link_reach=reach, link_role="editor") abilities = document.get_abilities(user) + assert abilities["ai_proxy"] == is_authenticated assert abilities["ai_transform"] == is_authenticated assert abilities["ai_translate"] == is_authenticated 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 2cc2b6c17..9c2b6e9c3 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 @@ -49,6 +49,7 @@ export interface Doc { abilities: { accesses_manage: boolean; accesses_view: boolean; + ai_proxy: boolean; ai_transform: boolean; ai_translate: boolean; attachment_upload: 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 49485277a..94473ce73 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 @@ -179,6 +179,7 @@ export class ApiPlugin implements WorkboxPlugin { abilities: { accesses_manage: true, accesses_view: true, + ai_proxy: true, ai_transform: true, ai_translate: true, attachment_upload: true, diff --git a/src/frontend/servers/y-provider/src/api/getDoc.ts b/src/frontend/servers/y-provider/src/api/getDoc.ts index 6c712f6da..9da249b03 100644 --- a/src/frontend/servers/y-provider/src/api/getDoc.ts +++ b/src/frontend/servers/y-provider/src/api/getDoc.ts @@ -32,6 +32,7 @@ interface Doc { abilities: { accesses_manage: boolean; accesses_view: boolean; + ai_proxy: boolean; ai_transform: boolean; ai_translate: boolean; attachment_upload: boolean; From 8a6d1eedffae9c3ff951e8f041d37f2b54bcbb67 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:15:31 +0200 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20new=20B?= =?UTF-8?q?locknote=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We integrate the new Blocknote AI feature into Docs, enhancing the document editing experience with AI capabilities. --- CHANGELOG.md | 1 + .../e2e/__tests__/app-impress/config.spec.ts | 4 +- .../__tests__/app-impress/doc-editor.spec.ts | 98 +++ src/frontend/apps/impress/package.json | 8 +- .../docs/doc-editor/assets/IconAI.svg | 6 + .../docs/doc-editor/components/AI/AIUI.tsx | 135 ++++ .../docs/doc-editor/components/AI/index.ts | 2 + .../docs/doc-editor/components/AI/useAI.tsx | 47 ++ .../doc-editor/components/BlockNoteEditor.tsx | 9 +- .../components/BlockNoteSuggestionMenu.tsx | 7 +- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 3 + .../docs/doc-editor/hook/useSaveDoc.tsx | 8 +- .../src/features/docs/doc-editor/styles.tsx | 18 + src/frontend/package.json | 1 + src/frontend/yarn.lock | 641 ++++++++++++++---- 15 files changed, 863 insertions(+), 125 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ea821ad15..9f20678f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - (doc) add documentation to install with compose #855 - ✨(backend) allow to disable checking unsafe mimetype on attachment upload - ✨Ask for access #1081 +- ✨(frontend) integrate new Blocknote AI feature #1016 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 52889171b..8a33b6927 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -91,9 +91,7 @@ test.describe('Config', () => { expect( await page.locator('button[data-test="convertMarkdown"]').count(), ).toBe(1); - expect(await page.locator('button[data-test="ai-actions"]').count()).toBe( - 0, - ); + await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); }); test('it checks that Crisp is trying to init from config endpoint', async ({ 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 f64139b79..3a1d3fe3b 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 @@ -285,6 +285,104 @@ test.describe('Doc Editor', () => { ); }); + test('it checks the AI feature', async ({ page, browserName }) => { + await page.route(/.*\/ai-proxy\//, async (route) => { + const request = route.request(); + if (request.method().includes('POST')) { + await route.fulfill({ + json: { + id: 'chatcmpl-b1e7a9e456ca41f78fec130d552a6bf5', + choices: [ + { + finish_reason: 'stop', + index: 0, + logprobs: null, + message: { + content: '', + refusal: null, + role: 'assistant', + annotations: null, + audio: null, + function_call: null, + tool_calls: [ + { + id: 'chatcmpl-tool-2e3567dfecf94a4c85e27a3528337718', + function: { + arguments: + '{"operations": [{"type": "update", "id": "initialBlockId$", "block": "

Bonjour le monde

"}]}', + name: 'json', + }, + type: 'function', + }, + ], + reasoning_content: null, + }, + stop_reason: null, + }, + ], + created: 1749549477, + model: 'neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8', + object: 'chat.completion', + service_tier: null, + system_fingerprint: null, + usage: { + completion_tokens: 0, + prompt_tokens: 204, + total_tokens: 204, + completion_tokens_details: null, + prompt_tokens_details: null, + details: [ + { + id: 'chatcmpl-b1e7a9e456ca41f78fec130d552a6bf5', + model: 'neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8', + prompt_tokens: 204, + completion_tokens: 0, + total_tokens: 204, + }, + ], + }, + prompt_logprobs: null, + }, + }); + } 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 World').selectText(); + + // Check from toolbar + await page.getByRole('button', { name: 'Ask AI' }).click(); + + await expect( + page.getByRole('option', { name: 'Improve Writing' }), + ).toBeVisible(); + await expect( + page.getByRole('option', { name: 'Fix Spelling' }), + ).toBeVisible(); + await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible(); + + await page.getByRole('option', { name: 'Translate' }).click(); + await page.getByPlaceholder('Ask AI anything…').fill('French'); + await page.getByPlaceholder('Ask AI anything…').press('Enter'); + await expect(editor.getByText('Docs AI')).toBeVisible(); + await page + .locator('p.bn-mt-suggestion-menu-item-title') + .getByText('Accept') + .click(); + + await expect(editor.getByText('Bonjour le monde')).toBeVisible(); + + // Check Suggestion menu + await page.locator('.bn-block-outer').last().fill('/'); + 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(); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index b21090d8a..2e212d7bb 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -16,10 +16,12 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", + "@ai-sdk/openai": "1.3.22", "@blocknote/code-block": "0.32.0", "@blocknote/core": "0.32.0", "@blocknote/mantine": "0.32.0", "@blocknote/react": "0.32.0", + "@blocknote/xl-ai": "0.32.0", "@blocknote/xl-docx-exporter": "0.32.0", "@blocknote/xl-pdf-exporter": "0.32.0", "@emoji-mart/data": "1.2.1", @@ -32,6 +34,7 @@ "@react-pdf/renderer": "4.3.0", "@sentry/nextjs": "9.32.0", "@tanstack/react-query": "5.81.2", + "ai": "4.3.16", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -45,9 +48,9 @@ "luxon": "3.6.1", "next": "15.3.4", "posthog-js": "1.255.1", - "react": "*", + "react": "19.1.0", "react-aria-components": "1.10.1", - "react-dom": "*", + "react-dom": "19.1.0", "react-i18next": "15.5.3", "react-intersection-observer": "9.16.0", "react-select": "5.10.1", @@ -55,6 +58,7 @@ "use-debounce": "10.0.5", "y-protocols": "1.0.6", "yjs": "*", + "zod": "3.25.28", "zustand": "5.0.5" }, "devDependencies": { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg new file mode 100644 index 000000000..8436d0047 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx new file mode 100644 index 000000000..cea52921a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -0,0 +1,135 @@ +import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react'; +import { + AIMenu as AIMenuDefault, + getAIExtension, + getDefaultAIMenuItems, +} from '@blocknote/xl-ai'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import IconAI from '../../assets/IconAI.svg'; +import { + DocsBlockNoteEditor, + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; + +export function AIMenu() { + return ( + { + if (aiResponseStatus === 'user-input') { + if (editor.getSelection()) { + const aiMenuItems = getDefaultAIMenuItems( + editor, + aiResponseStatus, + ).filter((item) => ['simplify'].indexOf(item.key) === -1); + + return aiMenuItems; + } else { + const aiMenuItems = getDefaultAIMenuItems( + editor, + aiResponseStatus, + ).filter( + (item) => + ['action_items', 'write_anything'].indexOf(item.key) === -1, + ); + + return aiMenuItems; + } + } + + return getDefaultAIMenuItems(editor, aiResponseStatus); + }} + /> + ); +} + +export const AIToolbarButton = () => { + const Components = useComponentsContext(); + const { t } = useTranslation(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + + if (!editor.isEditable || !Components) { + return null; + } + + const onClick = () => { + const aiExtension = getAIExtension(editor); + editor.formattingToolbar.closeMenu(); + const selection = editor.getSelection(); + if (!selection) { + throw new Error('No selection'); + } + + const position = selection.blocks[selection.blocks.length - 1].id; + aiExtension.openAIMenuAtBlock(position); + }; + + return ( + button.mantine-Button-root { + padding-inline: ${spacingsTokens['2xs']}; + transition: all 0.1s ease-in; + &:hover, + &:hover { + background-color: ${colorsTokens['greyscale-050']}; + } + &:hover .--docs--icon-bg { + background-color: #5858e1; + border: 1px solid #8484f5; + color: #ffffff; + } + } + `} + $direction="row" + className="--docs--ai-toolbar-button" + > + + + + + + {t('Ask AI')} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts new file mode 100644 index 000000000..fe820022c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts @@ -0,0 +1,2 @@ +export * from './AIUI'; +export * from './useAI'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx new file mode 100644 index 000000000..b0f791e29 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -0,0 +1,47 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createAIExtension, createBlockNoteAIClient } from '@blocknote/xl-ai'; +import { useMemo } from 'react'; + +import { fetchAPI } from '@/api'; +import { Doc } from '@/docs/doc-management'; + +const client = createBlockNoteAIClient({ + baseURL: ``, + apiKey: '', +}); + +/** + * Custom implementation of the PromptBuilder that allows for using predefined prompts. + * + * This extends the default HTML promptBuilder from BlockNote to support custom prompt templates. + * Custom prompts can be invoked using the pattern !promptName in the AI input field. + */ +export const useAI = (docId: Doc['id']) => { + return useMemo(() => { + const openai = createOpenAI({ + ...client.getProviderSettings('openai'), + fetch: (input, init) => { + // Create a new headers object without the Authorization header + const headers = new Headers(init?.headers); + headers.delete('Authorization'); + + return fetchAPI(`documents/${docId}/ai-proxy/`, { + ...init, + headers, + }); + }, + }); + const model = openai.chat('neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8'); + + const extension = createAIExtension({ + stream: false, + model, + agentCursor: { + name: 'Albert', + color: '#8bc6ff', + }, + }); + + return extension; + }, [docId]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 9a486b586..6a56df2b6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -9,6 +9,9 @@ import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; +import { AIMenuController } from '@blocknote/xl-ai'; +import { en as aiEn } from '@blocknote/xl-ai/locales'; +import '@blocknote/xl-ai/style.css'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,6 +28,7 @@ import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; +import { AIMenu, useAI } from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, DividerBlock } from './custom-blocks'; @@ -57,6 +61,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const lang = i18n.resolvedLanguage; const { uploadFile, errorAttachment } = useUploadFile(doc.id); + const aiExtension = useAI(doc.id); const collabName = readOnly ? 'Reader' @@ -115,7 +120,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, - dictionary: locales[lang as keyof typeof locales], + dictionary: { ...locales[lang as keyof typeof locales], ai: aiEn }, + extensions: [aiExtension], tables: { splitCells: true, cellBackgroundColor: true, @@ -163,6 +169,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 3122b1c17..d796dbd8d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -6,9 +6,12 @@ import { useBlockNoteEditor, useDictionary, } from '@blocknote/react'; +import { getAISlashMenuItems } from '@blocknote/xl-ai'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useConfig } from '@/core'; + import { DocsBlockSchema } from '../types'; import { @@ -20,6 +23,7 @@ export const BlockNoteSuggestionMenu = () => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; + const { data: conf } = useConfig(); const getSlashMenuItems = useMemo(() => { return async (query: string) => @@ -30,11 +34,12 @@ export const BlockNoteSuggestionMenu = () => { getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), + conf?.AI_FEATURE_ENABLED ? getAISlashMenuItems(editor) : [], ), query, ), ); - }, [basicBlocksName, editor, t]); + }, [basicBlocksName, editor, t, conf?.AI_FEATURE_ENABLED]); return ( { const formattingToolbar = useCallback(() => { return ( + {conf?.AI_FEATURE_ENABLED && } + {toolbarItems} {/* Extra button to do some AI powered actions */} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 274adcfff..b064a95d2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -31,7 +31,13 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { _updatedDoc: Y.Doc, transaction: Y.Transaction, ) => { - setIsLocalChange(transaction.local); + /** + * When the AI edit the doc transaction.local is false, + * so we check if the origin is null to know if the change + * is local or not. + * TODO: see if we can get the local changes from the AI + */ + setIsLocalChange(transaction.local || transaction.origin === null); }; yDoc.on('update', onUpdate); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index f24cd9524..3685d24ca 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -1,6 +1,11 @@ import { css } from 'styled-components'; export const cssEditor = (readonly: boolean) => css` + .mantine-Menu-itemLabel, + .mantine-Button-label { + font-family: var(--c--components--button--font-family); + } + &, & > .bn-container, & .ProseMirror { @@ -108,6 +113,19 @@ export const cssEditor = (readonly: boolean) => css` border-left: 4px solid var(--c--theme--colors--greyscale-300); font-style: italic; } + + /** + * AI + */ + ins, + [data-type='modification'] { + background: var(--c--theme--colors--primary-100); + border-bottom: 2px solid var(--c--theme--colors--primary-300); + color: var(--c--theme--colors--primary-700); + } + [data-show-selection] { + background-color: var(--c--theme--colors--primary-300); + } } & .bn-block-outer:not(:first-child) { diff --git a/src/frontend/package.json b/src/frontend/package.json index acef77729..0fc3bb4a8 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -34,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "eslint": "8.57.0", + "prosemirror-view": "1.33.7", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "5.8.3", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 8f5f3ecb7..50ffb4d7b 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -12,6 +12,49 @@ resolved "https://registry.yarnpkg.com/@ag-media/react-pdf-table/-/react-pdf-table-2.0.3.tgz#113554b583b46e41a098cf64fecb5decd59ba004" integrity sha512-IscjfAOKwsyQok9YmzvuToe6GojN7J8hF0kb8C+K8qZX1DvhheGO+hRSAPxbv2nKMbSpvk7CIhSqJEkw++XVWg== +"@ai-sdk/openai@1.3.22": + version "1.3.22" + resolved "https://registry.yarnpkg.com/@ai-sdk/openai/-/openai-1.3.22.tgz#ed52af8f8fb3909d108e945d12789397cb188b9b" + integrity sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/provider-utils@2.2.8": + version "2.2.8" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz#ad11b92d5a1763ab34ba7b5fc42494bfe08b76d1" + integrity sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA== + dependencies: + "@ai-sdk/provider" "1.1.3" + nanoid "^3.3.8" + secure-json-parse "^2.7.0" + +"@ai-sdk/provider@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-1.1.3.tgz#ebdda8077b8d2b3f290dcba32c45ad19b2704681" + integrity sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/react@1.2.12": + version "1.2.12" + resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-1.2.12.tgz#f4250b6df566b170af98a71d5708b52108dd0ce1" + integrity sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g== + dependencies: + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/ui-utils" "1.2.11" + swr "^2.2.5" + throttleit "2.1.0" + +"@ai-sdk/ui-utils@1.2.11": + version "1.2.11" + resolved "https://registry.yarnpkg.com/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz#4f815589d08d8fef7292ade54ee5db5d09652603" + integrity sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + zod-to-json-schema "^3.24.1" + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" @@ -1165,6 +1208,11 @@ "@mantine/utils" "^6.0.21" react-icons "^5.2.1" +"@blocknote/prosemirror-suggest-changes@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@blocknote/prosemirror-suggest-changes/-/prosemirror-suggest-changes-0.1.3.tgz#1c2e99d7a34c3c29597d1a4a79ce0a9bff202f66" + integrity sha512-g/RxkStEg67bGiujK092aabD6tA/O4jI9jwKAnasP+t00khgyBBIXIvp7K7QT+gjVB6kP+ez27669iiJy8MZ5w== + "@blocknote/react@0.32.0": version "0.32.0" resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.32.0.tgz#425127ff217af27c0d648d905d062066c4a89c9f" @@ -1194,6 +1242,34 @@ y-protocols "^1.0.6" yjs "^13.6.15" +"@blocknote/xl-ai@0.32.0": + version "0.32.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai/-/xl-ai-0.32.0.tgz#ff496a63981c6462717596d442caf36941f172cb" + integrity sha512-jMntc17tujtcJ64n/rdhWGgGqIA5P1j62ArE5P9Fx+ing5Whh+TVfIBmqO6FtNjJd8jjzTmey1VKq0KDcRhFag== + dependencies: + "@blocknote/core" "0.32.0" + "@blocknote/mantine" "0.32.0" + "@blocknote/prosemirror-suggest-changes" "^0.1.3" + "@blocknote/react" "0.32.0" + "@floating-ui/react" "^0.26.4" + "@tiptap/core" "^2.12.0" + ai "^4.3.15" + lodash.isequal "^4.5.0" + prosemirror-changeset "^2.3.0" + prosemirror-model "^1.24.1" + prosemirror-state "^1.4.3" + prosemirror-tables "^1.6.4" + prosemirror-transform "^1.10.4" + prosemirror-view "^1.33.7" + react "^18" + react-dom "^18" + react-icons "^5.2.1" + remark-parse "^10.0.1" + remark-stringify "^10.0.2" + unified "^10.1.2" + y-prosemirror "^1.3.4" + zustand "^5.0.3" + "@blocknote/xl-docx-exporter@0.32.0": version "0.32.0" resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.32.0.tgz#77f59249fefc08a375d32e2a4907f037da5aefba" @@ -2491,7 +2567,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -6550,6 +6626,11 @@ dependencies: "@types/ms" "*" +"@types/diff-match-patch@^1.0.36": + version "1.0.36" + resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af" + integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg== + "@types/eslint-scope@^3.7.7": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -6699,6 +6780,13 @@ "@types/linkify-it" "^5" "@types/mdurl" "^2" +"@types/mdast@^3.0.0": + version "3.0.15" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" + integrity sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ== + dependencies: + "@types/unist" "^2" + "@types/mdast@^4.0.0": version "4.0.4" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" @@ -6903,6 +6991,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== +"@types/unist@^2", "@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@types/use-sync-external-store@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" @@ -7389,11 +7482,6 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -"@yarnpkg/lockfile@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" - integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== - abs-svg-path@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf" @@ -7441,6 +7529,18 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== +ai@4.3.16, ai@^4.3.15: + version "4.3.16" + resolved "https://registry.yarnpkg.com/ai/-/ai-4.3.16.tgz#c9446da1024cdc1dfe2913d151b70c91d40f2378" + integrity sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/react" "1.2.12" + "@ai-sdk/ui-utils" "1.2.11" + "@opentelemetry/api" "1.9.0" + jsondiffpatch "0.6.0" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -8111,6 +8211,11 @@ chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -8190,11 +8295,6 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -ci-info@^3.7.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== - ci-info@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.2.0.tgz#cbd21386152ebfe1d56f280a3b5feccbd96764c7" @@ -8777,11 +8877,21 @@ dfa@^1.2.0: resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -9834,13 +9944,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-yarn-workspace-root@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" - integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== - dependencies: - micromatch "^4.0.2" - flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -9951,7 +10054,7 @@ fs-extra@^8.0.1, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.0.0, fs-extra@^9.0.1: +fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -10228,7 +10331,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -10836,6 +10939,11 @@ is-boolean-object@^1.2.1: call-bound "^1.0.3" has-tostringtag "^1.0.2" +is-buffer@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-bun-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" @@ -10872,11 +10980,6 @@ is-date-object@^1.0.5, is-date-object@^1.1.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" -is-docker@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -11077,13 +11180,6 @@ is-weakset@^2.0.3: call-bound "^1.0.3" get-intrinsic "^1.2.6" -is-wsl@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -11673,17 +11769,6 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json-stable-stringify@^1.0.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" - integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - isarray "^2.0.5" - jsonify "^0.0.1" - object-keys "^1.1.1" - json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -11696,6 +11781,15 @@ json5@^2.2.0, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsondiffpatch@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" + integrity sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ== + dependencies: + "@types/diff-match-patch" "^1.0.36" + chalk "^5.3.0" + diff-match-patch "^1.0.5" + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -11712,11 +11806,6 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsonify@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" - integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== - jsonpointer@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" @@ -11761,14 +11850,7 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== -klaw-sync@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" - integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== - dependencies: - graceful-fs "^4.1.11" - -kleur@^4.1.4: +kleur@^4.0.3, kleur@^4.1.4: version "4.1.5" resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== @@ -12032,6 +12114,24 @@ mdast-util-find-and-replace@^3.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" +mdast-util-from-markdown@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0" + integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + decode-named-character-reference "^1.0.0" + mdast-util-to-string "^3.1.0" + micromark "^3.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-decode-string "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + unist-util-stringify-position "^3.0.0" + uvu "^0.5.0" + mdast-util-from-markdown@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" @@ -12115,6 +12215,14 @@ mdast-util-gfm@^3.0.0: mdast-util-gfm-task-list-item "^2.0.0" mdast-util-to-markdown "^2.0.0" +mdast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz#c7c21d0d435d7fb90956038f02e8702781f95463" + integrity sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg== + dependencies: + "@types/mdast" "^3.0.0" + unist-util-is "^5.0.0" + mdast-util-phrasing@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" @@ -12138,6 +12246,20 @@ mdast-util-to-hast@^13.0.0: unist-util-visit "^5.0.0" vfile "^6.0.0" +mdast-util-to-markdown@^1.0.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz#c13343cb3fc98621911d33b5cd42e7d0731171c6" + integrity sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^3.0.0" + mdast-util-to-string "^3.0.0" + micromark-util-decode-string "^1.0.0" + unist-util-visit "^4.0.0" + zwitch "^2.0.0" + mdast-util-to-markdown@^2.0.0: version "2.1.2" resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" @@ -12153,6 +12275,13 @@ mdast-util-to-markdown@^2.0.0: unist-util-visit "^5.0.0" zwitch "^2.0.0" +mdast-util-to-string@^3.0.0, mdast-util-to-string@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789" + integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-string@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" @@ -12225,6 +12354,28 @@ methods@^1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" + integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-factory-destination "^1.0.0" + micromark-factory-label "^1.0.0" + micromark-factory-space "^1.0.0" + micromark-factory-title "^1.0.0" + micromark-factory-whitespace "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-classify-character "^1.0.0" + micromark-util-html-tag-name "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + micromark-core-commonmark@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" @@ -12326,6 +12477,15 @@ micromark-extension-gfm@^3.0.0: micromark-util-combine-extensions "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-destination@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f" + integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-destination@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" @@ -12335,6 +12495,16 @@ micromark-factory-destination@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-label@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68" + integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-factory-label@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" @@ -12345,6 +12515,14 @@ micromark-factory-label@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-space@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf" + integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-space@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" @@ -12353,6 +12531,16 @@ micromark-factory-space@^2.0.0: micromark-util-character "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-title@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1" + integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-title@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" @@ -12363,6 +12551,16 @@ micromark-factory-title@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-whitespace@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705" + integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-whitespace@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" @@ -12373,6 +12571,14 @@ micromark-factory-whitespace@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-character@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc" + integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-character@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" @@ -12381,6 +12587,13 @@ micromark-util-character@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-chunked@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b" + integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-chunked@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" @@ -12388,6 +12601,15 @@ micromark-util-chunked@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-classify-character@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d" + integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-classify-character@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" @@ -12397,6 +12619,14 @@ micromark-util-classify-character@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-combine-extensions@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84" + integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-combine-extensions@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" @@ -12405,6 +12635,13 @@ micromark-util-combine-extensions@^2.0.0: micromark-util-chunked "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-decode-numeric-character-reference@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6" + integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-decode-numeric-character-reference@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" @@ -12412,6 +12649,16 @@ micromark-util-decode-numeric-character-reference@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-decode-string@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c" + integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-decode-string@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" @@ -12422,16 +12669,33 @@ micromark-util-decode-string@^2.0.0: micromark-util-decode-numeric-character-reference "^2.0.0" micromark-util-symbol "^2.0.0" +micromark-util-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5" + integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw== + micromark-util-encode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== +micromark-util-html-tag-name@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588" + integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q== + micromark-util-html-tag-name@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== +micromark-util-normalize-identifier@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7" + integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-normalize-identifier@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" @@ -12439,6 +12703,13 @@ micromark-util-normalize-identifier@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-resolve-all@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188" + integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA== + dependencies: + micromark-util-types "^1.0.0" + micromark-util-resolve-all@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" @@ -12446,6 +12717,15 @@ micromark-util-resolve-all@^2.0.0: dependencies: micromark-util-types "^2.0.0" +micromark-util-sanitize-uri@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d" + integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-sanitize-uri@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" @@ -12455,6 +12735,16 @@ micromark-util-sanitize-uri@^2.0.0: micromark-util-encode "^2.0.0" micromark-util-symbol "^2.0.0" +micromark-util-subtokenize@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1" + integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-util-subtokenize@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" @@ -12465,16 +12755,49 @@ micromark-util-subtokenize@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-symbol@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142" + integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag== + micromark-util-symbol@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== +micromark-util-types@^1.0.0, micromark-util-types@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283" + integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg== + micromark-util-types@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== +micromark@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9" + integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + micromark-core-commonmark "^1.0.1" + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-combine-extensions "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + micromark@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" @@ -12498,7 +12821,7 @@ micromark@^4.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" -micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -12603,6 +12926,11 @@ module-details-from-path@^1.0.3: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== +mri@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -12618,7 +12946,7 @@ mylas@^2.1.9: resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== -nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7: +nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -12868,14 +13196,6 @@ oniguruma-to-es@^4.3.3: regex "^6.0.1" regex-recursion "^6.0.2" -open@^7.4.2: - version "7.4.2" - resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" - integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== - dependencies: - is-docker "^2.0.0" - is-wsl "^2.1.1" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -12893,11 +13213,6 @@ orderedmap@^2.0.0: resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== - own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -13004,27 +13319,6 @@ parseurl@^1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -patch-package@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" - integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== - dependencies: - "@yarnpkg/lockfile" "^1.1.0" - chalk "^4.1.2" - ci-info "^3.7.0" - cross-spawn "^7.0.3" - find-yarn-workspace-root "^2.0.0" - fs-extra "^9.0.0" - json-stable-stringify "^1.0.2" - klaw-sync "^6.0.0" - minimist "^1.2.6" - open "^7.4.2" - rimraf "^2.6.3" - semver "^7.5.3" - slash "^2.0.0" - tmp "^0.0.33" - yaml "^2.2.2" - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -13453,7 +13747,7 @@ prosemirror-menu@^1.2.4: prosemirror-history "^1.0.0" prosemirror-state "^1.0.0" -prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0, prosemirror-model@^1.25.1: +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.1: version "1.25.1" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.1.tgz#aeae9f1ec79fcaa76f6fc619800d91fbcf726870" integrity sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg== @@ -13511,10 +13805,10 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: - version "1.39.3" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.39.3.tgz#54fa4b8ab4fd75ad0075dc6dc0be1745429d5a5c" - integrity sha512-bY/7kg0LzRE7ytR0zRdSMWX3sknEjw68l836ffLPMh0OG3OYnNuBDUSF3v0vjvnzgYjgY9ZH/RypbARURlcMFA== +prosemirror-view@1.33.7, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.33.7, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: + version "1.33.7" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.33.7.tgz#fd9841a79a4bc517914a57456370b941bd655729" + integrity sha512-jo6eMQCtPRwcrA2jISBCnm0Dd2B+szS08BU1Ay+XGiozHo5EZMHfLQE8R5nO4vb1spTH2RW1woZIYXRiQsuP8g== dependencies: prosemirror-model "^1.20.0" prosemirror-state "^1.0.0" @@ -13896,7 +14190,7 @@ react-dnd@^14.0.3: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@*, react-dom@19.0.0, react-dom@19.1.0: +react-dom@19.0.0, react-dom@19.1.0, react-dom@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== @@ -14130,7 +14424,7 @@ react-window@^1.8.11: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@*, react@19.0.0, react@19.1.0: +react@19.0.0, react@19.1.0, react@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== @@ -14322,6 +14616,15 @@ remark-gfm@^4.0.1: remark-stringify "^11.0.0" unified "^11.0.0" +remark-parse@^10.0.1: + version "10.0.2" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.2.tgz#ca241fde8751c2158933f031a4e3efbaeb8bc262" + integrity sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-from-markdown "^1.0.0" + unified "^10.0.0" + remark-parse@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" @@ -14343,6 +14646,15 @@ remark-rehype@^11.1.1: unified "^11.0.0" vfile "^6.0.0" +remark-stringify@^10.0.2: + version "10.0.3" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-10.0.3.tgz#83b43f2445c4ffbb35b606f967d121b2b6d69717" + integrity sha512-koyOzCMYoUHudypbj4XpnAKFbkddRMYZHwghnxd7ue5210WzGw6kOBwauJTRUMq16jsovXx8dYNvSSWP89kZ3A== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.0.0" + unified "^10.0.0" + remark-stringify@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" @@ -14452,7 +14764,7 @@ rgbcolor@^1.0.1: resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d" integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw== -rimraf@^2.5.4, rimraf@^2.6.3: +rimraf@^2.5.4: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -14544,6 +14856,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +sade@^1.7.3: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-array-concat@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" @@ -14619,6 +14938,11 @@ schema-utils@^4.3.0, schema-utils@^4.3.2: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +secure-json-parse@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" + integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -14820,11 +15144,6 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" -slash@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" - integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -15346,6 +15665,14 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swr@^2.2.5: + version "2.3.3" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.3.3.tgz#9d6a703355f15f9099f45114db3ef75764444788" + integrity sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.4.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -15448,6 +15775,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +throttleit@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-2.1.0.tgz#a7e4aa0bf4845a5bd10daa39ea0c783f631a07b4" + integrity sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw== + through2@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" @@ -15488,13 +15820,6 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.86" -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -15810,6 +16135,19 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" +unified@^10.0.0, unified@^10.1.2: + version "10.1.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" + integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q== + dependencies: + "@types/unist" "^2.0.0" + bail "^2.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^5.0.0" + unified@^11.0.0, unified@^11.0.5: version "11.0.5" resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" @@ -15838,6 +16176,13 @@ unist-util-find-after@^5.0.0: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" +unist-util-is@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.2.1.tgz#b74960e145c18dcb6226bc57933597f5486deae9" + integrity sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" @@ -15852,6 +16197,13 @@ unist-util-position@^5.0.0: dependencies: "@types/unist" "^3.0.0" +unist-util-stringify-position@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d" + integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" @@ -15859,6 +16211,14 @@ unist-util-stringify-position@^4.0.0: dependencies: "@types/unist" "^3.0.0" +unist-util-visit-parents@^5.1.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb" + integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" @@ -15867,6 +16227,15 @@ unist-util-visit-parents@^6.0.0: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" +unist-util-visit@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2" + integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents "^5.1.1" + unist-util-visit@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" @@ -16035,6 +16404,16 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uvu@^0.5.0: + version "0.5.6" + resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" + integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA== + dependencies: + dequal "^2.0.0" + diff "^5.0.0" + kleur "^4.0.3" + sade "^1.7.3" + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -16067,6 +16446,14 @@ vfile-location@^5.0.0: "@types/unist" "^3.0.0" vfile "^6.0.0" +vfile-message@^3.0.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea" + integrity sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position "^3.0.0" + vfile-message@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" @@ -16075,6 +16462,16 @@ vfile-message@^4.0.0: "@types/unist" "^3.0.0" unist-util-stringify-position "^4.0.0" +vfile@^5.0.0: + version "5.3.7" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7" + integrity sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g== + dependencies: + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" + unist-util-stringify-position "^3.0.0" + vfile-message "^3.0.0" + vfile@^6.0.0: version "6.0.3" resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" @@ -16658,11 +17055,6 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yaml@^2.2.2: - version "2.8.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6" - integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -16720,11 +17112,26 @@ yoga-layout@^3.2.1: resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843" integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== +zod-to-json-schema@^3.24.1: + version "3.24.6" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" + integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== + +zod@3.25.28: + version "3.25.28" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.28.tgz#8ab13d04afa05933598fd9fca32490ca92c7ea3a" + integrity sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q== + zustand@5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.5.tgz#3e236f6a953142d975336d179bc735d97db17e84" integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg== +zustand@^5.0.3: + version "5.0.6" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.6.tgz#a2da43d8dc3d31e314279e5baec06297bea70a5c" + integrity sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A== + zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" From dd127235577142d284c4ac64d4e145a7e8a3ae1c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:47:23 +0200 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=94=A7(backend)=20make=20frontend?= =?UTF-8?q?=20ai=20bot=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We make the AI bot configurable with settings. We will be able to have different AI bot name per instance. --- docs/env.md | 119 +++++++++--------- src/backend/core/api/viewsets.py | 2 + src/backend/core/tests/test_api_config.py | 6 +- src/backend/impress/settings.py | 22 ++-- .../apps/e2e/__tests__/app-impress/common.ts | 5 + .../__tests__/app-impress/doc-editor.spec.ts | 12 +- .../impress/src/core/config/api/useConfig.tsx | 2 + .../docs/doc-editor/components/AI/useAI.tsx | 16 ++- .../doc-editor/components/BlockNoteEditor.tsx | 4 +- 9 files changed, 112 insertions(+), 76 deletions(-) diff --git a/docs/env.md b/docs/env.md index ac6e18e24..a7794b860 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,72 +11,73 @@ These are the environment variables you can set for the `impress-backend` contai | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | AI_API_KEY | AI key to be used for AI Base url | | | AI_BASE_URL | OpenAI compatible AI base url | | +| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" } | | AI_FEATURE_ENABLED | Enable AI options | false | | AI_MODEL | AI Model to use | | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | API_USERS_LIST_LIMIT | Limit on API users | 5 | -| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute | -| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour | -| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | | +| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | +| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | +| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | | -| AWS_S3_REGION_NAME | region name for s3 endpoint | | -| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | | -| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage | -| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | +| AWS_S3_REGION_NAME | Region name for s3 endpoint | | +| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | +| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | +| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | -| COLLABORATION_API_URL | collaboration api host | | -| COLLABORATION_SERVER_SECRET | collaboration api secret | | -| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | -| COLLABORATION_WS_URL | collaboration websocket url | | +| COLLABORATION_API_URL | Collaboration api host | | +| COLLABORATION_SERVER_SECRET | Collaboration api secret | | +| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | +| COLLABORATION_WS_URL | Collaboration websocket url | | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown | | CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' | | CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | | -| CRISP_WEBSITE_ID | crisp website id for support | | -| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 | -| DB_HOST | host of the database | localhost | -| DB_NAME | name of the database | impress | -| DB_PASSWORD | password to authenticate with | pass | -| DB_PORT | port of the database | 5432 | -| DB_USER | user to authenticate with | dinum | -| DJANGO_ALLOWED_HOSTS | allowed hosts | [] | -| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} | -| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | -| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false | -| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] | -| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] | +| CRISP_WEBSITE_ID | Crisp website id for support | | +| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | +| DB_HOST | Host of the database | localhost | +| DB_NAME | Name of the database | impress | +| DB_PASSWORD | Password to authenticate with | pass | +| DB_PORT | Port of the database | 5432 | +| DB_USER | User to authenticate with | dinum | +| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | +| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | +| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | +| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | +| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | +| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | -| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend | -| DJANGO_EMAIL_BRAND_NAME | brand name for email | | -| DJANGO_EMAIL_FROM | email address used as sender | from@example.com | -| DJANGO_EMAIL_HOST | host name of email | | -| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | | -| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | | -| DJANGO_EMAIL_LOGO_IMG | logo for the email | | -| DJANGO_EMAIL_PORT | port used to connect to email host | | -| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false | -| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false | -| DJANGO_SECRET_KEY | secret key | | +| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | +| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | +| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | +| DJANGO_EMAIL_HOST | Hostname of email | | +| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | +| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | +| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | +| DJANGO_EMAIL_PORT | Port used to connect to email host | | +| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | +| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | +| DJANGO_SECRET_KEY | Secret key | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | -| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 | +| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | FRONTEND_CSS_URL | To add a external css file to the app | | -| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false | -| FRONTEND_THEME | frontend theme to use | | -| LANGUAGE_CODE | default language | en-us | -| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGIN_REDIRECT_URL | login redirect url | | -| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | | -| LOGOUT_REDIRECT_URL | logout redirect url | | -| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | +| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | +| FRONTEND_THEME | Frontend theme to use | | +| LANGUAGE_CODE | Default language | en-us | +| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGIN_REDIRECT_URL | Login redirect url | | +| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | +| LOGOUT_REDIRECT_URL | Logout redirect url | | +| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | MEDIA_BASE_URL | | | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | -| OIDC_CREATE_USER | create used on OIDC | false | -| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | +| OIDC_CREATE_USER | Create used on OIDC | false | +| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | @@ -84,24 +85,24 @@ These are the environment variables you can set for the `impress-backend` contai | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | -| OIDC_RP_CLIENT_ID | client id used for OIDC | impress | -| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | | -| OIDC_RP_SCOPES | scopes requested for OIDC | openid email | -| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | +| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | +| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | +| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | +| OIDC_RP_SIGN_ALGO | Verification algorithm used OIDC tokens | RS256 | | OIDC_STORE_ID_TOKEN | Store OIDC token | true | -| OIDC_USE_NONCE | use nonce for OIDC | true | +| OIDC_USE_NONCE | Use nonce for OIDC | true | | OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | -| POSTHOG_KEY | posthog key for analytics | | -| REDIS_URL | cache url | redis://redis:6379/1 | -| SENTRY_DSN | sentry host | | -| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | +| POSTHOG_KEY | Posthog key for analytics | | +| REDIS_URL | Cache url | redis://redis:6379/1 | +| SENTRY_DSN | Sentry host | | +| SESSION_COOKIE_AGE | Duration of the cookie session | 60*60*12 | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | -| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | -| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 | -| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | +| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | +| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | | Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 4bba622b5..b8f403084 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1875,7 +1875,9 @@ def get(self, request): Return a dictionary of public settings. """ array_settings = [ + "AI_BOT", "AI_FEATURE_ENABLED", + "AI_MODEL", "COLLABORATION_WS_URL", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", "CRISP_WEBSITE_ID", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index cdc4a9cb2..a03711c9c 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -18,7 +18,9 @@ @override_settings( + AI_BOT={"name": "Test Bot", "color": "#000000"}, AI_FEATURE_ENABLED=False, + AI_MODEL="test-model", COLLABORATION_WS_URL="http://testcollab/", COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True, CRISP_WEBSITE_ID="123", @@ -41,6 +43,9 @@ def test_api_config(is_authenticated): response = client.get("/api/v1.0/config/") assert response.status_code == HTTP_200_OK assert response.json() == { + "AI_BOT": {"name": "Test Bot", "color": "#000000"}, + "AI_FEATURE_ENABLED": False, + "AI_MODEL": "test-model", "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "CRISP_WEBSITE_ID": "123", @@ -59,7 +64,6 @@ def test_api_config(is_authenticated): "MEDIA_BASE_URL": "http://testserver/", "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "SENTRY_DSN": "https://sentry.test/123", - "AI_FEATURE_ENABLED": False, "theme_customization": {}, } policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index dd1d3acc3..3646e609c 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -603,24 +603,32 @@ class Base(Configuration): default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None ) - # AI service - AI_FEATURE_ENABLED = values.BooleanValue( - default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None - ) - AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None) - AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) - AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + # AI settings AI_ALLOW_REACH_FROM = values.Value( choices=("public", "authenticated", "restricted"), default="authenticated", environ_name="AI_ALLOW_REACH_FROM", environ_prefix=None, ) + AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None) + AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) + AI_BOT = values.DictValue( + default={ + "name": _("Docs AI"), + "color": "#8bc6ff", + }, + environ_name="AI_BOT", + environ_prefix=None, + ) AI_DOCUMENT_RATE_THROTTLE_RATES = { "minute": 5, "hour": 100, "day": 500, } + AI_FEATURE_ENABLED = values.BooleanValue( + default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None + ) + AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) AI_USER_RATE_THROTTLE_RATES = { "minute": 3, "hour": 50, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index c91c42d78..4db868a56 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -3,7 +3,12 @@ import { Page, expect } from '@playwright/test'; export const BROWSERS = ['chromium', 'webkit', 'firefox']; export const CONFIG = { + AI_BOT: { + name: 'Docs AI', + color: '#8bc6ff', + }, AI_FEATURE_ENABLED: true, + AI_MODEL: 'llama', CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false, 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 3a1d3fe3b..f15acd512 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 @@ -9,6 +9,7 @@ import { createDoc, goToGridDoc, mockedDocument, + overrideConfig, verifyDocName, } from './common'; @@ -286,6 +287,15 @@ test.describe('Doc Editor', () => { }); test('it checks the AI feature', async ({ page, browserName }) => { + await overrideConfig(page, { + AI_BOT: { + name: 'Albert AI', + color: '#8bc6ff', + }, + }); + + await page.goto('/'); + await page.route(/.*\/ai-proxy\//, async (route) => { const request = route.request(); if (request.method().includes('POST')) { @@ -370,7 +380,7 @@ test.describe('Doc Editor', () => { await page.getByRole('option', { name: 'Translate' }).click(); await page.getByPlaceholder('Ask AI anything…').fill('French'); await page.getByPlaceholder('Ask AI anything…').press('Enter'); - await expect(editor.getByText('Docs AI')).toBeVisible(); + await expect(editor.getByText('Albert AI')).toBeVisible(); await page .locator('p.bn-mt-suggestion-menu-item-title') .getByText('Accept') diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 584500dea..05925bb0f 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -12,7 +12,9 @@ interface ThemeCustomization { } export interface ConfigResponse { + AI_BOT: { name: string; color: string }; AI_FEATURE_ENABLED?: boolean; + AI_MODEL?: string; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; CRISP_WEBSITE_ID?: string; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index b0f791e29..691c90398 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -3,6 +3,7 @@ import { createAIExtension, createBlockNoteAIClient } from '@blocknote/xl-ai'; import { useMemo } from 'react'; import { fetchAPI } from '@/api'; +import { useConfig } from '@/core'; import { Doc } from '@/docs/doc-management'; const client = createBlockNoteAIClient({ @@ -17,7 +18,13 @@ const client = createBlockNoteAIClient({ * Custom prompts can be invoked using the pattern !promptName in the AI input field. */ export const useAI = (docId: Doc['id']) => { + const conf = useConfig().data; + return useMemo(() => { + if (!conf?.AI_MODEL) { + return null; + } + const openai = createOpenAI({ ...client.getProviderSettings('openai'), fetch: (input, init) => { @@ -31,17 +38,14 @@ export const useAI = (docId: Doc['id']) => { }); }, }); - const model = openai.chat('neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8'); + const model = openai.chat(conf.AI_MODEL); const extension = createAIExtension({ stream: false, model, - agentCursor: { - name: 'Albert', - color: '#8bc6ff', - }, + agentCursor: conf?.AI_BOT, }); return extension; - }, [docId]); + }, [docId, conf?.AI_BOT, conf?.AI_MODEL]); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 6a56df2b6..6cc381699 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -121,7 +121,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { showCursorLabels: showCursorLabels as 'always' | 'activity', }, dictionary: { ...locales[lang as keyof typeof locales], ai: aiEn }, - extensions: [aiExtension], + extensions: aiExtension ? [aiExtension] : [], tables: { splitCells: true, cellBackgroundColor: true, @@ -169,7 +169,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > - + {aiExtension && } From 06e94c5bf674f4657730a59e7dd7beeabb2cd9fc Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:24:43 +0200 Subject: [PATCH 04/16] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20improve=20?= =?UTF-8?q?prompt=20of=20some=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some answers were a bit too concise or not detailed enough. Improve some prompts to get better answers from the AI. --- .../docs/doc-editor/components/AI/useAI.tsx | 23 +-- .../doc-editor/components/AI/usePromptAI.tsx | 158 ++++++++++++++++++ .../apps/impress/src/i18n/translations.json | 10 ++ 3 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index 691c90398..bac94447d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -1,32 +1,24 @@ import { createOpenAI } from '@ai-sdk/openai'; -import { createAIExtension, createBlockNoteAIClient } from '@blocknote/xl-ai'; +import { createAIExtension, llmFormats } from '@blocknote/xl-ai'; import { useMemo } from 'react'; import { fetchAPI } from '@/api'; import { useConfig } from '@/core'; import { Doc } from '@/docs/doc-management'; -const client = createBlockNoteAIClient({ - baseURL: ``, - apiKey: '', -}); - -/** - * Custom implementation of the PromptBuilder that allows for using predefined prompts. - * - * This extends the default HTML promptBuilder from BlockNote to support custom prompt templates. - * Custom prompts can be invoked using the pattern !promptName in the AI input field. - */ +import { usePromptAI } from './usePromptAI'; + export const useAI = (docId: Doc['id']) => { const conf = useConfig().data; + const promptBuilder = usePromptAI(); return useMemo(() => { if (!conf?.AI_MODEL) { - return null; + return; } const openai = createOpenAI({ - ...client.getProviderSettings('openai'), + apiKey: '', // The API key will be set by the AI proxy fetch: (input, init) => { // Create a new headers object without the Authorization header const headers = new Headers(init?.headers); @@ -44,8 +36,9 @@ export const useAI = (docId: Doc['id']) => { stream: false, model, agentCursor: conf?.AI_BOT, + promptBuilder: promptBuilder(llmFormats.html.defaultPromptBuilder), }); return extension; - }, [docId, conf?.AI_BOT, conf?.AI_MODEL]); + }, [conf, docId, promptBuilder]); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx new file mode 100644 index 000000000..f3a17fbb6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx @@ -0,0 +1,158 @@ +import { Block } from '@blocknote/core'; +import { CoreMessage } from 'ai'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DocsBlockNoteEditor } from '../../types'; + +export type PromptBuilderInput = { + userPrompt: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedBlocks?: Block[]; + excludeBlockIds?: string[]; + previousMessages?: Array; +}; + +type PromptBuilder = ( + editor: DocsBlockNoteEditor, + opts: PromptBuilderInput, +) => Promise>; + +/** + * Custom implementation of the PromptBuilder that allows for using predefined prompts. + * + * This extends the default HTML promptBuilder from BlockNote to support custom prompt templates. + * Custom prompts can be invoked using the pattern !promptName in the AI input field. + */ +export const usePromptAI = () => { + const { t } = useTranslation(); + + return useCallback( + (defaultPromptBuilder: PromptBuilder) => + async ( + editor: DocsBlockNoteEditor, + opts: PromptBuilderInput, + ): Promise> => { + const systemPrompts: Record< + | 'add-edit-instruction' + | 'add-formatting' + | 'add-markdown' + | 'assistant' + | 'language' + | 'referenceId', + CoreMessage + > = { + assistant: { + role: 'system', + content: t(`You are an AI assistant that edits user documents.`), + }, + referenceId: { + role: 'system', + content: t( + `Keep block IDs exactly as provided when referencing them (including the trailing "$").`, + ), + }, + 'add-markdown': { + role: 'system', + content: t(`Answer the user prompt in markdown format.`), + }, + 'add-formatting': { + role: 'system', + content: t(`Add formatting to the text to make it more readable.`), + }, + 'add-edit-instruction': { + role: 'system', + content: t( + `Add content; do not delete or alter existing blocks unless explicitly told.`, + ), + }, + language: { + role: 'system', + content: t( + `Detect the dominant language inside the provided blocks. YOU MUST PROVIDE A ANSWER IN THE DETECTED LANGUAGE.`, + ), + }, + }; + + const userPrompts: Record = { + 'continue writing': t( + 'Keep writing about the content send in the prompt, expanding on the ideas.', + ), + 'improve writing': t( + 'Improve the writing of the selected text. Make it more professional and clear.', + ), + summarize: t('Summarize the document into a concise paragraph.'), + 'fix spelling': t( + 'Fix the spelling and grammar mistakes in the selected text.', + ), + }; + + // Modify userPrompt if it matches a custom prompt + const customPromptMatch = opts.userPrompt.match(/^([^:]+)(?=[:]|$)/); + let modifiedOpts = opts; + const promptKey = customPromptMatch?.[0].trim().toLowerCase(); + if (promptKey) { + if (userPrompts[promptKey]) { + modifiedOpts = { + ...opts, + userPrompt: userPrompts[promptKey], + }; + } + } + + let prompts = await defaultPromptBuilder(editor, modifiedOpts); + const isTransformExistingContent = !!opts.selectedBlocks?.length; + if (!isTransformExistingContent) { + prompts = prompts.map((prompt) => { + if (!prompt.content || typeof prompt.content !== 'string') { + return prompt; + } + + /** + * Fix a bug when the initial content is empty + * TODO: Remove this when the bug is fixed in BlockNote + */ + if (prompt.content === '[]') { + const lastBlockId = + editor.document[editor.document.length - 1].id; + + prompt.content = `[{\"id\":\"${lastBlockId}$\",\"block\":\"

\"}]`; + return prompt; + } + + if ( + prompt.content.includes( + "You're manipulating a text document using HTML blocks.", + ) + ) { + prompt = systemPrompts['add-markdown']; + return prompt; + } + + if ( + prompt.content.includes( + 'First, determine what part of the document the user is talking about.', + ) + ) { + prompt = systemPrompts['add-edit-instruction']; + } + + return prompt; + }); + + prompts.push(systemPrompts['add-formatting']); + } + + prompts.unshift(systemPrompts['assistant']); + prompts.push(systemPrompts['referenceId']); + + // Try to keep the language of the document except when we are translating + if (!promptKey?.includes('Translate into')) { + prompts.push(systemPrompts['language']); + } + + return prompts; + }, + [t], + ); +}; diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 24985d1b2..153417020 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -590,6 +590,16 @@ "Warning": "Attention", "Why can't I edit?": "Pourquoi ne puis-je pas éditer ?", "Write": "Écrire", + "You are an AI assistant that helps users to edit their documents.": "Vous êtes un assistant IA qui aide les utilisateurs à éditer leurs documents.", + "Answer the user prompt in markdown format.": "Répondez à la demande de l'utilisateur au format markdown.", + "Add formatting to the text to make it more readable.": "Ajoutez du formatage au texte pour le rendre plus lisible.", + "Keep adding to the document, do not delete or modify existing blocks.": "Continuez à ajouter au document, ne supprimez ni ne modifiez les blocs existants.", + "Your answer must be in the same language as the document.": "Votre réponse doit être dans la même langue que le document.", + "Fix the spelling and grammar mistakes in the selected text.": "Corrigez les fautes d'orthographe et de grammaire dans le texte sélectionné.", + "Improve the writing of the selected text. Make it more professional and clear.": "Améliorez l'écriture du texte sélectionné. Rendez-le plus professionnel et clair.", + "Summarize the document into a concise paragraph.": "Résumez le document en un paragraphe concis.", + "Keep writing about the content send in the prompt, expanding on the ideas.": "Continuez à écrire sur le contenu envoyé dans la demande, en développant les idées.", + "Important, verified the language of the document! Your answer MUST be in the same language as the document. If the document is in English, your answer MUST be in English. If the document is in Spanish, your answer MUST be in Spanish, etc.": "Important, vérifiez la langue du document ! Votre réponse DOIT être dans la même langue que le document. Si le document est en anglais, votre réponse DOIT être en anglais. Si le document est en espagnol, votre réponse DOIT être en espagnol, etc.", "You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.", "You do not have permission to view this document.": "Vous n'avez pas la permission de voir ce document.", "You do not have permission to view users sharing this document or modify link settings.": "Vous n'avez pas la permission de voir les utilisateurs partageant ce document ou de modifier les paramètres du lien.", From ec18155b33fd158e0a7eace53ddfed1b077628dc Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:55:27 +0200 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=94=A5(project)=20remove=20previous?= =?UTF-8?q?=20AI=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 | 73 ---- .../test_api_documents_ai_transform.py | 356 ---------------- .../test_api_documents_ai_translate.py | 384 ------------------ .../documents/test_api_documents_retrieve.py | 10 - .../documents/test_api_documents_trashbin.py | 2 - .../core/tests/test_models_documents.py | 20 - .../core/tests/test_services_ai_services.py | 61 +-- .../__tests__/app-impress/doc-editor.spec.ts | 137 ------- .../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 - .../servers/y-provider/src/api/getDoc.ts | 2 - 18 files changed, 35 insertions(+), 1569 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 7fa25282e..beb2a6ab5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -13,7 +13,6 @@ from rest_framework import exceptions, serializers from core import enums, models, utils -from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, YdocConverter, @@ -718,36 +717,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 b8f403084..0d1578723 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -425,7 +425,6 @@ class DocumentViewSet( ] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer children_serializer_class = serializers.ListDocumentSerializer descendants_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer @@ -1365,62 +1364,6 @@ def ai_proxy(self, request, *args, **kwargs): 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) - @drf.decorators.action( detail=True, methods=["get"], diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index d7245d4f1..e537910d3 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -7,53 +7,8 @@ from openai import OpenAI -from core import enums - log = logging.getLogger(__name__) -AI_ACTIONS = { - "prompt": ( - "Answer the prompt in markdown format. " - "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.""" @@ -68,34 +23,6 @@ def __init__(self): 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 81b691745..000000000 --- a/src/backend/core/tests/documents/test_api_documents_ai_transform.py +++ /dev/null @@ -1,356 +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 in markdown format. 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 in markdown format. 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 f0d7978c2..000000000 --- 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 f686cb06b..41e71a12b 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -29,8 +29,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", "children_create": False, "children_list": True, @@ -98,8 +96,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", "children_create": False, "children_list": True, @@ -196,8 +192,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", "children_create": document.link_role == "editor", "children_list": True, @@ -272,8 +266,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", "children_create": grand_parent.link_role == "editor", "children_list": True, @@ -454,8 +446,6 @@ def test_api_documents_retrieve_authenticated_related_parent(): "accesses_manage": access.role in ["administrator", "owner"], "accesses_view": True, "ai_proxy": access.role != "reader", - "ai_transform": access.role != "reader", - "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", "children_create": access.role != "reader", "children_list": True, 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 c7037a443..1d34f9b29 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": 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/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index b6db6777b..9046776a0 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -153,8 +153,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, "children_create": False, "children_list": False, @@ -215,8 +213,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, "children_create": False, "children_list": True, @@ -279,8 +275,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, "children_create": is_authenticated, "children_list": True, @@ -332,8 +326,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, "children_create": True, "children_list": True, @@ -382,8 +374,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, "children_create": True, "children_list": True, @@ -435,8 +425,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, "children_create": True, "children_list": True, @@ -495,8 +483,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, "children_create": access_from_link, "children_list": True, @@ -553,8 +539,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, "children_create": False, "children_list": True, @@ -601,8 +585,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") @@ -621,8 +603,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 ffa5c170a..f6fa3703e 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"))] - ) - - response = AIService().transform("hello", "prompt") - - assert response == {"answer": "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().proxy({"messages": [{"role": "user", "content": "hello"}]}) + + 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 f15acd512..76ae6fded 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 @@ -393,143 +393,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(); - - /* eslint-disable playwright/no-conditional-expect */ - /* eslint-disable playwright/no-conditional-in-test */ - 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(); - } - /* eslint-enable playwright/no-conditional-expect */ - /* eslint-enable playwright/no-conditional-in-test */ - }); - }); - 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 040f6c7c3..5157ad455 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 cd8dfbfc1..000000000 --- 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 504d79b3e..000000000 --- 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 45bd1ed49..000000000 --- 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 '@openfun/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 AIMenuItemTransform { - 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 AIMenuItemTranslate { - 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 db61cdda5..83499b52a 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,10 +10,9 @@ import { useTranslation } from 'react-i18next'; import { useConfig } from '@/core/config/api'; -import { AIToolbarButton } from '../AI/AIToolbarButton'; +import { AIToolbarButton } from '../AI'; import { getCalloutFormattingToolbarItems } from '../custom-blocks'; -import { AIGroupButton } from './AIButton'; import { FileDownloadButton } from './FileDownloadButton'; import { MarkdownButton } from './MarkdownButton'; import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe'; @@ -61,9 +60,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 9c2b6e9c3..fc06f311c 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 @@ -50,8 +50,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 94473ce73..7cd9d3686 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 @@ -180,8 +180,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/getDoc.ts b/src/frontend/servers/y-provider/src/api/getDoc.ts index 9da249b03..155d89650 100644 --- a/src/frontend/servers/y-provider/src/api/getDoc.ts +++ b/src/frontend/servers/y-provider/src/api/getDoc.ts @@ -33,8 +33,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; From ced9fa076c4e69e37dd5a5ffb832a48eb1dc6aa8 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 10 Jun 2025 12:36:06 +0200 Subject: [PATCH 06/16] =?UTF-8?q?=F0=9F=93=84(frontend)=20remove=20AI=20fe?= =?UTF-8?q?ature=20when=20MIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI feature is under AGPL license, so it is removed when the project is under MIT license. NEXT_PUBLIC_PUBLISH_AS_MIT manage this. --- .../__tests__/app-impress/language.spec.ts | 2 +- .../docs/doc-editor/components/AI/AIUI.tsx | 1 + .../docs/doc-editor/components/AI/index.ts | 27 +++++++++++++++++-- .../doc-editor/components/BlockNoteEditor.tsx | 26 +++++++++++------- .../components/BlockNoteSuggestionMenu.tsx | 8 ++++-- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 6 +++-- 6 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 28a07f8fd..721298849 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -77,7 +77,7 @@ test.describe.serial('Language', () => { page, browserName, }) => { - await createDoc(page, 'doc-toolbar', browserName, 1); + await createDoc(page, 'doc-translations-slash', browserName, 1); const editor = page.locator('.ProseMirror'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx index cea52921a..8d814961d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -4,6 +4,7 @@ import { getAIExtension, getDefaultAIMenuItems, } from '@blocknote/xl-ai'; +import '@blocknote/xl-ai/style.css'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts index fe820022c..c50a690b4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts @@ -1,2 +1,25 @@ -export * from './AIUI'; -export * from './useAI'; +/** + * To import AI modules you must import from the index file. + * This is to ensure that the AI modules are only loaded when + * the application is not published as MIT. + */ +import * as XLAI from '@blocknote/xl-ai'; +import * as localesAI from '@blocknote/xl-ai/locales'; + +import * as AIUI from './AIUI'; +import * as useAI from './useAI'; + +let modulesAI = undefined; +if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { + modulesAI = { + ...XLAI, + ...AIUI, + localesAI: localesAI, + ...useAI, + }; +} + +type ModulesAI = typeof XLAI & + typeof AIUI & { localesAI: typeof localesAI } & typeof useAI; + +export default modulesAI as ModulesAI; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 6cc381699..6bf90ede2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -9,9 +9,6 @@ import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; -import { AIMenuController } from '@blocknote/xl-ai'; -import { en as aiEn } from '@blocknote/xl-ai/locales'; -import '@blocknote/xl-ai/style.css'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,11 +25,16 @@ import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; -import { AIMenu, useAI } from './AI'; +import BlockNoteAI from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, DividerBlock } from './custom-blocks'; +const AIMenu = BlockNoteAI?.AIMenu; +const AIMenuController = BlockNoteAI?.AIMenuController; +const useAI = BlockNoteAI?.useAI; +const localesAI = BlockNoteAI?.localesAI; + export const blockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { @@ -51,17 +53,16 @@ interface BlockNoteEditorProps { export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); - const { t } = useTranslation(); const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; useSaveDoc(doc.id, provider.document, !readOnly); - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); const lang = i18n.resolvedLanguage; const { uploadFile, errorAttachment } = useUploadFile(doc.id); - const aiExtension = useAI(doc.id); + const aiExtension = useAI?.(doc.id); const collabName = readOnly ? 'Reader' @@ -120,7 +121,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, - dictionary: { ...locales[lang as keyof typeof locales], ai: aiEn }, + dictionary: { + ...locales[lang as keyof typeof locales], + ai: localesAI?.[lang as keyof typeof localesAI], + }, extensions: aiExtension ? [aiExtension] : [], tables: { splitCells: true, @@ -131,7 +135,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [aiExtension, collabName, lang, provider, uploadFile], ); useHeadings(editor); @@ -169,7 +173,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > - {aiExtension && } + {aiExtension && AIMenuController && AIMenu && ( + + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index d796dbd8d..17098449a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -6,7 +6,6 @@ import { useBlockNoteEditor, useDictionary, } from '@blocknote/react'; -import { getAISlashMenuItems } from '@blocknote/xl-ai'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,11 +13,14 @@ import { useConfig } from '@/core'; import { DocsBlockSchema } from '../types'; +import BlockNoteAI from './AI'; import { getCalloutReactSlashMenuItems, getDividerReactSlashMenuItems, } from './custom-blocks'; +const getAISlashMenuItems = BlockNoteAI?.getAISlashMenuItems; + export const BlockNoteSuggestionMenu = () => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); @@ -34,7 +36,9 @@ export const BlockNoteSuggestionMenu = () => { getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), - conf?.AI_FEATURE_ENABLED ? getAISlashMenuItems(editor) : [], + conf?.AI_FEATURE_ENABLED && getAISlashMenuItems + ? getAISlashMenuItems(editor) + : [], ), query, ), 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 83499b52a..49bb9b9a4 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,13 +10,15 @@ import { useTranslation } from 'react-i18next'; import { useConfig } from '@/core/config/api'; -import { AIToolbarButton } from '../AI'; +import BlockNoteAI from '../AI'; import { getCalloutFormattingToolbarItems } from '../custom-blocks'; import { FileDownloadButton } from './FileDownloadButton'; import { MarkdownButton } from './MarkdownButton'; import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe'; +const AIToolbarButton = BlockNoteAI?.AIToolbarButton; + export const BlockNoteToolbar = () => { const dict = useDictionary(); const [confirmOpen, setIsConfirmOpen] = useState(false); @@ -56,7 +58,7 @@ export const BlockNoteToolbar = () => { const formattingToolbar = useCallback(() => { return ( - {conf?.AI_FEATURE_ENABLED && } + {conf?.AI_FEATURE_ENABLED && AIToolbarButton && } {toolbarItems} From 75b4e914dd71a3ef196391446c41b71c153cd823 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 10 Jun 2025 16:16:01 +0200 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=9B=82(frontend)=20bind=20ai=5Fprox?= =?UTF-8?q?y=20abilities=20with=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind ai_proxy abilities to the AI feature. If ai_proxy is false, the AI feature will not be available. --- .../__tests__/app-impress/doc-editor.spec.ts | 47 +++++++++++++++++++ .../docs/doc-editor/components/AI/useAI.tsx | 6 +-- .../doc-editor/components/BlockNoteEditor.tsx | 11 +++-- .../components/BlockNoteSuggestionMenu.tsx | 15 +++--- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 9 ++-- 5 files changed, 67 insertions(+), 21 deletions(-) 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 76ae6fded..6fad2f879 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 @@ -393,6 +393,53 @@ test.describe('Doc Editor', () => { await expect(page.getByText('Write with AI')).toBeVisible(); }); + test(`it checks ai_proxy ability`, 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_proxy: false, + 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-proxy', + 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(); + + await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); + await page.locator('.bn-block-outer').last().fill('/'); + await expect(page.getByText('Write with AI')).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/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index bac94447d..d9200f4b2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -8,12 +8,12 @@ import { Doc } from '@/docs/doc-management'; import { usePromptAI } from './usePromptAI'; -export const useAI = (docId: Doc['id']) => { +export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { const conf = useConfig().data; const promptBuilder = usePromptAI(); return useMemo(() => { - if (!conf?.AI_MODEL) { + if (!aiAllowed || !conf?.AI_MODEL) { return; } @@ -40,5 +40,5 @@ export const useAI = (docId: Doc['id']) => { }); return extension; - }, [conf, docId, promptBuilder]); + }, [aiAllowed, conf, docId, promptBuilder]); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 6bf90ede2..243b44f99 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; +import { useConfig } from '@/core'; import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; @@ -62,7 +63,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const lang = i18n.resolvedLanguage; const { uploadFile, errorAttachment } = useUploadFile(doc.id); - const aiExtension = useAI?.(doc.id); + const conf = useConfig().data; + const aiAllowed = !!(conf?.AI_FEATURE_ENABLED && doc.abilities?.ai_proxy); + const aiExtension = useAI?.(doc.id, aiAllowed); const collabName = readOnly ? 'Reader' @@ -173,11 +176,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > - {aiExtension && AIMenuController && AIMenu && ( + {aiAllowed && AIMenuController && AIMenu && ( )} - - + +
); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 17098449a..fe2f50842 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -9,8 +9,6 @@ import { import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core'; - import { DocsBlockSchema } from '../types'; import BlockNoteAI from './AI'; @@ -21,11 +19,14 @@ import { const getAISlashMenuItems = BlockNoteAI?.getAISlashMenuItems; -export const BlockNoteSuggestionMenu = () => { +export const BlockNoteSuggestionMenu = ({ + aiAllowed, +}: { + aiAllowed: boolean; +}) => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; - const { data: conf } = useConfig(); const getSlashMenuItems = useMemo(() => { return async (query: string) => @@ -36,14 +37,12 @@ export const BlockNoteSuggestionMenu = () => { getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), - conf?.AI_FEATURE_ENABLED && getAISlashMenuItems - ? getAISlashMenuItems(editor) - : [], + aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [], ), query, ), ); - }, [basicBlocksName, editor, t, conf?.AI_FEATURE_ENABLED]); + }, [basicBlocksName, editor, t, aiAllowed]); return ( { +export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => { const dict = useDictionary(); const [confirmOpen, setIsConfirmOpen] = useState(false); const [onConfirm, setOnConfirm] = useState<() => void | Promise>(); const { t } = useTranslation(); - const { data: conf } = useConfig(); const toolbarItems = useMemo(() => { const toolbarItems = getFormattingToolbarItems([ @@ -58,7 +55,7 @@ export const BlockNoteToolbar = () => { const formattingToolbar = useCallback(() => { return ( - {conf?.AI_FEATURE_ENABLED && AIToolbarButton && } + {aiAllowed && AIToolbarButton && } {toolbarItems} @@ -66,7 +63,7 @@ export const BlockNoteToolbar = () => { ); - }, [toolbarItems, conf?.AI_FEATURE_ENABLED]); + }, [toolbarItems, aiAllowed]); return ( <> From 86d28665b7fd421da587b6e9d4da3d2506b30797 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 5 Jun 2025 16:57:36 +0200 Subject: [PATCH 08/16] test-instance --- .github/workflows/docker-hub.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 5971fcfa7..a55472cc0 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'refacto/blocknote-ai' tags: - 'v*' pull_request: From 99c4f52fa1b68f84b40aef4d07e009e6ff96dc57 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 12 Jun 2025 11:08:23 +0200 Subject: [PATCH 09/16] =?UTF-8?q?=E2=9C=A8(back)=20manage=20streaming=20wi?= =?UTF-8?q?th=20the=20ai=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to handle both streaming or not when interacting with the AI backend service. --- src/backend/core/api/viewsets.py | 17 +++- src/backend/core/services/ai_services.py | 15 +++- src/backend/core/tests/test_api_config.py | 2 + .../core/tests/test_services_ai_services.py | 84 ++++++++++++++----- src/backend/impress/settings.py | 3 + .../apps/e2e/__tests__/app-impress/common.ts | 1 + .../impress/src/core/config/api/useConfig.tsx | 1 + .../docs/doc-editor/components/AI/useAI.tsx | 2 +- 8 files changed, 98 insertions(+), 27 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0d1578723..37d572f7d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1361,8 +1361,20 @@ def ai_proxy(self, request, *args, **kwargs): serializer = serializers.AIProxySerializer(data=request.data) serializer.is_valid(raise_exception=True) - response = AIService().proxy(request.data) - return drf.response.Response(response, status=drf.status.HTTP_200_OK) + ai_service = AIService() + + if settings.AI_STREAM: + return StreamingHttpResponse( + ai_service.stream(request.data), + content_type="text/event-stream", + status=drf.status.HTTP_200_OK, + ) + + ai_response = ai_service.proxy(request.data) + return drf.response.Response( + ai_response.model_dump(), + status=drf.status.HTTP_200_OK, + ) @drf.decorators.action( detail=True, @@ -1821,6 +1833,7 @@ def get(self, request): "AI_BOT", "AI_FEATURE_ENABLED", "AI_MODEL", + "AI_STREAM", "COLLABORATION_WS_URL", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", "CRISP_WEBSITE_ID", diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index e537910d3..1671ee7ce 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -1,6 +1,7 @@ """AI services.""" import logging +from typing import Generator from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -23,9 +24,15 @@ def __init__(self): raise ImproperlyConfigured("AI configuration not set") self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY) - def proxy(self, data: dict) -> dict: + def proxy(self, data: dict, stream: bool = False) -> Generator[str, None, None]: """Proxy AI API requests to the configured AI provider.""" - data["stream"] = False + data["stream"] = stream + return self.client.chat.completions.create(**data) - response = self.client.chat.completions.create(**data) - return response.model_dump() + def stream(self, data: dict) -> Generator[str, None, None]: + """Stream AI API requests to the configured AI provider.""" + stream = self.proxy(data, stream=True) + for chunk in stream: + yield f"data: {chunk.model_dump_json()}\n\n" + + yield "data: [DONE]\n\n" diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index a03711c9c..6f85f5739 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -21,6 +21,7 @@ AI_BOT={"name": "Test Bot", "color": "#000000"}, AI_FEATURE_ENABLED=False, AI_MODEL="test-model", + AI_STREAM=False, COLLABORATION_WS_URL="http://testcollab/", COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True, CRISP_WEBSITE_ID="123", @@ -46,6 +47,7 @@ def test_api_config(is_authenticated): "AI_BOT": {"name": "Test Bot", "color": "#000000"}, "AI_FEATURE_ENABLED": False, "AI_MODEL": "test-model", + "AI_STREAM": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "CRISP_WEBSITE_ID": "123", diff --git a/src/backend/core/tests/test_services_ai_services.py b/src/backend/core/tests/test_services_ai_services.py index f6fa3703e..5818dc326 100644 --- a/src/backend/core/tests/test_services_ai_services.py +++ b/src/backend/core/tests/test_services_ai_services.py @@ -2,10 +2,9 @@ Test ai API endpoints in the impress core app. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from django.core.exceptions import ImproperlyConfigured -from django.test.utils import override_settings import pytest from openai import OpenAIError @@ -15,6 +14,15 @@ pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def ai_settings(settings): + """Fixture to set AI settings.""" + settings.AI_MODEL = "llama" + settings.AI_BASE_URL = "http://example.com" + settings.AI_API_KEY = "test-key" + settings.AI_FEATURE_ENABLED = True + + @pytest.mark.parametrize( "setting_name, setting_value", [ @@ -23,22 +31,19 @@ ("AI_MODEL", None), ], ) -def test_api_ai_setting_missing(setting_name, setting_value): +def test_services_ai_setting_missing(setting_name, setting_value, settings): """Setting should be set""" + setattr(settings, setting_name, setting_value) - with override_settings(**{setting_name: setting_value}): - with pytest.raises( - ImproperlyConfigured, - match="AI configuration not set", - ): - AIService() + with pytest.raises( + ImproperlyConfigured, + match="AI configuration not set", + ): + AIService() -@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_error(mock_create): +def test_services_ai_proxy_client_error(mock_create): """Fail when the client raises an error""" mock_create.side_effect = OpenAIError("Mocked client error") @@ -50,15 +55,11 @@ def test_api_ai__client_error(mock_create): AIService().proxy({"messages": [{"role": "user", "content": "hello"}]}) -@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__success(mock_create): +def test_services_ai_proxy_success(mock_create): """The AI request should work as expect when called with valid arguments.""" - mock_response = MagicMock() - mock_response.model_dump.return_value = { + mock_create.return_value = { "id": "chatcmpl-test", "object": "chat.completion", "created": 1234567890, @@ -71,7 +72,6 @@ def test_api_ai__success(mock_create): } ], } - mock_create.return_value = mock_response response = AIService().proxy({"messages": [{"role": "user", "content": "hello"}]}) @@ -89,3 +89,47 @@ def test_api_ai__success(mock_create): ], } assert response == expected_response + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "hello"}], stream=False + ) + + +@patch("openai.resources.chat.completions.Completions.create") +def test_services_ai_proxy_with_stream(mock_create): + """The AI request should work as expect when called with valid arguments.""" + + mock_create.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Salut"}, + "finish_reason": "stop", + } + ], + } + + response = AIService().proxy( + {"messages": [{"role": "user", "content": "hello"}]}, stream=True + ) + + 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 + mock_create.assert_called_once_with( + messages=[{"role": "user", "content": "hello"}], stream=True + ) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 3646e609c..9d364193f 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -629,6 +629,9 @@ class Base(Configuration): default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None ) AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + AI_STREAM = values.BooleanValue( + default=False, environ_name="AI_STREAM", environ_prefix=None + ) AI_USER_RATE_THROTTLE_RATES = { "minute": 3, "hour": 50, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 4db868a56..ff50bdc28 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -9,6 +9,7 @@ export const CONFIG = { }, AI_FEATURE_ENABLED: true, AI_MODEL: 'llama', + AI_STREAM: false, CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 05925bb0f..66074e10c 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -15,6 +15,7 @@ export interface ConfigResponse { AI_BOT: { name: string; color: string }; AI_FEATURE_ENABLED?: boolean; AI_MODEL?: string; + AI_STREAM: boolean; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; CRISP_WEBSITE_ID?: string; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index d9200f4b2..2d20ad3bf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -33,7 +33,7 @@ export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { const model = openai.chat(conf.AI_MODEL); const extension = createAIExtension({ - stream: false, + stream: conf.AI_STREAM, model, agentCursor: conf?.AI_BOT, promptBuilder: promptBuilder(llmFormats.html.defaultPromptBuilder), From 7a3d154653d99603d0a8c94b5ab66accb020f28f Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 1 Jul 2025 11:01:52 +0200 Subject: [PATCH 10/16] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20integrate?= =?UTF-8?q?=20new=20Blocknote=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/__tests__/app-impress/doc-editor.spec.ts | 4 ++++ src/frontend/apps/impress/package.json | 2 +- .../docs/doc-editor/components/AI/useAI.tsx | 12 +++++++----- .../features/docs/doc-editor/hook/useSaveDoc.tsx | 13 ++++++++++--- src/frontend/yarn.lock | 8 ++++---- 5 files changed, 26 insertions(+), 13 deletions(-) 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 6fad2f879..94706699f 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 @@ -391,6 +391,10 @@ test.describe('Doc Editor', () => { // Check Suggestion menu await page.locator('.bn-block-outer').last().fill('/'); await expect(page.getByText('Write with AI')).toBeVisible(); + + // Reload the page to check that the AI change is still there + await page.goto(page.url()); + await expect(editor.getByText('Bonjour le monde')).toBeVisible(); }); test(`it checks ai_proxy ability`, async ({ page, browserName }) => { diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 2e212d7bb..fe7d7f21d 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", - "@ai-sdk/openai": "1.3.22", + "@ai-sdk/openai-compatible": "0.2.14", "@blocknote/code-block": "0.32.0", "@blocknote/core": "0.32.0", "@blocknote/mantine": "0.32.0", diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index 2d20ad3bf..b5df76752 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -1,8 +1,8 @@ -import { createOpenAI } from '@ai-sdk/openai'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { createAIExtension, llmFormats } from '@blocknote/xl-ai'; import { useMemo } from 'react'; -import { fetchAPI } from '@/api'; +import { baseApiUrl, fetchAPI } from '@/api'; import { useConfig } from '@/core'; import { Doc } from '@/docs/doc-management'; @@ -17,8 +17,9 @@ export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { return; } - const openai = createOpenAI({ - apiKey: '', // The API key will be set by the AI proxy + const openai = createOpenAICompatible({ + name: 'AI Proxy', + baseURL: `${baseApiUrl('1.0')}documents/${docId}/ai-proxy/`, // Necessary for initialization.. fetch: (input, init) => { // Create a new headers object without the Authorization header const headers = new Headers(init?.headers); @@ -30,7 +31,8 @@ export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { }); }, }); - const model = openai.chat(conf.AI_MODEL); + + const model = openai.chatModel(conf.AI_MODEL); const extension = createAIExtension({ stream: conf.AI_STREAM, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index b064a95d2..7721ec73c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -33,11 +33,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { ) => { /** * When the AI edit the doc transaction.local is false, - * so we check if the origin is null to know if the change - * is local or not. + * so we check if the origin constructor to know where + * the transaction comes from. + * * TODO: see if we can get the local changes from the AI */ - setIsLocalChange(transaction.local || transaction.origin === null); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const transactionOrigin = transaction?.origin?.constructor?.name; + const AI_ORIGIN_CONSTRUCTOR = 'ao'; + + setIsLocalChange( + transaction.local || transactionOrigin === AI_ORIGIN_CONSTRUCTOR, + ); }; yDoc.on('update', onUpdate); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 50ffb4d7b..0c61ef5df 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -12,10 +12,10 @@ resolved "https://registry.yarnpkg.com/@ag-media/react-pdf-table/-/react-pdf-table-2.0.3.tgz#113554b583b46e41a098cf64fecb5decd59ba004" integrity sha512-IscjfAOKwsyQok9YmzvuToe6GojN7J8hF0kb8C+K8qZX1DvhheGO+hRSAPxbv2nKMbSpvk7CIhSqJEkw++XVWg== -"@ai-sdk/openai@1.3.22": - version "1.3.22" - resolved "https://registry.yarnpkg.com/@ai-sdk/openai/-/openai-1.3.22.tgz#ed52af8f8fb3909d108e945d12789397cb188b9b" - integrity sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw== +"@ai-sdk/openai-compatible@0.2.14": + version "0.2.14" + resolved "https://registry.yarnpkg.com/@ai-sdk/openai-compatible/-/openai-compatible-0.2.14.tgz#f31e3dd1d767f3a44efaef2a0f0b2389d5c2b8a1" + integrity sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw== dependencies: "@ai-sdk/provider" "1.1.3" "@ai-sdk/provider-utils" "2.2.8" From 253868edcc0d9c1fd4aca244b8acb2b3acfba5f0 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 1 Jul 2025 11:35:09 +0200 Subject: [PATCH 11/16] =?UTF-8?q?fixup!=20=E2=9A=A1=EF=B8=8F(frontend)=20i?= =?UTF-8?q?mprove=20prompt=20of=20some=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-editor/components/AI/useAI.tsx | 4 +- .../doc-editor/components/AI/usePromptAI.tsx | 230 +++++++++--------- .../apps/impress/src/i18n/translations.json | 20 +- 3 files changed, 125 insertions(+), 129 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx index b5df76752..8c634215f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -1,5 +1,5 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { createAIExtension, llmFormats } from '@blocknote/xl-ai'; +import { createAIExtension } from '@blocknote/xl-ai'; import { useMemo } from 'react'; import { baseApiUrl, fetchAPI } from '@/api'; @@ -38,7 +38,7 @@ export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { stream: conf.AI_STREAM, model, agentCursor: conf?.AI_BOT, - promptBuilder: promptBuilder(llmFormats.html.defaultPromptBuilder), + promptBuilder, }); return extension; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx index f3a17fbb6..34b2cece8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx @@ -1,4 +1,5 @@ import { Block } from '@blocknote/core'; +import { llmFormats } from '@blocknote/xl-ai'; import { CoreMessage } from 'ai'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,11 +14,6 @@ export type PromptBuilderInput = { previousMessages?: Array; }; -type PromptBuilder = ( - editor: DocsBlockNoteEditor, - opts: PromptBuilderInput, -) => Promise>; - /** * Custom implementation of the PromptBuilder that allows for using predefined prompts. * @@ -28,131 +24,131 @@ export const usePromptAI = () => { const { t } = useTranslation(); return useCallback( - (defaultPromptBuilder: PromptBuilder) => - async ( - editor: DocsBlockNoteEditor, - opts: PromptBuilderInput, - ): Promise> => { - const systemPrompts: Record< - | 'add-edit-instruction' - | 'add-formatting' - | 'add-markdown' - | 'assistant' - | 'language' - | 'referenceId', - CoreMessage - > = { - assistant: { - role: 'system', - content: t(`You are an AI assistant that edits user documents.`), - }, - referenceId: { - role: 'system', - content: t( - `Keep block IDs exactly as provided when referencing them (including the trailing "$").`, - ), - }, - 'add-markdown': { - role: 'system', - content: t(`Answer the user prompt in markdown format.`), - }, - 'add-formatting': { - role: 'system', - content: t(`Add formatting to the text to make it more readable.`), - }, - 'add-edit-instruction': { - role: 'system', - content: t( - `Add content; do not delete or alter existing blocks unless explicitly told.`, - ), - }, - language: { - role: 'system', - content: t( - `Detect the dominant language inside the provided blocks. YOU MUST PROVIDE A ANSWER IN THE DETECTED LANGUAGE.`, - ), - }, - }; - - const userPrompts: Record = { - 'continue writing': t( - 'Keep writing about the content send in the prompt, expanding on the ideas.', + async ( + editor: DocsBlockNoteEditor, + opts: PromptBuilderInput, + ): Promise> => { + const systemPrompts: Record< + | 'add-edit-instruction' + | 'add-formatting' + | 'add-markdown' + | 'assistant' + | 'language' + | 'referenceId', + CoreMessage + > = { + assistant: { + role: 'system', + content: t(`You are an AI assistant that edits user documents.`), + }, + referenceId: { + role: 'system', + content: t( + `Keep block IDs exactly as provided when referencing them (including the trailing "$").`, ), - 'improve writing': t( - 'Improve the writing of the selected text. Make it more professional and clear.', + }, + 'add-markdown': { + role: 'system', + content: t(`Answer the user prompt in markdown format.`), + }, + 'add-formatting': { + role: 'system', + content: t(`Add formatting to the text to make it more readable.`), + }, + 'add-edit-instruction': { + role: 'system', + content: t( + `Add content; do not delete or alter existing blocks unless explicitly told.`, ), - summarize: t('Summarize the document into a concise paragraph.'), - 'fix spelling': t( - 'Fix the spelling and grammar mistakes in the selected text.', + }, + language: { + role: 'system', + content: t( + `Detect the dominant language inside the provided blocks. YOU MUST PROVIDE AN ANSWER IN THE DETECTED LANGUAGE.`, ), - }; - - // Modify userPrompt if it matches a custom prompt - const customPromptMatch = opts.userPrompt.match(/^([^:]+)(?=[:]|$)/); - let modifiedOpts = opts; - const promptKey = customPromptMatch?.[0].trim().toLowerCase(); - if (promptKey) { - if (userPrompts[promptKey]) { - modifiedOpts = { - ...opts, - userPrompt: userPrompts[promptKey], - }; - } + }, + }; + + const userPrompts: Record = { + 'continue writing': t( + 'Keep writing about the content sent in the prompt, expanding on the ideas.', + ), + 'improve writing': t( + 'Improve the writing of the selected text. Make it more professional and clear.', + ), + summarize: t('Summarize the document into a concise paragraph.'), + 'fix spelling': t( + 'Fix the spelling and grammar mistakes in the selected text.', + ), + }; + + // Modify userPrompt if it matches a custom prompt + const customPromptMatch = opts.userPrompt.match(/^([^:]+)(?=[:]|$)/); + let modifiedOpts = opts; + const promptKey = customPromptMatch?.[0].trim().toLowerCase(); + if (promptKey) { + if (userPrompts[promptKey]) { + modifiedOpts = { + ...opts, + userPrompt: userPrompts[promptKey], + }; } + } + let prompts = await llmFormats.html.defaultPromptBuilder( + editor, + modifiedOpts, + ); + const isTransformExistingContent = !!opts.selectedBlocks?.length; + if (!isTransformExistingContent) { + prompts = prompts.map((prompt) => { + if (!prompt.content || typeof prompt.content !== 'string') { + return prompt; + } - let prompts = await defaultPromptBuilder(editor, modifiedOpts); - const isTransformExistingContent = !!opts.selectedBlocks?.length; - if (!isTransformExistingContent) { - prompts = prompts.map((prompt) => { - if (!prompt.content || typeof prompt.content !== 'string') { - return prompt; - } - - /** - * Fix a bug when the initial content is empty - * TODO: Remove this when the bug is fixed in BlockNote - */ - if (prompt.content === '[]') { - const lastBlockId = - editor.document[editor.document.length - 1].id; + /** + * Fix a bug when the initial content is empty + * TODO: Remove this when the bug is fixed in BlockNote + */ + if (prompt.content === '[]') { + const lastBlockId = editor.document[editor.document.length - 1].id; - prompt.content = `[{\"id\":\"${lastBlockId}$\",\"block\":\"

\"}]`; - return prompt; - } + prompt.content = `[{\"id\":\"${lastBlockId}$\",\"block\":\"

\"}]`; + return prompt; + } - if ( - prompt.content.includes( - "You're manipulating a text document using HTML blocks.", - ) - ) { - prompt = systemPrompts['add-markdown']; - return prompt; - } + if ( + prompt.content.includes( + "You're manipulating a text document using HTML blocks.", + ) + ) { + prompt = systemPrompts['add-markdown']; + return prompt; + } - if ( - prompt.content.includes( - 'First, determine what part of the document the user is talking about.', - ) - ) { - prompt = systemPrompts['add-edit-instruction']; - } + if ( + prompt.content.includes( + 'First, determine what part of the document the user is talking about.', + ) + ) { + prompt = systemPrompts['add-edit-instruction']; + } - return prompt; - }); + return prompt; + }); - prompts.push(systemPrompts['add-formatting']); - } + prompts.push(systemPrompts['add-formatting']); + } - prompts.unshift(systemPrompts['assistant']); - prompts.push(systemPrompts['referenceId']); + prompts.unshift(systemPrompts['assistant']); + prompts.push(systemPrompts['referenceId']); - // Try to keep the language of the document except when we are translating - if (!promptKey?.includes('Translate into')) { - prompts.push(systemPrompts['language']); - } + // Try to keep the language of the document except when we are translating + if (!promptKey?.includes('Translate into')) { + prompts.push(systemPrompts['language']); + } - return prompts; - }, + return prompts; + }, [t], ); }; diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 153417020..1b1642650 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -590,16 +590,16 @@ "Warning": "Attention", "Why can't I edit?": "Pourquoi ne puis-je pas éditer ?", "Write": "Écrire", - "You are an AI assistant that helps users to edit their documents.": "Vous êtes un assistant IA qui aide les utilisateurs à éditer leurs documents.", - "Answer the user prompt in markdown format.": "Répondez à la demande de l'utilisateur au format markdown.", - "Add formatting to the text to make it more readable.": "Ajoutez du formatage au texte pour le rendre plus lisible.", - "Keep adding to the document, do not delete or modify existing blocks.": "Continuez à ajouter au document, ne supprimez ni ne modifiez les blocs existants.", - "Your answer must be in the same language as the document.": "Votre réponse doit être dans la même langue que le document.", - "Fix the spelling and grammar mistakes in the selected text.": "Corrigez les fautes d'orthographe et de grammaire dans le texte sélectionné.", - "Improve the writing of the selected text. Make it more professional and clear.": "Améliorez l'écriture du texte sélectionné. Rendez-le plus professionnel et clair.", - "Summarize the document into a concise paragraph.": "Résumez le document en un paragraphe concis.", - "Keep writing about the content send in the prompt, expanding on the ideas.": "Continuez à écrire sur le contenu envoyé dans la demande, en développant les idées.", - "Important, verified the language of the document! Your answer MUST be in the same language as the document. If the document is in English, your answer MUST be in English. If the document is in Spanish, your answer MUST be in Spanish, etc.": "Important, vérifiez la langue du document ! Votre réponse DOIT être dans la même langue que le document. Si le document est en anglais, votre réponse DOIT être en anglais. Si le document est en espagnol, votre réponse DOIT être en espagnol, etc.", + "You are an AI assistant that helps users to edit their documents.": "Tu es un assistant IA qui aide les utilisateurs à éditer leurs documents.", + "Answer the user prompt in markdown format.": "Réponds à la demande de l'utilisateur au format markdown.", + "Add formatting to the text to make it more readable.": "Ajoute du formatage au texte pour le rendre plus lisible.", + "Keep adding to the document, do not delete or modify existing blocks.": "Continue d'ajouter au document, ne supprime ni ne modifie les blocs existants.", + "Your answer must be in the same language as the document.": "Ta réponse doit être dans la même langue que le document.", + "Fix the spelling and grammar mistakes in the selected text.": "Corrige les fautes d'orthographe et de grammaire dans le texte sélectionné.", + "Improve the writing of the selected text. Make it more professional and clear.": "Améliore l'écriture du texte sélectionné. Rends-le plus professionnel et clair.", + "Summarize the document into a concise paragraph.": "Résume le document en un paragraphe concis.", + "Keep writing about the content sent in the prompt, expanding on the ideas.": "Continue à écrire sur le contenu envoyé dans la demande, en développant les idées.", + "Important, verified the language of the document! Your answer MUST be in the same language as the document. If the document is in English, your answer MUST be in English. If the document is in Spanish, your answer MUST be in Spanish, etc.": "Important, vérifie la langue du document ! Ta réponse DOIT être dans la même langue que le document. Si le document est en anglais, ta réponse DOIT être en anglais. Si le document est en espagnol, ta réponse DOIT être en espagnol, etc.", "You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.", "You do not have permission to view this document.": "Vous n'avez pas la permission de voir ce document.", "You do not have permission to view users sharing this document or modify link settings.": "Vous n'avez pas la permission de voir les utilisateurs partageant ce document ou de modifier les paramètres du lien.", From 09da617b6b33ef7b152a958962bc4cae9fa0af59 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 1 Jul 2025 13:03:14 +0200 Subject: [PATCH 12/16] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20integrate?= =?UTF-8?q?=20new=20Blocknote=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/doc-editor/assets/wand_stars.svg | 6 ++ .../docs/doc-editor/components/AI/AIUI.tsx | 93 ++++++++++++++----- 2 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg new file mode 100644 index 000000000..743b871a1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx index 8d814961d..be6e784ac 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -6,12 +6,13 @@ import { } from '@blocknote/xl-ai'; import '@blocknote/xl-ai/style.css'; import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; +import { createGlobalStyle, css } from 'styled-components'; -import { Box, Text } from '@/components'; +import { Box, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import IconAI from '../../assets/IconAI.svg'; +import IconWandStar from '../../assets/wand_stars.svg'; import { DocsBlockNoteEditor, DocsBlockSchema, @@ -19,34 +20,80 @@ import { DocsStyleSchema, } from '../../types'; +const AIMenuStyle = createGlobalStyle` + #ai-suggestion-menu .bn-suggestion-menu-item-small .bn-mt-suggestion-menu-item-section[data-position=left] svg { + height: 18px; + width: 18px; + } +`; + export function AIMenu() { return ( - { - if (aiResponseStatus === 'user-input') { - if (editor.getSelection()) { - const aiMenuItems = getDefaultAIMenuItems( - editor, - aiResponseStatus, - ).filter((item) => ['simplify'].indexOf(item.key) === -1); + <> + + { + if (aiResponseStatus === 'user-input') { + let aiMenuItems = getDefaultAIMenuItems(editor, aiResponseStatus); - return aiMenuItems; - } else { - const aiMenuItems = getDefaultAIMenuItems( - editor, - aiResponseStatus, - ).filter( - (item) => - ['action_items', 'write_anything'].indexOf(item.key) === -1, - ); + if (editor.getSelection()) { + aiMenuItems = aiMenuItems.filter( + (item) => ['simplify'].indexOf(item.key) === -1, + ); + + aiMenuItems = aiMenuItems.map((item) => { + if (item.key === 'improve_writing') { + return { + ...item, + icon: , + }; + } else if (item.key === 'translate') { + return { + ...item, + icon: ( + + ), + }; + } + + return item; + }); + } else { + aiMenuItems = aiMenuItems.filter( + (item) => + ['action_items', 'write_anything'].indexOf(item.key) === -1, + ); + } return aiMenuItems; + } else if (aiResponseStatus === 'user-reviewing') { + return getDefaultAIMenuItems(editor, aiResponseStatus).map( + (item) => { + if (item.key === 'accept') { + return { + ...item, + icon: ( + + ), + }; + } + return item; + }, + ); } - } - return getDefaultAIMenuItems(editor, aiResponseStatus); - }} - /> + return getDefaultAIMenuItems(editor, aiResponseStatus); + }} + /> + ); } From 100725f91fe9e2f7ae7566d221228277033f3fd6 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 1 Jul 2025 17:42:04 +0200 Subject: [PATCH 13/16] =?UTF-8?q?fixup!=20=E2=9C=A8(back)=20manage=20strea?= =?UTF-8?q?ming=20with=20the=20ai=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/viewsets.py | 2 +- src/backend/core/services/ai_services.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 37d572f7d..3156005e0 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1369,7 +1369,7 @@ def ai_proxy(self, request, *args, **kwargs): content_type="text/event-stream", status=drf.status.HTTP_200_OK, ) - + ai_response = ai_service.proxy(request.data) return drf.response.Response( ai_response.model_dump(), diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index 1671ee7ce..8c3dd2273 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -6,7 +6,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from openai import OpenAI +from openai import OpenAI, OpenAIError log = logging.getLogger(__name__) @@ -27,7 +27,10 @@ def __init__(self): def proxy(self, data: dict, stream: bool = False) -> Generator[str, None, None]: """Proxy AI API requests to the configured AI provider.""" data["stream"] = stream - return self.client.chat.completions.create(**data) + try: + return self.client.chat.completions.create(**data) + except OpenAIError as e: + raise RuntimeError(f"Failed to proxy AI request: {e}") from e def stream(self, data: dict) -> Generator[str, None, None]: """Stream AI API requests to the configured AI provider.""" From 42e455f5309329785ce760b9797c29ac52a53b81 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 1 Jul 2025 21:42:24 +0200 Subject: [PATCH 14/16] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20integrate?= =?UTF-8?q?=20new=20Blocknote=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/apps/impress/package.json | 4 ++-- src/frontend/yarn.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index fe7d7f21d..e95e880ad 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -48,9 +48,9 @@ "luxon": "3.6.1", "next": "15.3.4", "posthog-js": "1.255.1", - "react": "19.1.0", + "react": "*", "react-aria-components": "1.10.1", - "react-dom": "19.1.0", + "react-dom": "*", "react-i18next": "15.5.3", "react-intersection-observer": "9.16.0", "react-select": "5.10.1", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 0c61ef5df..c8201b304 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -14190,7 +14190,7 @@ react-dnd@^14.0.3: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@19.0.0, react-dom@19.1.0, react-dom@^18: +react-dom@*, react-dom@19.0.0, react-dom@19.1.0, react-dom@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== @@ -14424,7 +14424,7 @@ react-window@^1.8.11: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@19.0.0, react@19.1.0, react@^18: +react@*, react@19.0.0, react@19.1.0, react@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== From ec2efebfbd2eeca2086ff5b9252e2047958d9172 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 2 Jul 2025 11:14:53 +0200 Subject: [PATCH 15/16] =?UTF-8?q?fixup!=20=E2=9C=A8(frontend)=20integrate?= =?UTF-8?q?=20new=20Blocknote=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/package.json | 2 +- src/frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 0fc3bb4a8..c94f59ae5 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -34,7 +34,7 @@ "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "eslint": "8.57.0", - "prosemirror-view": "1.33.7", + "prosemirror-view": "1.40.0", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "5.8.3", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index c8201b304..313e232ae 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -13805,10 +13805,10 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@1.33.7, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.33.7, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: - version "1.33.7" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.33.7.tgz#fd9841a79a4bc517914a57456370b941bd655729" - integrity sha512-jo6eMQCtPRwcrA2jISBCnm0Dd2B+szS08BU1Ay+XGiozHo5EZMHfLQE8R5nO4vb1spTH2RW1woZIYXRiQsuP8g== +prosemirror-view@1.40.0, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.33.7, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: + version "1.40.0" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.40.0.tgz#212e627a0c4f0198ac9823a1232e0099c9a92865" + integrity sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw== dependencies: prosemirror-model "^1.20.0" prosemirror-state "^1.0.0" From 9a56292e2e120726d80628849d342c9d41ab6c44 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 3 Jul 2025 10:04:36 +0200 Subject: [PATCH 16/16] =?UTF-8?q?fixup!=20=E2=9A=A1=EF=B8=8F(frontend)=20i?= =?UTF-8?q?mprove=20prompt=20of=20some=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc-editor/components/AI/usePromptAI.tsx | 315 +++++++++++------- 1 file changed, 202 insertions(+), 113 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx index 34b2cece8..b43bb09e2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx @@ -1,19 +1,95 @@ import { Block } from '@blocknote/core'; import { llmFormats } from '@blocknote/xl-ai'; import { CoreMessage } from 'ai'; +import { TFunction } from 'i18next'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { DocsBlockNoteEditor } from '../../types'; +import { + DocsBlockNoteEditor, + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; export type PromptBuilderInput = { userPrompt: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectedBlocks?: Block[]; + selectedBlocks?: Block< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >[]; excludeBlockIds?: string[]; previousMessages?: Array; }; +type SystemPromptKey = + | 'add-edit-instruction' + | 'add-formatting' + | 'add-markdown' + | 'assistant' + | 'language' + | 'referenceId'; + +// Constants for prompt content matching +const PROMPT_INDICATORS = { + HTML_BLOCKS: "You're manipulating a text document using HTML blocks.", + EDIT_INSTRUCTION: + 'First, determine what part of the document the user is talking about.', + ASSISTANT_IDENTIFIER: 'You are an AI assistant that edits user documents.', + EMPTY_CONTENT: '[]', +} as const; + +const createSystemPrompts = ( + t: TFunction<'translation', undefined>, +): Record => ({ + assistant: { + role: 'system', + content: t('You are an AI assistant that edits user documents.'), + }, + referenceId: { + role: 'system', + content: t( + 'Keep block IDs exactly as provided when referencing them (including the trailing "$").', + ), + }, + 'add-markdown': { + role: 'system', + content: t('Answer the user prompt in markdown format.'), + }, + 'add-formatting': { + role: 'system', + content: t('Add formatting to the text to make it more readable.'), + }, + 'add-edit-instruction': { + role: 'system', + content: t( + 'Add content; do not delete or alter existing blocks unless explicitly told.', + ), + }, + language: { + role: 'system', + content: t( + 'Detect the dominant language inside the provided blocks. YOU MUST PROVIDE AN ANSWER IN THE DETECTED LANGUAGE.', + ), + }, +}); + +const createUserPrompts = ( + t: TFunction<'translation', undefined>, +): Record => ({ + 'continue writing': t( + 'Keep writing about the content sent in the prompt, expanding on the ideas.', + ), + 'improve writing': t( + 'Improve the writing of the selected text. Make it more professional and clear.', + ), + summarize: t('Summarize the document into a concise paragraph.'), + 'fix spelling': t( + 'Fix the spelling and grammar mistakes in the selected text.', + ), +}); + /** * Custom implementation of the PromptBuilder that allows for using predefined prompts. * @@ -23,132 +99,145 @@ export type PromptBuilderInput = { export const usePromptAI = () => { const { t } = useTranslation(); + const parseCustomPrompt = useCallback( + (userPrompt: string, userPrompts: Record) => { + const customPromptMatch = userPrompt.match(/^([^:]+)(?=[:]|$)/); + const promptKey = customPromptMatch?.[0].trim().toLowerCase(); + + if (promptKey && userPrompts[promptKey]) { + return { + promptKey, + modifiedPrompt: userPrompts[promptKey], + }; + } + + return { + promptKey, + modifiedPrompt: userPrompt, + }; + }, + [], + ); + + /** + * Fix a bug when the initial content is empty + * TODO: Remove this when the bug is fixed in BlockNote + */ + const fixEmptyContentBug = useCallback( + (prompt: CoreMessage, editor: DocsBlockNoteEditor): CoreMessage => { + const lastBlockId = editor.document[editor.document.length - 1]?.id; + return { + role: prompt.role, + content: `[{"id":"${lastBlockId}$","block":"

"}]`, + } as CoreMessage; + }, + [], + ); + + const transformPromptContent = useCallback( + ( + prompt: CoreMessage, + systemPrompts: Record, + editor: DocsBlockNoteEditor, + ): CoreMessage => { + if (!prompt.content || typeof prompt.content !== 'string') { + return prompt; + } + + // Fix empty content bug + if (prompt.content.includes(PROMPT_INDICATORS.EMPTY_CONTENT)) { + return fixEmptyContentBug(prompt, editor); + } + + // Replace specific prompt content with system prompts + if (prompt.content.includes(PROMPT_INDICATORS.HTML_BLOCKS)) { + return systemPrompts['add-markdown']; + } + + if (prompt.content.includes(PROMPT_INDICATORS.EDIT_INSTRUCTION)) { + return systemPrompts['add-edit-instruction']; + } + + return prompt; + }, + [fixEmptyContentBug], + ); + + const addSystemPrompts = useCallback( + ( + prompts: CoreMessage[], + systemPrompts: Record, + hasAssistantPrompt: boolean, + promptKey?: string, + ): CoreMessage[] => { + if (hasAssistantPrompt) { + return prompts; + } + + const newPrompts = [ + systemPrompts.assistant, + ...prompts, + systemPrompts.referenceId, + ]; + + // Add language prompt except when translating + if (!promptKey?.includes('Translate into')) { + newPrompts.push(systemPrompts.language); + } + + return newPrompts; + }, + [], + ); + return useCallback( async ( editor: DocsBlockNoteEditor, opts: PromptBuilderInput, ): Promise> => { - const systemPrompts: Record< - | 'add-edit-instruction' - | 'add-formatting' - | 'add-markdown' - | 'assistant' - | 'language' - | 'referenceId', - CoreMessage - > = { - assistant: { - role: 'system', - content: t(`You are an AI assistant that edits user documents.`), - }, - referenceId: { - role: 'system', - content: t( - `Keep block IDs exactly as provided when referencing them (including the trailing "$").`, - ), - }, - 'add-markdown': { - role: 'system', - content: t(`Answer the user prompt in markdown format.`), - }, - 'add-formatting': { - role: 'system', - content: t(`Add formatting to the text to make it more readable.`), - }, - 'add-edit-instruction': { - role: 'system', - content: t( - `Add content; do not delete or alter existing blocks unless explicitly told.`, - ), - }, - language: { - role: 'system', - content: t( - `Detect the dominant language inside the provided blocks. YOU MUST PROVIDE AN ANSWER IN THE DETECTED LANGUAGE.`, - ), - }, - }; + const systemPrompts = createSystemPrompts(t); + const userPrompts = createUserPrompts(t); - const userPrompts: Record = { - 'continue writing': t( - 'Keep writing about the content sent in the prompt, expanding on the ideas.', - ), - 'improve writing': t( - 'Improve the writing of the selected text. Make it more professional and clear.', - ), - summarize: t('Summarize the document into a concise paragraph.'), - 'fix spelling': t( - 'Fix the spelling and grammar mistakes in the selected text.', - ), - }; + // Parse and modify user prompt if it matches a custom prompt + const { promptKey, modifiedPrompt } = parseCustomPrompt( + opts.userPrompt, + userPrompts, + ); + const modifiedOpts = { ...opts, userPrompt: modifiedPrompt }; - // Modify userPrompt if it matches a custom prompt - const customPromptMatch = opts.userPrompt.match(/^([^:]+)(?=[:]|$)/); - let modifiedOpts = opts; - const promptKey = customPromptMatch?.[0].trim().toLowerCase(); - if (promptKey) { - if (userPrompts[promptKey]) { - modifiedOpts = { - ...opts, - userPrompt: userPrompts[promptKey], - }; - } - } + // Get initial prompts from BlockNote let prompts = await llmFormats.html.defaultPromptBuilder( editor, modifiedOpts, ); - const isTransformExistingContent = !!opts.selectedBlocks?.length; - if (!isTransformExistingContent) { - prompts = prompts.map((prompt) => { - if (!prompt.content || typeof prompt.content !== 'string') { - return prompt; - } - - /** - * Fix a bug when the initial content is empty - * TODO: Remove this when the bug is fixed in BlockNote - */ - if (prompt.content === '[]') { - const lastBlockId = editor.document[editor.document.length - 1].id; - - prompt.content = `[{\"id\":\"${lastBlockId}$\",\"block\":\"

\"}]`; - return prompt; - } - - if ( - prompt.content.includes( - "You're manipulating a text document using HTML blocks.", - ) - ) { - prompt = systemPrompts['add-markdown']; - return prompt; - } - - if ( - prompt.content.includes( - 'First, determine what part of the document the user is talking about.', - ) - ) { - prompt = systemPrompts['add-edit-instruction']; - } - - return prompt; - }); - - prompts.push(systemPrompts['add-formatting']); - } - prompts.unshift(systemPrompts['assistant']); - prompts.push(systemPrompts['referenceId']); + const hasAssistantPrompt = prompts.some( + (prompt) => prompt.content === PROMPT_INDICATORS.ASSISTANT_IDENTIFIER, + ); - // Try to keep the language of the document except when we are translating - if (!promptKey?.includes('Translate into')) { - prompts.push(systemPrompts['language']); + const hasSelectedBlocks = !!opts.selectedBlocks?.length; + // Transform prompts for new content creation (no selected blocks) + if (!hasSelectedBlocks) { + prompts = prompts.map((prompt) => + transformPromptContent(prompt, systemPrompts, editor), + ); + + // Add formatting prompt if no assistant prompt exists + if (!hasAssistantPrompt) { + prompts.push(systemPrompts['add-formatting']); + } } + // Add system prompts if they don't exist + prompts = addSystemPrompts( + prompts, + systemPrompts, + hasAssistantPrompt, + promptKey, + ); + return prompts; }, - [t], + [parseCustomPrompt, transformPromptContent, addSystemPrompts, t], ); };