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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2ce0758..adf916685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(frontend) add customization for translations #857 +- ✨(frontend) integrate new Blocknote AI feature #1016 ## [3.3.0] - 2025-05-06 diff --git a/docs/env.md b/docs/env.md index a41d7ef93..63752b93a 100644 --- a/docs/env.md +++ b/docs/env.md @@ -82,6 +82,7 @@ These are the environment variables you can set for the `impress-backend` contai | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | 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_MODEL | AI Model to use | | | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | AI_FEATURE_ENABLED | Enable AI options | false | diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb3..7a0462ad7 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, @@ -673,33 +672,38 @@ 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 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) -class AITranslateSerializer(serializers.Serializer): - """Serializer for AI translate requests.""" + 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" + ) - language = serializers.ChoiceField( - choices=tuple(enums.ALL_LANGUAGES.items()), required=True - ) - text = serializers.CharField(required=True) + return messages - def validate_text(self, value): - """Ensure the text field is not empty.""" + 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") - if len(value.strip()) == 0: - raise serializers.ValidationError("Text field cannot be empty.") return value diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index cf9930703..8d03528a1 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -384,21 +384,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 @@ -435,7 +422,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 @@ -1353,58 +1339,39 @@ def media_check(self, request, *args, **kwargs): @drf.decorators.action( detail=True, methods=["post"], - name="Apply a transformation action on a piece of text with AI", - url_path="ai-transform", + name="Proxy AI requests to the AI provider", + url_path="ai-proxy", throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], ) - def ai_transform(self, request, *args, **kwargs): + def ai_proxy(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. + 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() - 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) + if not settings.AI_FEATURE_ENABLED: + raise ValidationError("AI feature is not enabled.") - 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 = serializers.AIProxySerializer(data=request.data) serializer.is_valid(raise_exception=True) - text = serializer.validated_data["text"] - language = serializer.validated_data["language"] - - response = AIService().translate(text, language) + ai_service = AIService() - return drf.response.Response(response, status=drf.status.HTTP_200_OK) + if settings.AI_STREAM: + return StreamingHttpResponse( + ai_service.stream(request.data), + content_type="text/event-stream", + status=drf.status.HTTP_200_OK, + ) + else: + 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, @@ -1783,7 +1750,10 @@ def get(self, request): Return a dictionary of public settings. """ array_settings = [ + "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/models.py b/src/backend/core/models.py index e9880f527..7b6afcb2d 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..666b13940 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -1,54 +1,14 @@ """AI services.""" +import logging +from typing import Generator + from django.conf import settings from django.core.exceptions import ImproperlyConfigured from openai import OpenAI -from core import enums - -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." -) +log = logging.getLogger(__name__) class AIService: @@ -64,30 +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 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 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) - 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 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") - 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) + yield ("data: [DONE]\n\n") 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_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 91e6ca0e5..41e71a12b 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -28,8 +28,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": document.link_role == "editor", "children_create": False, "children_list": True, @@ -96,8 +95,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": grand_parent.link_role == "editor", "children_create": False, "children_list": True, @@ -193,8 +191,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": document.link_role == "editor", - "ai_translate": document.link_role == "editor", + "ai_proxy": document.link_role == "editor", "attachment_upload": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, @@ -268,8 +265,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": grand_parent.link_role == "editor", - "ai_translate": grand_parent.link_role == "editor", + "ai_proxy": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, @@ -449,8 +445,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "abilities": { "accesses_manage": access.role in ["administrator", "owner"], "accesses_view": True, - "ai_transform": access.role != "reader", - "ai_translate": access.role != "reader", + "ai_proxy": 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 4e4eb2769..1d34f9b29 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -72,8 +72,7 @@ def test_api_documents_trashbin_format(): "abilities": { "accesses_manage": True, "accesses_view": True, - "ai_transform": True, - "ai_translate": True, + "ai_proxy": True, "attachment_upload": True, "children_create": True, "children_list": True, diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 2d74594c3..77e71222e 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -18,7 +18,10 @@ @override_settings( + 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", @@ -41,6 +44,10 @@ 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", + "AI_STREAM": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "CRISP_WEBSITE_ID": "123", @@ -59,7 +66,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": {}, } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 01d5181e4..3b63af0d2 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -152,8 +152,7 @@ def test_models_documents_get_abilities_forbidden( expected_abilities = { "accesses_manage": False, "accesses_view": False, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": False, "children_create": False, "children_list": False, @@ -213,8 +212,7 @@ def test_models_documents_get_abilities_reader( expected_abilities = { "accesses_manage": False, "accesses_view": False, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": False, "children_create": False, "children_list": True, @@ -276,8 +274,7 @@ def test_models_documents_get_abilities_editor( expected_abilities = { "accesses_manage": False, "accesses_view": False, - "ai_transform": is_authenticated, - "ai_translate": is_authenticated, + "ai_proxy": is_authenticated, "attachment_upload": True, "children_create": is_authenticated, "children_list": True, @@ -328,8 +325,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): expected_abilities = { "accesses_manage": True, "accesses_view": True, - "ai_transform": True, - "ai_translate": True, + "ai_proxy": True, "attachment_upload": True, "children_create": True, "children_list": True, @@ -377,8 +373,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) expected_abilities = { "accesses_manage": True, "accesses_view": True, - "ai_transform": True, - "ai_translate": True, + "ai_proxy": True, "attachment_upload": True, "children_create": True, "children_list": True, @@ -429,8 +424,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): expected_abilities = { "accesses_manage": False, "accesses_view": True, - "ai_transform": True, - "ai_translate": True, + "ai_proxy": True, "attachment_upload": True, "children_create": True, "children_list": True, @@ -488,8 +482,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_transform": access_from_link and ai_access_setting != "restricted", - "ai_translate": access_from_link and ai_access_setting != "restricted", + "ai_proxy": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, "children_create": access_from_link, "children_list": True, @@ -545,8 +538,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): assert abilities == { "accesses_manage": False, "accesses_view": True, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": False, "children_create": False, "children_list": True, @@ -592,8 +584,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_transform"] is True - assert abilities["ai_translate"] is True + assert abilities["ai_proxy"] is True @override_settings(AI_ALLOW_REACH_FROM="authenticated") @@ -611,8 +602,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_transform"] == is_authenticated - assert abilities["ai_translate"] == is_authenticated + assert abilities["ai_proxy"] == 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..dad25a98c 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") @@ -47,38 +52,84 @@ def test_api_ai__client_error(mock_create): OpenAIError, match="Mocked client error", ): - AIService().transform("hello", "prompt") + 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__client_invalid_response(mock_create): - """Fail when the client response is invalid""" +def test_services_ai_proxy_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=None))] + 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"}]}) + + 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=False ) - with pytest.raises( - RuntimeError, - match="AI response does not contain an answer", - ): - 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__success(mock_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 = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] + 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 ) - response = AIService().transform("hello", "prompt") - - assert response == {"answer": "Salut"} + expected_response = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Salut"}, + "finish_reason": "stop", + } + ], + } + assert response == expected_response + 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 737bb3382..d6c6ef21f 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -593,24 +593,35 @@ 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 = values.Value(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 = values.Value(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_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 9c5d5b02e..bdd97e8b8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -1,7 +1,13 @@ import { Page, expect } from '@playwright/test'; export const CONFIG = { + AI_BOT: { + name: 'Docs AI', + color: '#8bc6ff', + }, 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/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 7dd938737..ebaa57a1f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -87,9 +87,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 722769af3..34c5763ba 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'; @@ -281,13 +282,72 @@ test.describe('Doc Editor', () => { ); }); - test('it checks the AI buttons', async ({ page, browserName }) => { - await page.route(/.*\/ai-translate\//, async (route) => { + 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')) { await route.fulfill({ json: { - answer: 'Bonjour le monde', + 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 { @@ -300,122 +360,80 @@ test.describe('Doc Editor', () => { await page.locator('.bn-block-outer').last().fill('Hello World'); const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); + await editor.getByText('Hello World').selectText(); - await page.getByRole('button', { name: 'AI' }).click(); + // Check from toolbar + await page.getByRole('button', { name: 'Ask AI' }).click(); await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Rephrase' }), + page.getByRole('option', { name: 'Improve Writing' }), ).toBeVisible(); await expect( - page.getByRole('menuitem', { name: 'Summarize' }), - ).toBeVisible(); - await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Language' }), + page.getByRole('option', { name: 'Fix Spelling' }), ).toBeVisible(); + await expect(page.getByRole('option', { name: 'Translate' })).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 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('Albert 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(); }); - [ - { 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', - }, + 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_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; - } + ], + 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: '', + }); - await page.getByRole('button', { name: 'AI' }).click(); + const [randomDoc] = await createDoc( + page, + 'doc-editor-ai-proxy', + browserName, + 1, + ); - if (ai_transform) { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - } else { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeHidden(); - } + await verifyDocName(page, randomDoc); - 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 */ - }); + 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 }) => { 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 048020837..fba397da4 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 header = page.locator('header').first(); const editor = page.locator('.ProseMirror'); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 8fb4dc3c5..9731889e5 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -16,10 +16,13 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", + "@ai-sdk/openai": "1.3.22", "@blocknote/code-block": "0.31.1", "@blocknote/core": "0.31.1", "@blocknote/mantine": "0.31.1", "@blocknote/react": "0.31.1", + "@blocknote/xl-ai": "0.31.1", + "@blocknote/xl-ai-server": "0.31.0", "@blocknote/xl-docx-exporter": "0.31.1", "@blocknote/xl-pdf-exporter": "0.31.1", "@emoji-mart/data": "1.2.1", @@ -32,6 +35,7 @@ "@react-pdf/renderer": "4.3.0", "@sentry/nextjs": "9.26.0", "@tanstack/react-query": "5.80.5", + "ai": "4.3.16", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -45,9 +49,9 @@ "luxon": "3.6.1", "next": "15.3.3", "posthog-js": "1.249.3", - "react": "*", + "react": "19.1.0", "react-aria-components": "1.9.0", - "react-dom": "*", + "react-dom": "19.1.0", "react-i18next": "15.5.2", "react-intersection-observer": "9.16.0", "react-select": "5.10.1", @@ -55,6 +59,7 @@ "use-debounce": "10.0.4", "y-protocols": "1.0.6", "yjs": "*", + "zod": "3.25.28", "zustand": "5.0.5" }, "devDependencies": { 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..66074e10c 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,10 @@ interface ThemeCustomization { } 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/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/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..b5e2dc998 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -0,0 +1,136 @@ +import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react'; +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'; + +import { + AIMenuDefault, + getAIExtension, + getDefaultAIMenuItems, +} from './libAGPL'; + +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..d3337d254 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts @@ -0,0 +1,30 @@ +import dynamic from 'next/dynamic'; +import { FC } from 'react'; + +export { useAI } from './useAI'; +export * from './useModuleAI'; + +const AIMenuController = dynamic(() => + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('./libAGPL').then((mod) => mod.AIMenuController) + : Promise.resolve(() => null), +); + +const AIToolbarButton = dynamic(() => + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('./AIUI').then((mod) => mod.AIToolbarButton) + : Promise.resolve(() => null), +); + +const AIMenu = dynamic(() => + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('./AIUI').then((mod) => mod.AIMenu) + : Promise.resolve(() => null), +) as FC; + +const modGetAISlashMenuItems = + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('./libAGPL').then((mod) => mod.getAISlashMenuItems) + : Promise.resolve(null); + +export { AIMenu, AIMenuController, AIToolbarButton, modGetAISlashMenuItems }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/libAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/libAGPL.tsx new file mode 100644 index 000000000..f4e8d35f2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/libAGPL.tsx @@ -0,0 +1,11 @@ +export { + AIMenu as AIMenuDefault, + AIMenuController, + createAIExtension, + getAIExtension, + getAISlashMenuItems, + getDefaultAIMenuItems, + llmFormats, +} from '@blocknote/xl-ai'; +export * as localesAI from '@blocknote/xl-ai/locales'; +import '@blocknote/xl-ai/style.css'; 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..0ed5f2b5a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -0,0 +1,56 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { useMemo } from 'react'; + +import { fetchAPI } from '@/api'; +import { useConfig } from '@/core'; +import { Doc } from '@/docs/doc-management'; + +import { useModuleAI } from './useModuleAI'; +import { usePromptAI } from './usePromptAI'; + +export const useAI = (doc: Doc) => { + const conf = useConfig().data; + const modules = useModuleAI(); + const aiAllowed = !!(conf?.AI_FEATURE_ENABLED && doc.abilities?.ai_proxy); + const promptBuilder = usePromptAI(); + + return useMemo(() => { + if (!aiAllowed || !modules || !conf?.AI_MODEL) { + return; + } + + const { createAIExtension, llmFormats } = modules; + + const openai = createOpenAI({ + 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); + headers.delete('Authorization'); + + return fetchAPI(`documents/${doc.id}/ai-proxy/`, { + ...init, + headers, + }); + }, + }); + const model = openai.chat(conf.AI_MODEL); + + const extension = createAIExtension({ + stream: conf.AI_STREAM, + model, + agentCursor: conf.AI_BOT, + promptBuilder: promptBuilder(llmFormats.html.defaultPromptBuilder), + }); + + return extension; + }, [ + aiAllowed, + conf?.AI_BOT, + conf?.AI_MODEL, + conf?.AI_STREAM, + doc.id, + modules, + promptBuilder, + ]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useModuleAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useModuleAI.tsx new file mode 100644 index 000000000..a7c1bf93d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useModuleAI.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const modulesAGPL = + process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' + ? import('./libAGPL') + : Promise.resolve(null); + +export const useModuleAI = () => { + const [modules, setModules] = useState>(); + + useEffect(() => { + const resolveModule = async () => { + const resolvedModules = await modulesAGPL; + if (!resolvedModules) { + return; + } + setModules(resolvedModules); + }; + void resolveModule(); + }, []); + + return modules; +}; + +export const useModuleDictionnaryAI = () => { + const { i18n } = useTranslation(); + const lang = i18n.resolvedLanguage; + + const [dictionary, setDictionary] = useState<{ + ai?: unknown; + }>(); + + useEffect(() => { + const resolveModule = async () => { + const resolvedModules = await modulesAGPL; + if (!resolvedModules) { + return; + } + + const { localesAI } = resolvedModules; + + setDictionary({ + ai: localesAI[lang as keyof typeof localesAI], + }); + }; + + void resolveModule(); + }, [lang]); + + return dictionary; +}; 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/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 7ed06af1d..ab8849664 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 @@ -5,7 +5,6 @@ import { withPageBreak, } from '@blocknote/core'; import '@blocknote/core/fonts/inter.css'; -import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; @@ -19,12 +18,14 @@ import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; import { useHeadings, useUploadFile, useUploadStatus } from '../hook/'; +import { useDictionary } from '../hook/useDictionary'; import useSaveDoc from '../hook/useSaveDoc'; import { useEditorStore } from '../stores'; import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; +import { AIMenu, AIMenuController, useAI } from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, DividerBlock } from './custom-blocks'; @@ -53,10 +54,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; useSaveDoc(doc.id, provider.document, !readOnly); - const { i18n } = useTranslation(); - const lang = i18n.resolvedLanguage; + const dictionary = useDictionary(); const { uploadFile, errorAttachment } = useUploadFile(doc.id); + const aiExtension = useAI(doc); + const aiAllowed = !!aiExtension; const collabName = readOnly ? 'Reader' @@ -115,7 +117,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, - dictionary: locales[lang as keyof typeof locales], + dictionary, + extensions: aiExtension ? [aiExtension] : [], tables: { splitCells: true, cellBackgroundColor: true, @@ -125,7 +128,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [aiExtension, collabName, dictionary, provider, uploadFile], ); useHeadings(editor); @@ -163,8 +166,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > - - + {aiAllowed && } + +
); 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..e6fe187a7 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 @@ -11,30 +11,39 @@ import { useTranslation } from 'react-i18next'; import { DocsBlockSchema } from '../types'; +import { modGetAISlashMenuItems } from './AI'; import { getCalloutReactSlashMenuItems, getDividerReactSlashMenuItems, } from './custom-blocks'; -export const BlockNoteSuggestionMenu = () => { +export const BlockNoteSuggestionMenu = ({ + aiAllowed, +}: { + aiAllowed: boolean; +}) => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; const getSlashMenuItems = useMemo(() => { - return async (query: string) => - Promise.resolve( + return async (query: string) => { + const getAISlashMenuItems = await modGetAISlashMenuItems; + + return Promise.resolve( filterSuggestionItems( combineByGroup( getDefaultReactSlashMenuItems(editor), getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), + aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [], ), query, ), ); - }, [basicBlocksName, editor, t]); + }; + }, [basicBlocksName, editor, t, aiAllowed]); return ( { - 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 d59a09ab2..0fbaf71ab 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 @@ -8,21 +8,18 @@ import { import React, { JSX, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core/config/api'; - +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'; -export const BlockNoteToolbar = () => { +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([ @@ -56,16 +53,15 @@ export const BlockNoteToolbar = () => { const formattingToolbar = useCallback(() => { return ( - {toolbarItems} + {aiAllowed && } - {/* Extra button to do some AI powered actions */} - {conf?.AI_FEATURE_ENABLED && } + {toolbarItems} {/* Extra button to convert from markdown to json */} ); - }, [toolbarItems, conf?.AI_FEATURE_ENABLED]); + }, [toolbarItems, aiAllowed]); return ( <> diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useDictionary.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useDictionary.tsx new file mode 100644 index 000000000..3d35208d1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useDictionary.tsx @@ -0,0 +1,30 @@ +import * as locales from '@blocknote/core/locales'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useModuleDictionnaryAI } from '../components/AI'; + +export const useDictionary = () => { + const { i18n } = useTranslation(); + const lang = i18n.resolvedLanguage; + const aiDictionary = useModuleDictionnaryAI(); + const [dictionary, setDictionary] = useState(() => ({ + ...locales[lang as keyof typeof locales], + })); + + useEffect(() => { + const dictionary = locales[lang as keyof typeof locales]; + + if (!aiDictionary) { + setDictionary(dictionary); + return; + } + + setDictionary({ + ...dictionary, + ...aiDictionary, + }); + }, [aiDictionary, lang]); + + return dictionary; +}; 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 da02458e0..6d1a17cc7 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/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e14..26f3b2bd9 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,8 +49,7 @@ export interface Doc { abilities: { accesses_manage: boolean; accesses_view: boolean; - ai_transform: boolean; - ai_translate: boolean; + ai_proxy: 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 7a03f291d..8e2999f51 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,8 +179,7 @@ export class ApiPlugin implements WorkboxPlugin { abilities: { accesses_manage: true, accesses_view: true, - ai_transform: true, - ai_translate: true, + ai_proxy: true, attachment_upload: true, children_create: true, children_list: true, 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.", diff --git a/src/frontend/servers/y-provider/src/api/getDoc.ts b/src/frontend/servers/y-provider/src/api/getDoc.ts index 6c712f6da..155d89650 100644 --- a/src/frontend/servers/y-provider/src/api/getDoc.ts +++ b/src/frontend/servers/y-provider/src/api/getDoc.ts @@ -32,8 +32,7 @@ interface Doc { abilities: { accesses_manage: boolean; accesses_view: boolean; - ai_transform: boolean; - ai_translate: boolean; + ai_proxy: boolean; attachment_upload: boolean; children_create: boolean; children_list: boolean; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 462eefa14..c6b0469ea 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -12,6 +12,73 @@ 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/groq@^1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@ai-sdk/groq/-/groq-1.2.9.tgz#e3987bae374a714ab9dd3589bbf5e1e13e5f5751" + integrity sha512-7MoDaxm8yWtiRbD1LipYZG0kBl+Xe0sv/EeyxnHnGPZappXdlgtdOgTZVjjXkT3nWP30jjZi9A45zoVrBMb3Xg== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/mistral@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@ai-sdk/mistral/-/mistral-1.2.8.tgz#bc5d9923c11b5611d3c8a3ed483aa29596f34960" + integrity sha512-lv857D9UJqCVxiq2Fcu7mSPTypEHBUqLl1K+lCaP6X/7QAkcaxI36QDONG+tOhGHJOXTsS114u8lrUTaEiGXbg== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@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" + +"@ai-sdk/openai@1.3.22", "@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" @@ -1072,6 +1139,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.31.1": version "0.31.1" resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.31.1.tgz#f6fc384777c395f5aa0e2707e43d2bfb9219c041" @@ -1101,6 +1173,46 @@ y-protocols "^1.0.6" yjs "^13.6.15" +"@blocknote/xl-ai-server@0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai-server/-/xl-ai-server-0.31.0.tgz#e61b91b64c0f7eb0c68c1174ea4ad7ab3b8fa574" + integrity sha512-AcKmuS/eB2o/qSczUIvru9dZC8AWi4quMKSHrP9aDkQmXyPnvOvMIbqqgMBksx1HAvS3/+FBj1Dz4tbJIdpX9g== + dependencies: + "@hono/node-server" "^1.13.7" + hono "^4.6.12" + +"@blocknote/xl-ai@0.31.1": + version "0.31.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai/-/xl-ai-0.31.1.tgz#b63a4c77b6a99d782947f9f89a856e9c86868e4b" + integrity sha512-sebLiCuOcQaAhUyRnXPLFdg7q/v6Bjjj2yKCuqNxcME729Al64EEROgy+dtNDIsTjTabLXkTXOidRHpN+/pmkw== + dependencies: + "@ai-sdk/groq" "^1.2.9" + "@ai-sdk/mistral" "^1.2.8" + "@ai-sdk/openai" "^1.3.22" + "@ai-sdk/openai-compatible" "^0.2.14" + "@blocknote/core" "0.31.1" + "@blocknote/mantine" "0.31.1" + "@blocknote/prosemirror-suggest-changes" "^0.1.3" + "@blocknote/react" "0.31.1" + "@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.31.1": version "0.31.1" resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.31.1.tgz#dbf7bc6a1aea2ec8bf729337ed271cc73a104db5" @@ -1673,6 +1785,11 @@ uuid "^11.0.3" ws "^8.5.0" +"@hono/node-server@^1.13.7": + version "1.14.2" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.14.2.tgz#b9974d0322dd7b3467001ab371e28597c205b897" + integrity sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A== + "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -2310,7 +2427,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== @@ -6324,6 +6441,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" @@ -6480,6 +6602,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" @@ -6684,6 +6813,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" @@ -7133,6 +7267,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" @@ -7790,6 +7936,11 @@ chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0: 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" @@ -8477,6 +8628,11 @@ 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-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -8487,6 +8643,11 @@ diff@^4.0.1: 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" @@ -10087,6 +10248,11 @@ hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" +hono@^4.6.12: + version "4.7.10" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.7.10.tgz#c2400f068ac64e1b10b575b63321d494c9e70f86" + integrity sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ== + hookified@^1.8.2: version "1.9.0" resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.9.0.tgz#271211f61c63b3a68a8ead9d9fddd72b5806c004" @@ -10430,6 +10596,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" @@ -11275,6 +11446,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" @@ -11340,7 +11520,7 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -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== @@ -11604,6 +11784,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" @@ -11687,6 +11885,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" @@ -11710,6 +11916,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" @@ -11725,6 +11945,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" @@ -11797,6 +12024,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" @@ -11898,6 +12147,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" @@ -11907,6 +12165,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" @@ -11917,6 +12185,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" @@ -11925,6 +12201,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" @@ -11935,6 +12221,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" @@ -11945,6 +12241,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" @@ -11953,6 +12257,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" @@ -11960,6 +12271,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" @@ -11969,6 +12289,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" @@ -11977,6 +12305,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" @@ -11984,6 +12319,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" @@ -11994,16 +12339,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" @@ -12011,6 +12373,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" @@ -12018,6 +12387,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" @@ -12027,6 +12405,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" @@ -12037,16 +12425,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" @@ -12175,6 +12596,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" @@ -12994,7 +13420,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== @@ -13052,7 +13478,7 @@ 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: +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.39.3" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.39.3.tgz#54fa4b8ab4fd75ad0075dc6dc0be1745429d5a5c" integrity sha512-bY/7kg0LzRE7ytR0zRdSMWX3sknEjw68l836ffLPMh0OG3OYnNuBDUSF3v0vjvnzgYjgY9ZH/RypbARURlcMFA== @@ -13449,7 +13875,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== @@ -13683,7 +14109,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== @@ -13875,6 +14301,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" @@ -13896,6 +14331,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" @@ -14107,6 +14551,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" @@ -14182,6 +14633,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.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -14892,6 +15348,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" @@ -14994,6 +15458,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" @@ -15367,6 +15836,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" @@ -15395,6 +15877,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" @@ -15409,6 +15898,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" @@ -15416,6 +15912,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" @@ -15424,6 +15928,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" @@ -15578,6 +16091,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" @@ -15610,6 +16133,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" @@ -15618,6 +16149,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" @@ -16289,7 +16830,17 @@ 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== -zustand@5.0.5: +zod-to-json-schema@^3.24.1: + version "3.24.5" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" + integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== + +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, zustand@^5.0.3: version "5.0.5" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.5.tgz#3e236f6a953142d975336d179bc735d97db17e84" integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==