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 ea821ad15..9f20678f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - (doc) add documentation to install with compose #855 - ✨(backend) allow to disable checking unsafe mimetype on attachment upload - ✨Ask for access #1081 +- ✨(frontend) integrate new Blocknote AI feature #1016 ### Changed diff --git a/docs/env.md b/docs/env.md index ac6e18e24..a7794b860 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,72 +11,73 @@ These are the environment variables you can set for the `impress-backend` contai | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | AI_API_KEY | AI key to be used for AI Base url | | | AI_BASE_URL | OpenAI compatible AI base url | | +| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" } | | AI_FEATURE_ENABLED | Enable AI options | false | | AI_MODEL | AI Model to use | | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | API_USERS_LIST_LIMIT | Limit on API users | 5 | -| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute | -| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour | -| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | | +| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | +| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | +| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | | -| AWS_S3_REGION_NAME | region name for s3 endpoint | | -| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | | -| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage | -| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | +| AWS_S3_REGION_NAME | Region name for s3 endpoint | | +| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | +| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | +| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | -| COLLABORATION_API_URL | collaboration api host | | -| COLLABORATION_SERVER_SECRET | collaboration api secret | | -| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | -| COLLABORATION_WS_URL | collaboration websocket url | | +| COLLABORATION_API_URL | Collaboration api host | | +| COLLABORATION_SERVER_SECRET | Collaboration api secret | | +| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | +| COLLABORATION_WS_URL | Collaboration websocket url | | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown | | CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' | | CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | | -| CRISP_WEBSITE_ID | crisp website id for support | | -| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 | -| DB_HOST | host of the database | localhost | -| DB_NAME | name of the database | impress | -| DB_PASSWORD | password to authenticate with | pass | -| DB_PORT | port of the database | 5432 | -| DB_USER | user to authenticate with | dinum | -| DJANGO_ALLOWED_HOSTS | allowed hosts | [] | -| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} | -| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | -| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false | -| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] | -| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] | +| CRISP_WEBSITE_ID | Crisp website id for support | | +| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | +| DB_HOST | Host of the database | localhost | +| DB_NAME | Name of the database | impress | +| DB_PASSWORD | Password to authenticate with | pass | +| DB_PORT | Port of the database | 5432 | +| DB_USER | User to authenticate with | dinum | +| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | +| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | +| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | +| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | +| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | +| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | -| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend | -| DJANGO_EMAIL_BRAND_NAME | brand name for email | | -| DJANGO_EMAIL_FROM | email address used as sender | from@example.com | -| DJANGO_EMAIL_HOST | host name of email | | -| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | | -| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | | -| DJANGO_EMAIL_LOGO_IMG | logo for the email | | -| DJANGO_EMAIL_PORT | port used to connect to email host | | -| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false | -| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false | -| DJANGO_SECRET_KEY | secret key | | +| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | +| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | +| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | +| DJANGO_EMAIL_HOST | Hostname of email | | +| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | +| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | +| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | +| DJANGO_EMAIL_PORT | Port used to connect to email host | | +| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | +| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | +| DJANGO_SECRET_KEY | Secret key | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | -| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 | +| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | FRONTEND_CSS_URL | To add a external css file to the app | | -| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false | -| FRONTEND_THEME | frontend theme to use | | -| LANGUAGE_CODE | default language | en-us | -| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGIN_REDIRECT_URL | login redirect url | | -| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | | -| LOGOUT_REDIRECT_URL | logout redirect url | | -| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | +| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | +| FRONTEND_THEME | Frontend theme to use | | +| LANGUAGE_CODE | Default language | en-us | +| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGIN_REDIRECT_URL | Login redirect url | | +| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | +| LOGOUT_REDIRECT_URL | Logout redirect url | | +| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | MEDIA_BASE_URL | | | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | -| OIDC_CREATE_USER | create used on OIDC | false | -| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | +| OIDC_CREATE_USER | Create used on OIDC | false | +| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | @@ -84,24 +85,24 @@ These are the environment variables you can set for the `impress-backend` contai | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | -| OIDC_RP_CLIENT_ID | client id used for OIDC | impress | -| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | | -| OIDC_RP_SCOPES | scopes requested for OIDC | openid email | -| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | +| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | +| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | +| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | +| OIDC_RP_SIGN_ALGO | Verification algorithm used OIDC tokens | RS256 | | OIDC_STORE_ID_TOKEN | Store OIDC token | true | -| OIDC_USE_NONCE | use nonce for OIDC | true | +| OIDC_USE_NONCE | Use nonce for OIDC | true | | OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | -| POSTHOG_KEY | posthog key for analytics | | -| REDIS_URL | cache url | redis://redis:6379/1 | -| SENTRY_DSN | sentry host | | -| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | +| POSTHOG_KEY | Posthog key for analytics | | +| REDIS_URL | Cache url | redis://redis:6379/1 | +| SENTRY_DSN | Sentry host | | +| SESSION_COOKIE_AGE | Duration of the cookie session | 60*60*12 | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | -| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | -| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 | -| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | +| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | +| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | | Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | | diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 8a0d3d9ef..beb2a6ab5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -13,7 +13,6 @@ from rest_framework import exceptions, serializers from core import enums, models, utils -from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, YdocConverter, @@ -718,33 +717,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 78fc02428..3156005e0 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -387,21 +387,8 @@ class DocumentViewSet( 9. **Media Auth**: Authorize access to document media. Example: GET /documents/media-auth/ - 10. **AI Transform**: Apply a transformation action on a piece of text with AI. - Example: POST /documents/{id}/ai-transform/ - Expected data: - - text (str): The input text. - - action (str): The transformation type, one of [prompt, correct, rephrase, summarize]. - Returns: JSON response with the processed text. - Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. - - 11. **AI Translate**: Translate a piece of text with AI. - Example: POST /documents/{id}/ai-translate/ - Expected data: - - text (str): The input text. - - language (str): The target language, chosen from settings.LANGUAGES. - Returns: JSON response with the translated text. - Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. + 10. **AI Proxy**: Proxy an AI request to an external AI service. + Example: POST /api/v1.0/documents//ai-proxy ### Ordering: created_at, updated_at, is_favorite, title @@ -438,7 +425,6 @@ class DocumentViewSet( ] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer children_serializer_class = serializers.ListDocumentSerializer descendants_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer @@ -1356,58 +1342,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) - - 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() + if not settings.AI_FEATURE_ENABLED: + raise ValidationError("AI feature is not enabled.") - 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"] + ai_service = AIService() - response = AIService().translate(text, language) + if settings.AI_STREAM: + return StreamingHttpResponse( + ai_service.stream(request.data), + content_type="text/event-stream", + status=drf.status.HTTP_200_OK, + ) - return drf.response.Response(response, status=drf.status.HTTP_200_OK) + ai_response = ai_service.proxy(request.data) + return drf.response.Response( + ai_response.model_dump(), + status=drf.status.HTTP_200_OK, + ) @drf.decorators.action( detail=True, @@ -1863,7 +1830,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 fb3443ce9..9c83ce867 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -832,8 +832,7 @@ def get_abilities(self, user, ancestors_links=None): return { "accesses_manage": is_owner_or_admin, "accesses_view": has_access_role, - "ai_transform": ai_access, - "ai_translate": ai_access, + "ai_proxy": ai_access, "attachment_upload": can_update, "media_check": can_get, "children_list": can_get, diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index 97ad583d8..8c3dd2273 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 +from openai import OpenAI, OpenAIError -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,18 @@ def __init__(self): raise ImproperlyConfigured("AI configuration not set") self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY) - def call_ai_api(self, system_content, text): - """Helper method to call the OpenAI API and process the response.""" - response = self.client.chat.completions.create( - model=settings.AI_MODEL, - messages=[ - {"role": "system", "content": system_content}, - {"role": "user", "content": text}, - ], - ) - - content = response.choices[0].message.content - - if not content: - raise RuntimeError("AI response does not contain an answer") - - return {"answer": content} - - def transform(self, text, action): - """Transform text based on specified action.""" - system_content = AI_ACTIONS[action] - return self.call_ai_api(system_content, text) - - def translate(self, text, language): - """Translate text to a specified language.""" - language_display = enums.ALL_LANGUAGES.get(language, language) - system_content = AI_TRANSLATE.format(language=language_display) - return self.call_ai_api(system_content, text) + def proxy(self, data: dict, stream: bool = False) -> Generator[str, None, None]: + """Proxy AI API requests to the configured AI provider.""" + data["stream"] = stream + try: + return self.client.chat.completions.create(**data) + except OpenAIError as e: + raise RuntimeError(f"Failed to proxy AI request: {e}") from e + + def stream(self, data: dict) -> Generator[str, None, None]: + """Stream AI API requests to the configured AI provider.""" + stream = self.proxy(data, stream=True) + for chunk in stream: + yield f"data: {chunk.model_dump_json()}\n\n" + + yield "data: [DONE]\n\n" diff --git a/src/backend/core/tests/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 cdc4a9cb2..6f85f5739 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": {}, } policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ae10fb55b..9046776a0 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..5818dc326 100644 --- a/src/backend/core/tests/test_services_ai_services.py +++ b/src/backend/core/tests/test_services_ai_services.py @@ -2,10 +2,9 @@ Test ai API endpoints in the impress core app. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from django.core.exceptions import ImproperlyConfigured -from django.test.utils import override_settings import pytest from openai import OpenAIError @@ -15,6 +14,15 @@ pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def ai_settings(settings): + """Fixture to set AI settings.""" + settings.AI_MODEL = "llama" + settings.AI_BASE_URL = "http://example.com" + settings.AI_API_KEY = "test-key" + settings.AI_FEATURE_ENABLED = True + + @pytest.mark.parametrize( "setting_name, setting_value", [ @@ -23,62 +31,105 @@ ("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") with pytest.raises( - OpenAIError, - match="Mocked client error", + RuntimeError, + match="Failed to proxy AI request: 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 dd1d3acc3..9d364193f 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -603,24 +603,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 = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None) - AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) - AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + # AI settings AI_ALLOW_REACH_FROM = values.Value( choices=("public", "authenticated", "restricted"), default="authenticated", environ_name="AI_ALLOW_REACH_FROM", environ_prefix=None, ) + AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None) + AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) + AI_BOT = values.DictValue( + default={ + "name": _("Docs AI"), + "color": "#8bc6ff", + }, + environ_name="AI_BOT", + environ_prefix=None, + ) AI_DOCUMENT_RATE_THROTTLE_RATES = { "minute": 5, "hour": 100, "day": 500, } + AI_FEATURE_ENABLED = values.BooleanValue( + default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None + ) + AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + AI_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 c91c42d78..ff50bdc28 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -3,7 +3,13 @@ import { Page, expect } from '@playwright/test'; export const BROWSERS = ['chromium', 'webkit', 'firefox']; export const CONFIG = { + AI_BOT: { + name: 'Docs AI', + color: '#8bc6ff', + }, AI_FEATURE_ENABLED: true, + AI_MODEL: 'llama', + 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 52889171b..8a33b6927 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -91,9 +91,7 @@ test.describe('Config', () => { expect( await page.locator('button[data-test="convertMarkdown"]').count(), ).toBe(1); - expect(await page.locator('button[data-test="ai-actions"]').count()).toBe( - 0, - ); + await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); }); test('it checks that Crisp is trying to init from config endpoint', async ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index f64139b79..94706699f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -9,6 +9,7 @@ import { createDoc, goToGridDoc, mockedDocument, + overrideConfig, verifyDocName, } from './common'; @@ -285,13 +286,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 { @@ -304,122 +364,84 @@ 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('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 page.getByRole('menuitem', { name: 'English', exact: true }).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(); + + // Reload the page to check that the AI change is still there + await page.goto(page.url()); await expect(editor.getByText('Bonjour le monde')).toBeVisible(); }); - [ - { 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 28a07f8fd..721298849 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -77,7 +77,7 @@ test.describe.serial('Language', () => { page, browserName, }) => { - await createDoc(page, 'doc-toolbar', browserName, 1); + await createDoc(page, 'doc-translations-slash', browserName, 1); const editor = page.locator('.ProseMirror'); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index b21090d8a..e95e880ad 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -16,10 +16,12 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", + "@ai-sdk/openai-compatible": "0.2.14", "@blocknote/code-block": "0.32.0", "@blocknote/core": "0.32.0", "@blocknote/mantine": "0.32.0", "@blocknote/react": "0.32.0", + "@blocknote/xl-ai": "0.32.0", "@blocknote/xl-docx-exporter": "0.32.0", "@blocknote/xl-pdf-exporter": "0.32.0", "@emoji-mart/data": "1.2.1", @@ -32,6 +34,7 @@ "@react-pdf/renderer": "4.3.0", "@sentry/nextjs": "9.32.0", "@tanstack/react-query": "5.81.2", + "ai": "4.3.16", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -55,6 +58,7 @@ "use-debounce": "10.0.5", "y-protocols": "1.0.6", "yjs": "*", + "zod": "3.25.28", "zustand": "5.0.5" }, "devDependencies": { diff --git a/src/frontend/apps/impress/src/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/assets/wand_stars.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg new file mode 100644 index 000000000..743b871a1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx new file mode 100644 index 000000000..be6e784ac --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -0,0 +1,183 @@ +import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react'; +import { + AIMenu as AIMenuDefault, + getAIExtension, + getDefaultAIMenuItems, +} from '@blocknote/xl-ai'; +import '@blocknote/xl-ai/style.css'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle, css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import IconAI from '../../assets/IconAI.svg'; +import IconWandStar from '../../assets/wand_stars.svg'; +import { + DocsBlockNoteEditor, + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; + +const AIMenuStyle = createGlobalStyle` + #ai-suggestion-menu .bn-suggestion-menu-item-small .bn-mt-suggestion-menu-item-section[data-position=left] svg { + height: 18px; + width: 18px; + } +`; + +export function AIMenu() { + return ( + <> + + { + if (aiResponseStatus === 'user-input') { + let aiMenuItems = getDefaultAIMenuItems(editor, aiResponseStatus); + + if (editor.getSelection()) { + aiMenuItems = aiMenuItems.filter( + (item) => ['simplify'].indexOf(item.key) === -1, + ); + + aiMenuItems = aiMenuItems.map((item) => { + if (item.key === 'improve_writing') { + return { + ...item, + icon: , + }; + } else if (item.key === 'translate') { + return { + ...item, + icon: ( + + ), + }; + } + + return item; + }); + } else { + aiMenuItems = aiMenuItems.filter( + (item) => + ['action_items', 'write_anything'].indexOf(item.key) === -1, + ); + } + + return aiMenuItems; + } else if (aiResponseStatus === 'user-reviewing') { + return getDefaultAIMenuItems(editor, aiResponseStatus).map( + (item) => { + if (item.key === 'accept') { + return { + ...item, + icon: ( + + ), + }; + } + return item; + }, + ); + } + + return getDefaultAIMenuItems(editor, aiResponseStatus); + }} + /> + + ); +} + +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..c50a690b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts @@ -0,0 +1,25 @@ +/** + * To import AI modules you must import from the index file. + * This is to ensure that the AI modules are only loaded when + * the application is not published as MIT. + */ +import * as XLAI from '@blocknote/xl-ai'; +import * as localesAI from '@blocknote/xl-ai/locales'; + +import * as AIUI from './AIUI'; +import * as useAI from './useAI'; + +let modulesAI = undefined; +if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { + modulesAI = { + ...XLAI, + ...AIUI, + localesAI: localesAI, + ...useAI, + }; +} + +type ModulesAI = typeof XLAI & + typeof AIUI & { localesAI: typeof localesAI } & typeof useAI; + +export default modulesAI as ModulesAI; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx new file mode 100644 index 000000000..8c634215f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -0,0 +1,46 @@ +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { createAIExtension } from '@blocknote/xl-ai'; +import { useMemo } from 'react'; + +import { baseApiUrl, fetchAPI } from '@/api'; +import { useConfig } from '@/core'; +import { Doc } from '@/docs/doc-management'; + +import { usePromptAI } from './usePromptAI'; + +export const useAI = (docId: Doc['id'], aiAllowed: boolean) => { + const conf = useConfig().data; + const promptBuilder = usePromptAI(); + + return useMemo(() => { + if (!aiAllowed || !conf?.AI_MODEL) { + return; + } + + const openai = createOpenAICompatible({ + name: 'AI Proxy', + baseURL: `${baseApiUrl('1.0')}documents/${docId}/ai-proxy/`, // Necessary for initialization.. + fetch: (input, init) => { + // Create a new headers object without the Authorization header + const headers = new Headers(init?.headers); + headers.delete('Authorization'); + + return fetchAPI(`documents/${docId}/ai-proxy/`, { + ...init, + headers, + }); + }, + }); + + const model = openai.chatModel(conf.AI_MODEL); + + const extension = createAIExtension({ + stream: conf.AI_STREAM, + model, + agentCursor: conf?.AI_BOT, + promptBuilder, + }); + + return extension; + }, [aiAllowed, conf, docId, promptBuilder]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx new file mode 100644 index 000000000..34b2cece8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/usePromptAI.tsx @@ -0,0 +1,154 @@ +import { Block } from '@blocknote/core'; +import { llmFormats } from '@blocknote/xl-ai'; +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; +}; + +/** + * 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( + async ( + editor: DocsBlockNoteEditor, + opts: PromptBuilderInput, + ): Promise> => { + const systemPrompts: Record< + | 'add-edit-instruction' + | 'add-formatting' + | 'add-markdown' + | 'assistant' + | 'language' + | 'referenceId', + CoreMessage + > = { + assistant: { + role: 'system', + content: t(`You are an AI assistant that edits user documents.`), + }, + referenceId: { + role: 'system', + content: t( + `Keep block IDs exactly as provided when referencing them (including the trailing "$").`, + ), + }, + 'add-markdown': { + role: 'system', + content: t(`Answer the user prompt in markdown format.`), + }, + 'add-formatting': { + role: 'system', + content: t(`Add formatting to the text to make it more readable.`), + }, + 'add-edit-instruction': { + role: 'system', + content: t( + `Add content; do not delete or alter existing blocks unless explicitly told.`, + ), + }, + language: { + role: 'system', + content: t( + `Detect the dominant language inside the provided blocks. YOU MUST PROVIDE AN ANSWER IN THE DETECTED LANGUAGE.`, + ), + }, + }; + + const userPrompts: Record = { + 'continue writing': t( + 'Keep writing about the content sent in the prompt, expanding on the ideas.', + ), + 'improve writing': t( + 'Improve the writing of the selected text. Make it more professional and clear.', + ), + summarize: t('Summarize the document into a concise paragraph.'), + 'fix spelling': t( + 'Fix the spelling and grammar mistakes in the selected text.', + ), + }; + + // Modify userPrompt if it matches a custom prompt + const customPromptMatch = opts.userPrompt.match(/^([^:]+)(?=[:]|$)/); + let modifiedOpts = opts; + const promptKey = customPromptMatch?.[0].trim().toLowerCase(); + if (promptKey) { + if (userPrompts[promptKey]) { + modifiedOpts = { + ...opts, + userPrompt: userPrompts[promptKey], + }; + } + } + let prompts = await llmFormats.html.defaultPromptBuilder( + editor, + modifiedOpts, + ); + const isTransformExistingContent = !!opts.selectedBlocks?.length; + if (!isTransformExistingContent) { + prompts = prompts.map((prompt) => { + if (!prompt.content || typeof prompt.content !== 'string') { + return prompt; + } + + /** + * 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 9a486b586..243b44f99 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; +import { useConfig } from '@/core'; import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; @@ -25,10 +26,16 @@ import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; +import BlockNoteAI from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, DividerBlock } from './custom-blocks'; +const AIMenu = BlockNoteAI?.AIMenu; +const AIMenuController = BlockNoteAI?.AIMenuController; +const useAI = BlockNoteAI?.useAI; +const localesAI = BlockNoteAI?.localesAI; + export const blockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { @@ -47,16 +54,18 @@ interface BlockNoteEditorProps { export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); - const { t } = useTranslation(); const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; useSaveDoc(doc.id, provider.document, !readOnly); - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); const lang = i18n.resolvedLanguage; const { uploadFile, errorAttachment } = useUploadFile(doc.id); + const conf = useConfig().data; + const aiAllowed = !!(conf?.AI_FEATURE_ENABLED && doc.abilities?.ai_proxy); + const aiExtension = useAI?.(doc.id, aiAllowed); const collabName = readOnly ? 'Reader' @@ -115,7 +124,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, - dictionary: locales[lang as keyof typeof locales], + dictionary: { + ...locales[lang as keyof typeof locales], + ai: localesAI?.[lang as keyof typeof localesAI], + }, + extensions: aiExtension ? [aiExtension] : [], tables: { splitCells: true, cellBackgroundColor: true, @@ -125,7 +138,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [aiExtension, collabName, lang, provider, uploadFile], ); useHeadings(editor); @@ -163,8 +176,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { editable={!readOnly} theme="light" > - - + {aiAllowed && AIMenuController && AIMenu && ( + + )} + +
); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 3122b1c17..fe2f50842 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -11,12 +11,19 @@ import { useTranslation } from 'react-i18next'; import { DocsBlockSchema } from '../types'; +import BlockNoteAI from './AI'; import { getCalloutReactSlashMenuItems, getDividerReactSlashMenuItems, } from './custom-blocks'; -export const BlockNoteSuggestionMenu = () => { +const getAISlashMenuItems = BlockNoteAI?.getAISlashMenuItems; + +export const BlockNoteSuggestionMenu = ({ + aiAllowed, +}: { + aiAllowed: boolean; +}) => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; @@ -30,11 +37,12 @@ export const BlockNoteSuggestionMenu = () => { 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..a3285d51d 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,20 @@ import { import React, { JSX, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core/config/api'; - +import BlockNoteAI 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 = () => { +const AIToolbarButton = BlockNoteAI?.AIToolbarButton; + +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 +55,15 @@ export const BlockNoteToolbar = () => { const formattingToolbar = useCallback(() => { return ( - {toolbarItems} + {aiAllowed && AIToolbarButton && } - {/* 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/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 274adcfff..7721ec73c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -31,7 +31,20 @@ 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 constructor to know where + * the transaction comes from. + * + * TODO: see if we can get the local changes from the AI + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const transactionOrigin = transaction?.origin?.constructor?.name; + const AI_ORIGIN_CONSTRUCTOR = 'ao'; + + setIsLocalChange( + transaction.local || transactionOrigin === AI_ORIGIN_CONSTRUCTOR, + ); }; yDoc.on('update', onUpdate); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index f24cd9524..3685d24ca 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -1,6 +1,11 @@ import { css } from 'styled-components'; export const cssEditor = (readonly: boolean) => css` + .mantine-Menu-itemLabel, + .mantine-Button-label { + font-family: var(--c--components--button--font-family); + } + &, & > .bn-container, & .ProseMirror { @@ -108,6 +113,19 @@ export const cssEditor = (readonly: boolean) => css` border-left: 4px solid var(--c--theme--colors--greyscale-300); font-style: italic; } + + /** + * AI + */ + ins, + [data-type='modification'] { + background: var(--c--theme--colors--primary-100); + border-bottom: 2px solid var(--c--theme--colors--primary-300); + color: var(--c--theme--colors--primary-700); + } + [data-show-selection] { + background-color: var(--c--theme--colors--primary-300); + } } & .bn-block-outer:not(:first-child) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index 2cc2b6c17..fc06f311c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -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 49485277a..7cd9d3686 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -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..1b1642650 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.": "Tu es un assistant IA qui aide les utilisateurs à éditer leurs documents.", + "Answer the user prompt in markdown format.": "Réponds à la demande de l'utilisateur au format markdown.", + "Add formatting to the text to make it more readable.": "Ajoute du formatage au texte pour le rendre plus lisible.", + "Keep adding to the document, do not delete or modify existing blocks.": "Continue d'ajouter au document, ne supprime ni ne modifie les blocs existants.", + "Your answer must be in the same language as the document.": "Ta réponse doit être dans la même langue que le document.", + "Fix the spelling and grammar mistakes in the selected text.": "Corrige les fautes d'orthographe et de grammaire dans le texte sélectionné.", + "Improve the writing of the selected text. Make it more professional and clear.": "Améliore l'écriture du texte sélectionné. Rends-le plus professionnel et clair.", + "Summarize the document into a concise paragraph.": "Résume le document en un paragraphe concis.", + "Keep writing about the content sent in the prompt, expanding on the ideas.": "Continue à écrire sur le contenu envoyé dans la demande, en développant les idées.", + "Important, verified the language of the document! Your answer MUST be in the same language as the document. If the document is in English, your answer MUST be in English. If the document is in Spanish, your answer MUST be in Spanish, etc.": "Important, vérifie la langue du document ! Ta réponse DOIT être dans la même langue que le document. Si le document est en anglais, ta réponse DOIT être en anglais. Si le document est en espagnol, ta réponse DOIT être en espagnol, etc.", "You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.", "You do not have permission to view this document.": "Vous n'avez pas la permission de voir ce document.", "You do not have permission to view users sharing this document or modify link settings.": "Vous n'avez pas la permission de voir les utilisateurs partageant ce document ou de modifier les paramètres du lien.", diff --git a/src/frontend/package.json b/src/frontend/package.json index acef77729..0fc3bb4a8 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -34,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "eslint": "8.57.0", + "prosemirror-view": "1.33.7", "react": "19.1.0", "react-dom": "19.1.0", "typescript": "5.8.3", diff --git a/src/frontend/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 8f5f3ecb7..c8201b304 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -12,6 +12,49 @@ resolved "https://registry.yarnpkg.com/@ag-media/react-pdf-table/-/react-pdf-table-2.0.3.tgz#113554b583b46e41a098cf64fecb5decd59ba004" integrity sha512-IscjfAOKwsyQok9YmzvuToe6GojN7J8hF0kb8C+K8qZX1DvhheGO+hRSAPxbv2nKMbSpvk7CIhSqJEkw++XVWg== +"@ai-sdk/openai-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/provider-utils@2.2.8": + version "2.2.8" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz#ad11b92d5a1763ab34ba7b5fc42494bfe08b76d1" + integrity sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA== + dependencies: + "@ai-sdk/provider" "1.1.3" + nanoid "^3.3.8" + secure-json-parse "^2.7.0" + +"@ai-sdk/provider@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-1.1.3.tgz#ebdda8077b8d2b3f290dcba32c45ad19b2704681" + integrity sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/react@1.2.12": + version "1.2.12" + resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-1.2.12.tgz#f4250b6df566b170af98a71d5708b52108dd0ce1" + integrity sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g== + dependencies: + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/ui-utils" "1.2.11" + swr "^2.2.5" + throttleit "2.1.0" + +"@ai-sdk/ui-utils@1.2.11": + version "1.2.11" + resolved "https://registry.yarnpkg.com/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz#4f815589d08d8fef7292ade54ee5db5d09652603" + integrity sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + zod-to-json-schema "^3.24.1" + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" @@ -1165,6 +1208,11 @@ "@mantine/utils" "^6.0.21" react-icons "^5.2.1" +"@blocknote/prosemirror-suggest-changes@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@blocknote/prosemirror-suggest-changes/-/prosemirror-suggest-changes-0.1.3.tgz#1c2e99d7a34c3c29597d1a4a79ce0a9bff202f66" + integrity sha512-g/RxkStEg67bGiujK092aabD6tA/O4jI9jwKAnasP+t00khgyBBIXIvp7K7QT+gjVB6kP+ez27669iiJy8MZ5w== + "@blocknote/react@0.32.0": version "0.32.0" resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.32.0.tgz#425127ff217af27c0d648d905d062066c4a89c9f" @@ -1194,6 +1242,34 @@ y-protocols "^1.0.6" yjs "^13.6.15" +"@blocknote/xl-ai@0.32.0": + version "0.32.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai/-/xl-ai-0.32.0.tgz#ff496a63981c6462717596d442caf36941f172cb" + integrity sha512-jMntc17tujtcJ64n/rdhWGgGqIA5P1j62ArE5P9Fx+ing5Whh+TVfIBmqO6FtNjJd8jjzTmey1VKq0KDcRhFag== + dependencies: + "@blocknote/core" "0.32.0" + "@blocknote/mantine" "0.32.0" + "@blocknote/prosemirror-suggest-changes" "^0.1.3" + "@blocknote/react" "0.32.0" + "@floating-ui/react" "^0.26.4" + "@tiptap/core" "^2.12.0" + ai "^4.3.15" + lodash.isequal "^4.5.0" + prosemirror-changeset "^2.3.0" + prosemirror-model "^1.24.1" + prosemirror-state "^1.4.3" + prosemirror-tables "^1.6.4" + prosemirror-transform "^1.10.4" + prosemirror-view "^1.33.7" + react "^18" + react-dom "^18" + react-icons "^5.2.1" + remark-parse "^10.0.1" + remark-stringify "^10.0.2" + unified "^10.1.2" + y-prosemirror "^1.3.4" + zustand "^5.0.3" + "@blocknote/xl-docx-exporter@0.32.0": version "0.32.0" resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.32.0.tgz#77f59249fefc08a375d32e2a4907f037da5aefba" @@ -2491,7 +2567,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -6550,6 +6626,11 @@ dependencies: "@types/ms" "*" +"@types/diff-match-patch@^1.0.36": + version "1.0.36" + resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af" + integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg== + "@types/eslint-scope@^3.7.7": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -6699,6 +6780,13 @@ "@types/linkify-it" "^5" "@types/mdurl" "^2" +"@types/mdast@^3.0.0": + version "3.0.15" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5" + integrity sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ== + dependencies: + "@types/unist" "^2" + "@types/mdast@^4.0.0": version "4.0.4" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" @@ -6903,6 +6991,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== +"@types/unist@^2", "@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@types/use-sync-external-store@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" @@ -7389,11 +7482,6 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -"@yarnpkg/lockfile@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" - integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== - abs-svg-path@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf" @@ -7441,6 +7529,18 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== +ai@4.3.16, ai@^4.3.15: + version "4.3.16" + resolved "https://registry.yarnpkg.com/ai/-/ai-4.3.16.tgz#c9446da1024cdc1dfe2913d151b70c91d40f2378" + integrity sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/react" "1.2.12" + "@ai-sdk/ui-utils" "1.2.11" + "@opentelemetry/api" "1.9.0" + jsondiffpatch "0.6.0" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -8111,6 +8211,11 @@ chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -8190,11 +8295,6 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -ci-info@^3.7.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== - ci-info@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.2.0.tgz#cbd21386152ebfe1d56f280a3b5feccbd96764c7" @@ -8777,11 +8877,21 @@ dfa@^1.2.0: resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -9834,13 +9944,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-yarn-workspace-root@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" - integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== - dependencies: - micromatch "^4.0.2" - flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -9951,7 +10054,7 @@ fs-extra@^8.0.1, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.0.0, fs-extra@^9.0.1: +fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -10228,7 +10331,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.8: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -10836,6 +10939,11 @@ is-boolean-object@^1.2.1: call-bound "^1.0.3" has-tostringtag "^1.0.2" +is-buffer@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-bun-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" @@ -10872,11 +10980,6 @@ is-date-object@^1.0.5, is-date-object@^1.1.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" -is-docker@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -11077,13 +11180,6 @@ is-weakset@^2.0.3: call-bound "^1.0.3" get-intrinsic "^1.2.6" -is-wsl@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -11673,17 +11769,6 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json-stable-stringify@^1.0.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" - integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - isarray "^2.0.5" - jsonify "^0.0.1" - object-keys "^1.1.1" - json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -11696,6 +11781,15 @@ json5@^2.2.0, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsondiffpatch@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" + integrity sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ== + dependencies: + "@types/diff-match-patch" "^1.0.36" + chalk "^5.3.0" + diff-match-patch "^1.0.5" + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -11712,11 +11806,6 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsonify@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" - integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== - jsonpointer@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" @@ -11761,14 +11850,7 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== -klaw-sync@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" - integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== - dependencies: - graceful-fs "^4.1.11" - -kleur@^4.1.4: +kleur@^4.0.3, kleur@^4.1.4: version "4.1.5" resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== @@ -12032,6 +12114,24 @@ mdast-util-find-and-replace@^3.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" +mdast-util-from-markdown@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0" + integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + decode-named-character-reference "^1.0.0" + mdast-util-to-string "^3.1.0" + micromark "^3.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-decode-string "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + unist-util-stringify-position "^3.0.0" + uvu "^0.5.0" + mdast-util-from-markdown@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" @@ -12115,6 +12215,14 @@ mdast-util-gfm@^3.0.0: mdast-util-gfm-task-list-item "^2.0.0" mdast-util-to-markdown "^2.0.0" +mdast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz#c7c21d0d435d7fb90956038f02e8702781f95463" + integrity sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg== + dependencies: + "@types/mdast" "^3.0.0" + unist-util-is "^5.0.0" + mdast-util-phrasing@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" @@ -12138,6 +12246,20 @@ mdast-util-to-hast@^13.0.0: unist-util-visit "^5.0.0" vfile "^6.0.0" +mdast-util-to-markdown@^1.0.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz#c13343cb3fc98621911d33b5cd42e7d0731171c6" + integrity sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A== + dependencies: + "@types/mdast" "^3.0.0" + "@types/unist" "^2.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^3.0.0" + mdast-util-to-string "^3.0.0" + micromark-util-decode-string "^1.0.0" + unist-util-visit "^4.0.0" + zwitch "^2.0.0" + mdast-util-to-markdown@^2.0.0: version "2.1.2" resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" @@ -12153,6 +12275,13 @@ mdast-util-to-markdown@^2.0.0: unist-util-visit "^5.0.0" zwitch "^2.0.0" +mdast-util-to-string@^3.0.0, mdast-util-to-string@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789" + integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-string@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" @@ -12225,6 +12354,28 @@ methods@^1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" + integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-factory-destination "^1.0.0" + micromark-factory-label "^1.0.0" + micromark-factory-space "^1.0.0" + micromark-factory-title "^1.0.0" + micromark-factory-whitespace "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-classify-character "^1.0.0" + micromark-util-html-tag-name "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + micromark-core-commonmark@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" @@ -12326,6 +12477,15 @@ micromark-extension-gfm@^3.0.0: micromark-util-combine-extensions "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-destination@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f" + integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-destination@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" @@ -12335,6 +12495,16 @@ micromark-factory-destination@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-label@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68" + integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-factory-label@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" @@ -12345,6 +12515,14 @@ micromark-factory-label@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-space@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf" + integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-space@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" @@ -12353,6 +12531,16 @@ micromark-factory-space@^2.0.0: micromark-util-character "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-title@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1" + integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-title@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" @@ -12363,6 +12551,16 @@ micromark-factory-title@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-factory-whitespace@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705" + integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-factory-whitespace@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" @@ -12373,6 +12571,14 @@ micromark-factory-whitespace@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-character@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc" + integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-character@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" @@ -12381,6 +12587,13 @@ micromark-util-character@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-chunked@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b" + integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-chunked@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" @@ -12388,6 +12601,15 @@ micromark-util-chunked@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-classify-character@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d" + integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-classify-character@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" @@ -12397,6 +12619,14 @@ micromark-util-classify-character@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-combine-extensions@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84" + integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-types "^1.0.0" + micromark-util-combine-extensions@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" @@ -12405,6 +12635,13 @@ micromark-util-combine-extensions@^2.0.0: micromark-util-chunked "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-decode-numeric-character-reference@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6" + integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-decode-numeric-character-reference@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" @@ -12412,6 +12649,16 @@ micromark-util-decode-numeric-character-reference@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-decode-string@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c" + integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-decode-string@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" @@ -12422,16 +12669,33 @@ micromark-util-decode-string@^2.0.0: micromark-util-decode-numeric-character-reference "^2.0.0" micromark-util-symbol "^2.0.0" +micromark-util-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5" + integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw== + micromark-util-encode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== +micromark-util-html-tag-name@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588" + integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q== + micromark-util-html-tag-name@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== +micromark-util-normalize-identifier@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7" + integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q== + dependencies: + micromark-util-symbol "^1.0.0" + micromark-util-normalize-identifier@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" @@ -12439,6 +12703,13 @@ micromark-util-normalize-identifier@^2.0.0: dependencies: micromark-util-symbol "^2.0.0" +micromark-util-resolve-all@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188" + integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA== + dependencies: + micromark-util-types "^1.0.0" + micromark-util-resolve-all@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" @@ -12446,6 +12717,15 @@ micromark-util-resolve-all@^2.0.0: dependencies: micromark-util-types "^2.0.0" +micromark-util-sanitize-uri@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d" + integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-sanitize-uri@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" @@ -12455,6 +12735,16 @@ micromark-util-sanitize-uri@^2.0.0: micromark-util-encode "^2.0.0" micromark-util-symbol "^2.0.0" +micromark-util-subtokenize@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1" + integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-util-subtokenize@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" @@ -12465,16 +12755,49 @@ micromark-util-subtokenize@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-util-symbol@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142" + integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag== + micromark-util-symbol@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== +micromark-util-types@^1.0.0, micromark-util-types@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283" + integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg== + micromark-util-types@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== +micromark@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9" + integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + micromark-core-commonmark "^1.0.1" + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-chunked "^1.0.0" + micromark-util-combine-extensions "^1.0.0" + micromark-util-decode-numeric-character-reference "^1.0.0" + micromark-util-encode "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-subtokenize "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.1" + uvu "^0.5.0" + micromark@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" @@ -12498,7 +12821,7 @@ micromark@^4.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" -micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -12603,6 +12926,11 @@ module-details-from-path@^1.0.3: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== +mri@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -12618,7 +12946,7 @@ mylas@^2.1.9: resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== -nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7: +nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -12868,14 +13196,6 @@ oniguruma-to-es@^4.3.3: regex "^6.0.1" regex-recursion "^6.0.2" -open@^7.4.2: - version "7.4.2" - resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" - integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== - dependencies: - is-docker "^2.0.0" - is-wsl "^2.1.1" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -12893,11 +13213,6 @@ orderedmap@^2.0.0: resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== - own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -13004,27 +13319,6 @@ parseurl@^1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -patch-package@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" - integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== - dependencies: - "@yarnpkg/lockfile" "^1.1.0" - chalk "^4.1.2" - ci-info "^3.7.0" - cross-spawn "^7.0.3" - find-yarn-workspace-root "^2.0.0" - fs-extra "^9.0.0" - json-stable-stringify "^1.0.2" - klaw-sync "^6.0.0" - minimist "^1.2.6" - open "^7.4.2" - rimraf "^2.6.3" - semver "^7.5.3" - slash "^2.0.0" - tmp "^0.0.33" - yaml "^2.2.2" - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -13453,7 +13747,7 @@ prosemirror-menu@^1.2.4: prosemirror-history "^1.0.0" prosemirror-state "^1.0.0" -prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0, prosemirror-model@^1.25.1: +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.1: version "1.25.1" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.1.tgz#aeae9f1ec79fcaa76f6fc619800d91fbcf726870" integrity sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg== @@ -13511,10 +13805,10 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: - version "1.39.3" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.39.3.tgz#54fa4b8ab4fd75ad0075dc6dc0be1745429d5a5c" - integrity sha512-bY/7kg0LzRE7ytR0zRdSMWX3sknEjw68l836ffLPMh0OG3OYnNuBDUSF3v0vjvnzgYjgY9ZH/RypbARURlcMFA== +prosemirror-view@1.33.7, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.33.7, prosemirror-view@^1.37.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: + version "1.33.7" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.33.7.tgz#fd9841a79a4bc517914a57456370b941bd655729" + integrity sha512-jo6eMQCtPRwcrA2jISBCnm0Dd2B+szS08BU1Ay+XGiozHo5EZMHfLQE8R5nO4vb1spTH2RW1woZIYXRiQsuP8g== dependencies: prosemirror-model "^1.20.0" prosemirror-state "^1.0.0" @@ -13896,7 +14190,7 @@ react-dnd@^14.0.3: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@*, react-dom@19.0.0, react-dom@19.1.0: +react-dom@*, react-dom@19.0.0, react-dom@19.1.0, react-dom@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== @@ -14130,7 +14424,7 @@ react-window@^1.8.11: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@*, react@19.0.0, react@19.1.0: +react@*, react@19.0.0, react@19.1.0, react@^18: version "19.1.0" resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== @@ -14322,6 +14616,15 @@ remark-gfm@^4.0.1: remark-stringify "^11.0.0" unified "^11.0.0" +remark-parse@^10.0.1: + version "10.0.2" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.2.tgz#ca241fde8751c2158933f031a4e3efbaeb8bc262" + integrity sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-from-markdown "^1.0.0" + unified "^10.0.0" + remark-parse@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" @@ -14343,6 +14646,15 @@ remark-rehype@^11.1.1: unified "^11.0.0" vfile "^6.0.0" +remark-stringify@^10.0.2: + version "10.0.3" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-10.0.3.tgz#83b43f2445c4ffbb35b606f967d121b2b6d69717" + integrity sha512-koyOzCMYoUHudypbj4XpnAKFbkddRMYZHwghnxd7ue5210WzGw6kOBwauJTRUMq16jsovXx8dYNvSSWP89kZ3A== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.0.0" + unified "^10.0.0" + remark-stringify@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" @@ -14452,7 +14764,7 @@ rgbcolor@^1.0.1: resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d" integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw== -rimraf@^2.5.4, rimraf@^2.6.3: +rimraf@^2.5.4: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -14544,6 +14856,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +sade@^1.7.3: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-array-concat@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" @@ -14619,6 +14938,11 @@ schema-utils@^4.3.0, schema-utils@^4.3.2: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +secure-json-parse@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" + integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -14820,11 +15144,6 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" -slash@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" - integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -15346,6 +15665,14 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swr@^2.2.5: + version "2.3.3" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.3.3.tgz#9d6a703355f15f9099f45114db3ef75764444788" + integrity sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.4.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -15448,6 +15775,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +throttleit@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-2.1.0.tgz#a7e4aa0bf4845a5bd10daa39ea0c783f631a07b4" + integrity sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw== + through2@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" @@ -15488,13 +15820,6 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.86" -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -15810,6 +16135,19 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" +unified@^10.0.0, unified@^10.1.2: + version "10.1.2" + resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" + integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q== + dependencies: + "@types/unist" "^2.0.0" + bail "^2.0.0" + extend "^3.0.0" + is-buffer "^2.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^5.0.0" + unified@^11.0.0, unified@^11.0.5: version "11.0.5" resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" @@ -15838,6 +16176,13 @@ unist-util-find-after@^5.0.0: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" +unist-util-is@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.2.1.tgz#b74960e145c18dcb6226bc57933597f5486deae9" + integrity sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" @@ -15852,6 +16197,13 @@ unist-util-position@^5.0.0: dependencies: "@types/unist" "^3.0.0" +unist-util-stringify-position@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d" + integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" @@ -15859,6 +16211,14 @@ unist-util-stringify-position@^4.0.0: dependencies: "@types/unist" "^3.0.0" +unist-util-visit-parents@^5.1.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb" + integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" @@ -15867,6 +16227,15 @@ unist-util-visit-parents@^6.0.0: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" +unist-util-visit@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2" + integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg== + dependencies: + "@types/unist" "^2.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents "^5.1.1" + unist-util-visit@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" @@ -16035,6 +16404,16 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uvu@^0.5.0: + version "0.5.6" + resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" + integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA== + dependencies: + dequal "^2.0.0" + diff "^5.0.0" + kleur "^4.0.3" + sade "^1.7.3" + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -16067,6 +16446,14 @@ vfile-location@^5.0.0: "@types/unist" "^3.0.0" vfile "^6.0.0" +vfile-message@^3.0.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea" + integrity sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw== + dependencies: + "@types/unist" "^2.0.0" + unist-util-stringify-position "^3.0.0" + vfile-message@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" @@ -16075,6 +16462,16 @@ vfile-message@^4.0.0: "@types/unist" "^3.0.0" unist-util-stringify-position "^4.0.0" +vfile@^5.0.0: + version "5.3.7" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7" + integrity sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g== + dependencies: + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" + unist-util-stringify-position "^3.0.0" + vfile-message "^3.0.0" + vfile@^6.0.0: version "6.0.3" resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" @@ -16658,11 +17055,6 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yaml@^2.2.2: - version "2.8.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6" - integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -16720,11 +17112,26 @@ yoga-layout@^3.2.1: resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843" integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== +zod-to-json-schema@^3.24.1: + version "3.24.6" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" + integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== + +zod@3.25.28: + version "3.25.28" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.28.tgz#8ab13d04afa05933598fd9fca32490ca92c7ea3a" + integrity sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q== + zustand@5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.5.tgz#3e236f6a953142d975336d179bc735d97db17e84" integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg== +zustand@^5.0.3: + version "5.0.6" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.6.tgz#a2da43d8dc3d31e314279e5baec06297bea70a5c" + integrity sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A== + zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"