diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index fba15f6ca3..9e5e33bcc9 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 c4d59f857f..66349b98bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨ Import of documents #1609 - 🚨(CI) gives warning if theme not updated #1811 - 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825 +- ✨(frontend) integrate new Blocknote AI feature #1016 ### Changed diff --git a/docs/env.md b/docs/env.md index 186cfb3051..214fb54efc 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,6 +11,7 @@ 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 | diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 349e0191e5..0f78faf5fe 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -17,7 +17,6 @@ from core import choices, enums, models, utils, validators from core.services import mime_types -from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, Converter, @@ -792,33 +791,38 @@ class VersionFilterSerializer(serializers.Serializer): ) -class AITransformSerializer(serializers.Serializer): - """Serializer for AI transform requests.""" +class AIProxySerializer(serializers.Serializer): + """Serializer for AI proxy requests.""" - action = serializers.ChoiceField(choices=AI_ACTIONS, required=True) - text = serializers.CharField(required=True) - - def validate_text(self, value): - """Ensure the text field is not empty.""" - - if len(value.strip()) == 0: - raise serializers.ValidationError("Text field cannot be empty.") - return value - - -class AITranslateSerializer(serializers.Serializer): - """Serializer for AI translate requests.""" - - language = serializers.ChoiceField( - choices=tuple(enums.ALL_LANGUAGES.items()), required=True + messages = serializers.ListField( + required=True, + child=serializers.DictField( + child=serializers.CharField(required=True), + ), + allow_empty=False, ) - text = serializers.CharField(required=True) + model = serializers.CharField(required=True) + + def validate_messages(self, messages): + """Validate messages structure.""" + # Ensure each message has the required fields + for message in messages: + if ( + not isinstance(message, dict) + or "role" not in message + or "content" not in message + ): + raise serializers.ValidationError( + "Each message must have 'role' and 'content' fields" + ) + + return messages - def validate_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 7432004d9e..5a210812b5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -338,21 +338,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 @@ -391,7 +378,6 @@ class DocumentViewSet( throttle_scope = "document" queryset = models.Document.objects.select_related("creator").all() serializer_class = serializers.DocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer all_serializer_class = serializers.ListDocumentSerializer children_serializer_class = serializers.ListDocumentSerializer descendants_serializer_class = serializers.ListDocumentSerializer @@ -1645,58 +1631,42 @@ 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", - throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], + 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"] + if not settings.AI_FEATURE_ENABLED: + raise ValidationError("AI feature is not enabled.") - response = AIService().transform(text, action) + ai_service = AIService() - return drf.response.Response(response, status=drf.status.HTTP_200_OK) - - @drf.decorators.action( - detail=True, - methods=["post"], - name="Translate a piece of text with AI", - url_path="ai-translate", - throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle], - ) - def ai_translate(self, request, *args, **kwargs): - """ - POST /api/v1.0/documents//ai-translate - with expected data: - - text: str - - language: str [settings.LANGUAGES] - Return JSON response with the translated text. - """ - # Check permissions first - self.get_object() - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) + if settings.AI_STREAM: + stream_gen = ai_service.stream_proxy( + url=settings.AI_BASE_URL.rstrip("/") + "/chat/completions", + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps(request.data, ensure_ascii=False).encode("utf-8"), + ) - text = serializer.validated_data["text"] - language = serializer.validated_data["language"] + resp = StreamingHttpResponse( + streaming_content=stream_gen, + content_type="text/event-stream", + status=200, + ) + resp["X-Accel-Buffering"] = "no" + resp["Cache-Control"] = "no-cache" + return resp - response = AIService().translate(text, language) - return drf.response.Response(response, status=drf.status.HTTP_200_OK) def _reject_invalid_ips(self, ips): """ @@ -2337,7 +2307,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", "CONVERSION_FILE_EXTENSIONS_ALLOWED", diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 71abd253a3..4d248a4038 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -783,8 +783,7 @@ def get_abilities(self, user): 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, "can_edit": can_update, diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index 7b4a560cf3..fcbb8bca78 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -1,98 +1,168 @@ -"""AI services.""" +# core/services/ai_services.py +from __future__ import annotations +import json +from typing import Any, Dict, Generator +from urllib.parse import urlparse + +import httpx from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from core import enums - -if settings.LANGFUSE_PUBLIC_KEY: - from langfuse.openai import OpenAI -else: - from openai import OpenAI - - -AI_ACTIONS = { - "prompt": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "correct": ( - "Correct grammar and spelling of the markdown text, " - "preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "rephrase": ( - "Rephrase the given markdown text, " - "preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "summarize": ( - "Summarize the markdown text, preserving language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - "beautify": ( - "Add formatting to the text to make it more readable. " - "Do not provide any other information. " - "Preserve the language." - ), - "emojify": ( - "Add emojis to the important parts of the text. " - "Do not provide any other information. " - "Preserve the language." - ), -} - -AI_TRANSLATE = ( - "Keep the same html structure and formatting. " - "Translate the content in the html to the specified language {language:s}. " - "Check the translation for accuracy and make any necessary corrections. " - "Do not provide any other information." -) + +BLOCKNOTE_TOOL_STRICT_PROMPT = """You are editing a BlockNote document via the tool applyDocumentOperations. + +You MUST respond ONLY by calling applyDocumentOperations. +The tool input MUST be valid JSON: +{ "operations": [ ... ] } + +Each operation MUST include "type" and it MUST be one of: +- "update" (requires: id, block) +- "add" (requires: referenceId, position, blocks) +- "delete" (requires: id) + +VALID SHAPES (FOLLOW EXACTLY): + +Update: +{ "type":"update", "id":"", "block":"

...

" } +IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element. + +Add: +{ "type":"add", "referenceId":"", "position":"before|after", "blocks":["

...

"] } +IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS. +Each item MUST be a STRING containing a SINGLE valid HTML element. + +Delete: +{ "type":"delete", "id":"" } + +IDs ALWAYS end with "$". Use ids EXACTLY as provided. + +Return ONLY the JSON tool input. No prose, no markdown. +""" + + +def _drop_nones(obj: Any) -> Any: + if isinstance(obj, dict): + return {k: _drop_nones(v) for k, v in obj.items() if v is not None} + if isinstance(obj, list): + return [_drop_nones(v) for v in obj] + return obj class AIService: - """Service class for AI-related operations.""" - - def __init__(self): - """Ensure that the AI configuration is set properly.""" - if ( - settings.AI_BASE_URL is None - or settings.AI_API_KEY is None - or settings.AI_MODEL is None - ): - 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) + """ + Backward-compatible proxy service for your existing viewset: + + stream_proxy(provider, url, method, headers, body) -> yields bytes + + Plus: hardening payload so BlockNote tool calls are valid. + """ + + def __init__(self) -> None: + if not settings.AI_BASE_URL or not settings.AI_API_KEY: + raise ImproperlyConfigured("AI_BASE_URL and AI_API_KEY must be set") + + self.base_url = str(settings.AI_BASE_URL).rstrip("/") + self.api_key = str(settings.AI_API_KEY) + self.allowed_host = urlparse(self.base_url).netloc + + def _assert_allowed_target(self, target_url: str) -> None: + t = urlparse(target_url) + if t.scheme not in ("http", "https"): + raise ValueError("Target URL not allowed") + if t.netloc != self.allowed_host: + raise ValueError("Target URL not allowed") + + def _filtered_headers(self, incoming_headers: Dict[str, str]) -> Dict[str, str]: + hop_by_hop = {"host", "connection", "content-length", "accept-encoding"} + out: Dict[str, str] = {} + for k, v in incoming_headers.items(): + lk = k.lower() + if lk in hop_by_hop: + continue + if lk == "authorization": + # Client auth is for Django only, not upstream + continue + out[k] = v + + out["Authorization"] = f"Bearer {self.api_key}" + return out + + def _normalize_tools(self, tools: list) -> list: + normalized = [] + for tool in tools: + if isinstance(tool, dict) and tool.get("type") == "function": + fn = tool.get("function") or {} + if isinstance(fn, dict) and not fn.get("description"): + fn["description"] = f"Tool {fn.get('name', 'unknown')}." + tool["function"] = fn + normalized.append(_drop_nones(tool)) + return normalized + + def _harden_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(payload) + + # Enforce server model (important with Albert routing) + if getattr(settings, "AI_MODEL", None): + payload["model"] = settings.AI_MODEL + + # Compliance + payload["temperature"] = 0 + + # Tools normalization + if isinstance(payload.get("tools"), list): + payload["tools"] = self._normalize_tools(payload["tools"]) + + # Force tool call if tools exist + if payload.get("tools"): + payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}} + + # Convert non-standard "required" + if payload.get("tool_choice") == "required": + payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}} + + # Inject strict system prompt once + msgs = payload.get("messages") + if isinstance(msgs, list): + need = True + if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system": + c = msgs[0].get("content") or "" + if isinstance(c, str) and "applyDocumentOperations" in c and "blocks" in c: + need = False + if need: + payload["messages"] = [{"role": "system", "content": BLOCKNOTE_TOOL_STRICT_PROMPT}] + msgs + + return _drop_nones(payload) + + def _maybe_harden_json_body(self, body: bytes, headers: Dict[str, str]) -> bytes: + ct = (headers.get("Content-Type") or headers.get("content-type") or "").lower() + if "application/json" not in ct: + return body + try: + payload = json.loads(body.decode("utf-8")) + except Exception: + return body + if isinstance(payload, dict): + payload = self._harden_payload(payload) + return json.dumps(payload, ensure_ascii=False).encode("utf-8") + return body + + def stream_proxy( + self, + *, + url: str, + method: str, + headers: Dict[str, str], + body: bytes, + ) -> Generator[bytes, None, None]: + self._assert_allowed_target(url) + + req_headers = self._filtered_headers(dict(headers)) + req_body = self._maybe_harden_json_body(body, req_headers) + + timeout = httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=10.0) + with httpx.Client(timeout=timeout, follow_redirects=False) as client: + with client.stream(method.upper(), url, headers=req_headers, content=req_body) as r: + for chunk in r.iter_bytes(): + if chunk: + yield chunk 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 0000000000..055dcf5bf0 --- /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 d047839c1b..0000000000 --- a/src/backend/core/tests/documents/test_api_documents_ai_transform.py +++ /dev/null @@ -1,362 +0,0 @@ -""" -Test AI transform API endpoint for users in impress's core app. -""" - -import random -from unittest.mock import MagicMock, patch - -from django.test import override_settings - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.tests.conftest import TEAM, USER, VIA - -pytestmark = pytest.mark.django_db - - -@pytest.fixture -def ai_settings(): - """Fixture to set AI settings.""" - with override_settings( - AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama" - ): - yield - - -@override_settings( - AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) -) -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("authenticated", "editor"), - ("public", "reader"), - ], -) -def test_api_documents_ai_transform_anonymous_forbidden(reach, role): - """ - Anonymous users should not be able to request AI transform if the link reach - and role don't allow it. - """ - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "hello", "action": "prompt"}) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -@override_settings(AI_ALLOW_REACH_FROM="public") -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_anonymous_success(mock_create): - """ - Anonymous users should be able to request AI transform to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Summarize the markdown text, preserving language and markdown formatting. " - "Do not provide any other information. Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create): - """ - Anonymous users should be able to request AI transform to a document - if the link reach and role permit it. - """ - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - answer = '{"answer": "Salut"}' - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content=answer))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 401 - - -@pytest.mark.parametrize( - "reach, role", - [ - ("restricted", "reader"), - ("restricted", "editor"), - ("authenticated", "reader"), - ("public", "reader"), - ], -) -def test_api_documents_ai_transform_authenticated_forbidden(reach, role): - """ - Users who are not related to a document can't request AI transform if the - link reach and role don't allow it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize( - "reach, role", - [ - ("authenticated", "editor"), - ("public", "editor"), - ], -) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role): - """ - Authenticated who are not related to a document should be able to request AI transform - if the link reach and role permit it. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach=reach, link_role=role) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -@pytest.mark.parametrize("via", VIA) -def test_api_documents_ai_transform_reader(via, mock_user_teams): - """ - Users who are simple readers on a document should not be allowed to request AI transform. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_role="reader") - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role="reader") - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role="reader" - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize("role", ["editor", "administrator", "owner"]) -@pytest.mark.parametrize("via", VIA) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams): - """ - Editors, administrators and owners of a document should be able to request AI transform. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory() - if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role=role) - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role=role - ) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "prompt"}) - - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - mock_create.assert_called_once_with( - model="llama", - messages=[ - { - "role": "system", - "content": ( - "Answer the prompt using markdown formatting for structure and emphasis. " - "Return the content directly without wrapping it in code blocks or markdown delimiters. " - "Preserve the language and markdown formatting. " - "Do not provide any other information. " - "Preserve the language." - ), - }, - {"role": "user", "content": "Hello"}, - ], - ) - - -def test_api_documents_ai_transform_empty_text(): - """The text should not be empty when requesting AI transform.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": " ", "action": "prompt"}) - - assert response.status_code == 400 - assert response.json() == {"text": ["This field may not be blank."]} - - -def test_api_documents_ai_transform_invalid_action(): - """The action should valid when requesting AI transform.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "invalid"}) - - assert response.status_code == 400 - assert response.json() == {"action": ['"invalid" is not a valid choice.']} - - -@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_throttling_document(mock_create): - """ - Throttling per document should be triggered on the AI transform endpoint. - For full throttle class test see: `test_api_utils_ai_document_rate_throttles` - """ - client = APIClient() - document = factories.DocumentFactory(link_reach="public", link_role="editor") - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - for _ in range(3): - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "action": "summarize"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - user = factories.UserFactory() - client.force_login(user) - response = client.post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } - - -@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10}) -@pytest.mark.usefixtures("ai_settings") -@patch("openai.resources.chat.completions.Completions.create") -def test_api_documents_ai_transform_throttling_user(mock_create): - """ - Throttling per user should be triggered on the AI transform endpoint. - For full throttle class test see: `test_api_utils_ai_user_rate_throttles` - """ - user = factories.UserFactory() - client = APIClient() - client.force_login(user) - - mock_create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="Salut"))] - ) - - for _ in range(3): - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "summarize"}) - assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} - - document = factories.DocumentFactory(link_reach="public", link_role="editor") - url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" - response = client.post(url, {"text": "Hello", "action": "summarize"}) - - assert response.status_code == 429 - assert response.json() == { - "detail": "Request was throttled. Expected available in 60 seconds." - } diff --git a/src/backend/core/tests/documents/test_api_documents_ai_translate.py b/src/backend/core/tests/documents/test_api_documents_ai_translate.py deleted file mode 100644 index f0d7978c26..0000000000 --- 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 59c5e0298c..97c5471c3e 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -29,8 +29,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", "can_edit": document.link_role == "editor", "children_create": False, @@ -107,8 +106,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", "can_edit": grand_parent.link_role == "editor", "children_create": False, @@ -215,8 +213,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", "can_edit": document.link_role == "editor", "children_create": document.link_role == "editor", @@ -300,8 +297,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", "can_edit": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", @@ -498,6 +494,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "abilities": { "accesses_manage": access.role in ["administrator", "owner"], "accesses_view": True, + "ai_proxy": access.role not in ["reader", "commenter"], "ai_transform": access.role not in ["reader", "commenter"], "ai_translate": access.role not in ["reader", "commenter"], "attachment_upload": access.role not in ["reader", "commenter"], 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 cc32c09f95..a7604099c5 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": False, "accesses_view": False, - "ai_transform": False, - "ai_translate": False, + "ai_proxy": False, "attachment_upload": False, "can_edit": False, "children_create": False, diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index ac3a9b3099..5e785b9c1d 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -19,7 +19,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", @@ -43,7 +46,11 @@ 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_FEATURE_ENABLED": False, + "AI_STREAM": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"], diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index e5432cddc4..01352e2e38 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -155,8 +155,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, "can_edit": False, "children_create": False, @@ -220,8 +219,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, "can_edit": False, "children_create": False, @@ -357,8 +355,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, "can_edit": True, "children_create": is_authenticated, @@ -413,8 +410,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, "can_edit": True, "children_create": True, @@ -501,8 +497,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, "can_edit": True, "children_create": True, @@ -557,8 +552,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, "can_edit": True, "children_create": True, @@ -620,8 +614,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, "can_edit": access_from_link, "children_create": access_from_link, @@ -747,8 +740,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, "can_edit": False, "children_create": False, @@ -878,8 +870,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") @@ -897,8 +888,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 ffa5c170ae..5818dc326b 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 7510160316..e5de62f2be 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -670,24 +670,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/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 44470fbb28..239159e13e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -93,9 +93,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 e0ea8f59b9..781dd77c1a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable playwright/no-conditional-expect */ import path from 'path'; import { expect, test } from '@playwright/test'; @@ -389,13 +388,72 @@ test.describe('Doc Editor', () => { await expect(image).toHaveAttribute('aria-hidden', 'true'); }); - 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 { @@ -408,118 +466,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' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Summarize' }), + page.getByRole('option', { name: 'Improve Writing' }), ).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'); + ], + abilities: { + destroy: true, // Means owner + link_configuration: true, + ai_proxy: false, + accesses_manage: true, + accesses_view: true, + update: true, + partial_update: true, + retrieve: true, + }, + link_reach: 'restricted', + link_role: 'editor', + created_at: '2021-09-01T09:00:00Z', + title: '', + }); - const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); + const [randomDoc] = await createDoc( + page, + 'doc-editor-ai-proxy', + browserName, + 1, + ); - if (!ai_transform && !ai_translate) { - await expect(page.getByRole('button', { name: 'AI' })).toBeHidden(); - return; - } + await verifyDocName(page, randomDoc); - await page.getByRole('button', { name: 'AI' }).click(); + await page.locator('.bn-block-outer').last().fill('Hello World'); - if (ai_transform) { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - } else { - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeHidden(); - } + const editor = page.locator('.ProseMirror'); + await editor.getByText('Hello').selectText(); - if (ai_translate) { - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeVisible(); - } else { - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeHidden(); - } - }); + 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 27250623f6..620fb85497 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -121,7 +121,7 @@ test.describe('Language', () => { LANGUAGE_CODE: 'en-us', }); - await createDoc(page, 'doc-toolbar', browserName, 1); + await createDoc(page, 'doc-translations-slash', browserName, 1); const { editor, suggestionMenu } = await openSuggestionMenu({ page }); await expect( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 6854e0b41b..620ee3fad5 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -4,7 +4,13 @@ export type BrowserName = 'chromium' | 'firefox' | 'webkit'; export const BROWSERS: BrowserName[] = ['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: true, diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index cfb372cb14..10edb81f0f 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -19,14 +19,18 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", - "@blocknote/code-block": "0.45.0", - "@blocknote/core": "0.45.0", - "@blocknote/mantine": "0.45.0", - "@blocknote/react": "0.45.0", - "@blocknote/xl-docx-exporter": "0.45.0", - "@blocknote/xl-multi-column": "0.45.0", - "@blocknote/xl-odt-exporter": "0.45.0", - "@blocknote/xl-pdf-exporter": "0.45.0", + "@ai-sdk/groq": "^3.0.15", + "@ai-sdk/openai": "^3.0.19", + "@ai-sdk/openai-compatible": "2.0.18", + "@blocknote/code-block": "0.46.1", + "@blocknote/core": "0.46.1", + "@blocknote/mantine": "0.46.1", + "@blocknote/react": "0.46.1", + "@blocknote/xl-ai": "0.46.1", + "@blocknote/xl-docx-exporter": "0.46.1", + "@blocknote/xl-multi-column": "0.46.1", + "@blocknote/xl-odt-exporter": "0.46.1", + "@blocknote/xl-pdf-exporter": "0.46.1", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", @@ -44,6 +48,7 @@ "@sentry/nextjs": "10.32.1", "@tanstack/react-query": "5.90.16", "@tiptap/extensions": "*", + "ai": "6.0.49", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -72,6 +77,7 @@ "uuid": "13.0.0", "y-protocols": "1.0.7", "yjs": "*", + "zod": "3.25.28", "zustand": "5.0.9" }, "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 6181ecabf1..f326e58093 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -15,7 +15,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; CONVERSION_FILE_EXTENSIONS_ALLOWED: 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 040f6c7c3c..5157ad4554 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 cd8dfbfc16..0000000000 --- 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 504d79b3e4..0000000000 --- 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 0000000000..8436d0047f --- /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 0000000000..743b871a16 --- /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 0000000000..fb1b19b7ac --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIUI.tsx @@ -0,0 +1,190 @@ +import { FormattingToolbarExtension } from '@blocknote/core/extensions'; +import { + useBlockNoteEditor, + useComponentsContext, + useExtension, +} from '@blocknote/react'; +import { + AIExtension, + AIMenu as AIMenuDefault, + 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 { t } = useTranslation(); + const Components = useComponentsContext(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + const ai = useExtension(AIExtension); + const formattingToolbar = useExtension(FormattingToolbarExtension); + + if (!editor.isEditable || !Components) { + return null; + } + + const onClick = () => { + const selection = editor.getSelection(); + if (!selection) { + throw new Error('No selection'); + } + + const position = selection.blocks[selection.blocks.length - 1].id; + + ai.openAIMenuAtBlock(position); + formattingToolbar.store.setState(false); + }; + + return ( + button.mantine-Button-root { + padding-inline: ${spacingsTokens['2xs']}; + transition: all 0.1s ease-in; + &:hover, + &:hover { + background-color: ${colorsTokens['gray-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 0000000000..c50a690b4a --- /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 0000000000..a707769ba7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -0,0 +1,43 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { AIExtension, ClientSideTransport } 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 aIprovider = createOpenAI({ + apiKey: '', + baseURL: `${baseApiUrl('1.0')}documents/${docId}/ai-proxy/`, + fetch: (input, init) => { + // Create a new headers object without the Authorization header + const headers = new Headers(init?.headers); + headers.delete('Authorization'); + + return fetchAPI(`documents/${docId}/ai-proxy/`, { + ...init, + headers, + }); + }, + }); + + const model = aIprovider.chat(conf.AI_MODEL); + + const extension = AIExtension({ + agentCursor: conf.AI_BOT, + transport: new ClientSideTransport({ model }), + }); + + return extension; + }, [aiAllowed, conf, docId]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index e0be2f28c4..28731c2d73 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 @@ -20,6 +20,7 @@ import type { Awareness } from 'y-protocols/awareness'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; +import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, useProviderStore } from '@/docs/doc-management'; import { avatarUrlFromName, useAuth } from '@/features/auth'; @@ -36,6 +37,7 @@ 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 { cssComments, useComments } from './comments/'; @@ -45,6 +47,10 @@ import { PdfBlock, UploadLoaderBlock, } from './custom-blocks'; +const AIMenu = BlockNoteAI?.AIMenu; +const AIMenuController = BlockNoteAI?.AIMenuController; +const useAI = BlockNoteAI?.useAI; +const localesAI = BlockNoteAI?.localesAI; import { InterlinkingLinkInlineContent, InterlinkingSearchInlineContent, @@ -83,7 +89,6 @@ interface BlockNoteEditorProps { export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); - const { t } = useTranslation(); const { themeTokens } = useCunninghamTheme(); const { isSynced: isConnectedToCollabServer } = useProviderStore(); const refEditorContainer = useRef(null); @@ -92,13 +97,16 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const showComments = canSeeComment; useSaveDoc(doc.id, provider.document, isConnectedToCollabServer); - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); let lang = i18n.resolvedLanguage; if (!lang || !(lang in locales)) { lang = 'en'; } 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 = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); @@ -168,6 +176,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { ...(multiColumnLocales && { multi_column: multiColumnLocales[lang as keyof typeof multiColumnLocales], + ai: localesAI?.[lang as keyof typeof localesAI], }), }, pasteHandler: ({ event, defaultPasteHandler }) => { @@ -190,7 +199,15 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { return defaultPasteHandler(); }, - extensions: [CommentsExtension({ threadStore, resolveUsers })], + extensions: [ + CommentsExtension({ threadStore, resolveUsers }), + ...(aiExtension ? [aiExtension] : []), + ], + visualMedia: { + image: { + maxWidth: 760, + }, + }, tables: { splitCells: true, cellBackgroundColor: true, @@ -200,7 +217,15 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [cursorName, lang, provider, uploadFile, threadStore, resolveUsers], + [ + aiExtension, + cursorName, + lang, + provider, + uploadFile, + threadStore, + resolveUsers, + ], ); useHeadings(editor); @@ -243,8 +268,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { comments={showComments} aria-label={t('Document editor')} > - - + {aiAllowed && AIMenuController && AIMenu && ( + + )} + + ); @@ -311,7 +339,7 @@ export const BlockNoteReader = ({ slashMenu={false} comments={false} > - + ); 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 e07d15bc06..805377f691 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 @@ -17,6 +17,7 @@ import { DocsStyleSchema, } from '../types'; +import BlockNoteAI from './AI'; import { getCalloutReactSlashMenuItems, getPdfReactSlashMenuItems, @@ -27,7 +28,13 @@ import XLMultiColumn from './xl-multi-column'; const getMultiColumnSlashMenuItems = XLMultiColumn?.getMultiColumnSlashMenuItems; -export const BlockNoteSuggestionMenu = () => { +const getAISlashMenuItems = BlockNoteAI?.getAISlashMenuItems; + +export const BlockNoteSuggestionMenu = ({ + aiAllowed, +}: { + aiAllowed: boolean; +}) => { const editor = useBlockNoteEditor< DocsBlockSchema, DocsInlineContentSchema, @@ -50,6 +57,7 @@ export const BlockNoteSuggestionMenu = () => { getMultiColumnSlashMenuItems?.(editor) || [], getPdfReactSlashMenuItems(editor, t, fileBlocksName), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), + aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [], ); const index = combinedMenu.findIndex( @@ -66,7 +74,14 @@ export const BlockNoteSuggestionMenu = () => { return async (query: string) => Promise.resolve(filterSuggestionItems(newSlashMenuItems, query)); - }, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]); + }, [ + editor, + t, + fileBlocksName, + basicBlocksName, + aiAllowed, + getInterlinkingMenuItems, + ]); 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 AIMenuItemTransformProps { - action: AITransformActions; - docId: string; - icon?: ReactNode; -} - -const AIMenuItemTransform = ({ - docId, - action, - children, - icon, -}: PropsWithChildren) => { - const { mutateAsync: requestAI, isPending } = useDocAITransform(); - const editor = useBlockNoteEditor(); - - const requestAIAction = async (selectedBlocks: Block[]) => { - const text = await editor.blocksToMarkdownLossy(selectedBlocks); - - const responseAI = await requestAI({ - text, - action, - docId, - }); - - if (!responseAI?.answer) { - throw new Error('No response from AI'); - } - - const markdown = await editor.tryParseMarkdownToBlocks(responseAI.answer); - editor.replaceBlocks(selectedBlocks, markdown); - }; - - return ( - - {children} - - ); -}; - -interface AIMenuItemTranslateProps { - language: string; - docId: string; - icon?: ReactNode; -} - -const AIMenuItemTranslate = ({ - children, - docId, - icon, - language, -}: PropsWithChildren) => { - const { mutateAsync: requestAI, isPending } = useDocAITranslate(); - const editor = useBlockNoteEditor(); - - const requestAITranslate = async (selectedBlocks: Block[]) => { - let fullHtml = ''; - for (const block of selectedBlocks) { - if (Array.isArray(block.content) && block.content.length === 0) { - fullHtml += '


'; - continue; - } - - fullHtml += await editor.blocksToHTMLLossy([block]); - } - - const responseAI = await requestAI({ - text: fullHtml, - language, - docId, - }); - - if (!responseAI || !responseAI.answer) { - throw new Error('No response from AI'); - } - - try { - const blocks = await editor.tryParseHTMLToBlocks(responseAI.answer); - editor.replaceBlocks(selectedBlocks, blocks); - } catch { - editor.replaceBlocks(selectedBlocks, selectedBlocks); - } - }; - - return ( - - {children} - - ); -}; - -interface AIMenuItemProps { - requestAI: (blocks: Block[]) => Promise; - isPending: boolean; - icon?: ReactNode; -} - -const AIMenuItem = ({ - requestAI, - isPending, - children, - icon, -}: PropsWithChildren) => { - const Components = useComponentsContext(); - const { toast } = useToastProvider(); - const { t } = useTranslation(); - - const editor = useBlockNoteEditor(); - const handleAIError = useHandleAIError(); - - const handleAIAction = async () => { - const selectedBlocks = editor.getSelection()?.blocks ?? [ - editor.getTextCursorPosition().block, - ]; - - if (!selectedBlocks?.length) { - toast(t('No text selected'), VariantType.WARNING); - return; - } - - try { - await requestAI(selectedBlocks); - } catch (error) { - handleAIError(error); - } - }; - - if (!Components) { - return null; - } - - const Item = Components.Generic.Menu.Item as React.FC; - - return ( - { - e.stopPropagation(); - void handleAIAction(); - }} - rightSection={isPending ? : undefined} - > - {children} - - ); -}; - -const useHandleAIError = () => { - const { toast } = useToastProvider(); - const { t } = useTranslation(); - - return (error: unknown) => { - if (isAPIError(error) && error.status === 429) { - toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR); - return; - } - - toast(t('AI seems busy! Please try again.'), VariantType.ERROR); - }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index b88e09c7a4..3a38a4b95e 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,22 +8,21 @@ import { import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core/config/api'; - +import BlockNoteAI from '../AI'; import { CommentToolbarButton } from '../comments/CommentToolbarButton'; import { getCalloutFormattingToolbarItems } from '../custom-blocks'; -import { AIGroupButton } from './AIButton'; import { FileDownloadButton } from './FileDownloadButton'; import { MarkdownButton } from './MarkdownButton'; import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe'; -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(() => { let toolbarItems = getFormattingToolbarItems([ @@ -69,18 +68,17 @@ export const BlockNoteToolbar = () => { const formattingToolbar = useCallback(() => { return ( + {aiAllowed && AIToolbarButton && } + {toolbarItems} - {/* Extra button to do some AI powered actions */} - {conf?.AI_FEATURE_ENABLED && } - {/* 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 a5d1d585ab..fc5094d3c5 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 @@ -35,7 +35,26 @@ export const useSaveDoc = ( _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. + * "PluginKey" constructor comes from the current user, but transaction.local is more reliable + * "HocuspocusProvider" constructor comes from other users from the collaboration server, + * it seems quite reliable too. + * The AI constructor name seems to not be reliable enough, but by deduction if it's not local + * and not from other users, it has to be from the AI. + * + * TODO: see if we can get the local changes from the AI + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const transactionOrigin = transaction?.origin?.constructor?.name; + const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider'; + + const isAIChange = + !transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR; + + setIsLocalChange(transaction.local || isAIChange); }; 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 6c9c1645a1..01472bcdd0 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 = css` + .mantine-Menu-itemLabel, + .mantine-Button-label { + font-family: var(--c--components--button--font-family); + } + &, & > .bn-container, & .ProseMirror { @@ -148,6 +153,16 @@ export const cssEditor = css` font-style: italic; } + /** + * AI + */ + ins, + [data-type='modification'] { + background: var(--c--globals--colors--primary-100); + border-bottom: 2px solid var(--c--globals--colors--primary-300); + color: var(--c--globals--colors--primary-700); + } + /** * Divider */ 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 64640f9ec1..07bc516ca9 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 @@ -74,8 +74,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 2fe688816b..6d4209b24d 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 @@ -182,8 +182,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 453728115c..c9e0868699 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -967,6 +967,16 @@ "Warning": "Attention", "Why you can't edit the document?": "Pourquoi vous ne pouvez pas modifier le document ?", "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 can view this document but need additional access to see its members or modify settings.": "Vous pouvez voir ce document mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres.", "You cannot restrict access to a subpage relative to its parent page.": "Vous ne pouvez pas restreindre l'accès à une sous-page par rapport à sa page parente.", diff --git a/src/frontend/package.json b/src/frontend/package.json index d1260f697d..9d8a0eeb77 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -35,7 +35,7 @@ "@types/node": "24.10.4", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", - "docx": "9.5.0", + "docx": "9.5.1", "eslint": "9.39.2", "prosemirror-view": "1.41.4", "react": "19.2.3", diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 44639fc92c..d8807a03ac 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -16,7 +16,7 @@ "node": ">=22" }, "dependencies": { - "@blocknote/server-util": "0.45.0", + "@blocknote/server-util": "0.46.1", "@hocuspocus/server": "3.4.3", "@sentry/node": "10.32.1", "@sentry/profiling-node": "10.32.1", @@ -30,7 +30,7 @@ "yjs": "*" }, "devDependencies": { - "@blocknote/core": "0.45.0", + "@blocknote/core": "0.46.1", "@hocuspocus/provider": "3.4.3", "@types/cors": "2.8.19", "@types/express": "5.0.6", diff --git a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts b/src/frontend/servers/y-provider/src/api/collaborationBackend.ts index 6fca2b84f3..f9ff6358b0 100644 --- a/src/frontend/servers/y-provider/src/api/collaborationBackend.ts +++ b/src/frontend/servers/y-provider/src/api/collaborationBackend.ts @@ -29,8 +29,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/servers/y-provider/src/services/sentry.ts b/src/frontend/servers/y-provider/src/services/sentry.ts index ce27c97d0f..beafabbc03 100644 --- a/src/frontend/servers/y-provider/src/services/sentry.ts +++ b/src/frontend/servers/y-provider/src/services/sentry.ts @@ -1,12 +1,13 @@ import * as Sentry from '@sentry/node'; import { nodeProfilingIntegration } from '@sentry/profiling-node'; - +import packageJson from '../../package.json'; import { SENTRY_DSN } from '../env'; Sentry.init({ dsn: SENTRY_DSN, integrations: [nodeProfilingIntegration()], - tracesSampleRate: 0.1, profilesSampleRate: 1.0, + release: packageJson.version, + tracesSampleRate: 0.1, }); Sentry.setTag('application', 'y-provider'); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 6fcdef5526..2fb8f84c67 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -17,6 +17,65 @@ 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/gateway@3.0.22": + version "3.0.22" + resolved "https://registry.yarnpkg.com/@ai-sdk/gateway/-/gateway-3.0.22.tgz#96836072096ead43f046192c29be188109a5bec6" + integrity sha512-NgnlY73JNuooACHqUIz5uMOEWvqR1MMVbb2soGLMozLY1fgwEIF5iJFDAGa5/YArlzw2ATVU7zQu7HkR/FUjgA== + dependencies: + "@ai-sdk/provider" "3.0.5" + "@ai-sdk/provider-utils" "4.0.9" + "@vercel/oidc" "3.1.0" + +"@ai-sdk/groq@^3.0.15": + version "3.0.15" + resolved "https://registry.yarnpkg.com/@ai-sdk/groq/-/groq-3.0.15.tgz#9639ba70daf6e31556852c9558d499a3bdd8ff85" + integrity sha512-tvxaM3RNmbDxoI9fktngyd8bdn35RF09FxQT5y7P4Pfcgu0LlyGQ6EtmfWGb2ke1ZLKLXFyr7jN3uX5Y3L3kbA== + dependencies: + "@ai-sdk/provider" "3.0.5" + "@ai-sdk/provider-utils" "4.0.9" + +"@ai-sdk/openai-compatible@2.0.18": + version "2.0.18" + resolved "https://registry.yarnpkg.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.18.tgz#36cc231c022d95549f42682912c01673dfa7ed59" + integrity sha512-CMbsSDWzQT5y0woUWRqom+eUDsyB+btFyA68MGkrUOBWDDsmcCWt/DHUAAIWC5GO+hwcX4WXT2Q9KJQrQJ9RQg== + dependencies: + "@ai-sdk/provider" "3.0.5" + "@ai-sdk/provider-utils" "4.0.9" + +"@ai-sdk/openai@^3.0.19": + version "3.0.19" + resolved "https://registry.yarnpkg.com/@ai-sdk/openai/-/openai-3.0.19.tgz#c4471a0e667c3404339a645e575e867538bf407e" + integrity sha512-qpMGKV6eYfW8IzErk/OppchQwVui3GPc4BEfg/sQGRzR89vf2Sa8qvSavXeZi5w/oUF56d+VtobwSH0FRooFCQ== + dependencies: + "@ai-sdk/provider" "3.0.5" + "@ai-sdk/provider-utils" "4.0.9" + +"@ai-sdk/provider-utils@4.0.9", "@ai-sdk/provider-utils@^4.0.2": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-4.0.9.tgz#f15d6ed31fca8aeca402fa56278659a20581057e" + integrity sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow== + dependencies: + "@ai-sdk/provider" "3.0.5" + "@standard-schema/spec" "^1.1.0" + eventsource-parser "^3.0.6" + +"@ai-sdk/provider@3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-3.0.5.tgz#730c5acdc4f074c877a547c1492fafc81bdc4f53" + integrity sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/react@^3.0.5": + version "3.0.51" + resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-3.0.51.tgz#0da8105ac55310fba26fcf3eef2cabe3e7d95cf4" + integrity sha512-7nmCwEJM52NQZB4/ED8qJ4wbDg7EEWh94qJ7K9GSJxD6sWF3GOKrRZ5ivm4qNmKhY+JfCxCAxfghGY5mTKOsxw== + dependencies: + "@ai-sdk/provider-utils" "4.0.9" + ai "6.0.49" + swr "^2.2.5" + throttleit "2.1.0" + "@apideck/better-ajv-errors@^0.3.1": version "0.3.6" resolved "https://registry.yarnpkg.com/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz#957d4c28e886a64a8141f7522783be65733ff097" @@ -1135,12 +1194,12 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@blocknote/code-block@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.45.0.tgz#28be65bea168935cf6bd3048f2261e8d18de95b5" - integrity sha512-qWfo2L++xcSvM88Lxx9s3N6pc9t+kwdWHGuwzhWNNEI8X9W50vFkO3vup8Xze0v6KgNouqRBZm0nKuZvp5o2gw== +"@blocknote/code-block@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.46.1.tgz#e748d5f5f9f72294249b067b70fcc631d79284cc" + integrity sha512-K8tQAxGGPOvTfSq8gZoGjnxcRNsZISF3OLpyHObOBHvNO5lU1m2ajsMJc2X5nZl1bKpu0tZUl1icCL+UGiu8fQ== dependencies: - "@blocknote/core" "0.45.0" + "@blocknote/core" "0.46.1" "@shikijs/core" "^3" "@shikijs/engine-javascript" "^3" "@shikijs/langs" "^3" @@ -1148,26 +1207,26 @@ "@shikijs/themes" "^3" "@shikijs/types" "^3" -"@blocknote/core@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.45.0.tgz#f571e451f1e9a391b3f46450c8bc2f1488792a66" - integrity sha512-G8Bt9gFCAGMxcZpmrcCwcbdgZLjllME2nagsWOAhFtDxEr4zKpetKgpyM0pEdTbPnMFXYuDzcIue64N6FDSDJg== +"@blocknote/core@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.46.1.tgz#81a81cad0573894878acf8df08dcbcb2d59ccc7b" + integrity sha512-gV7dkQJPa2PjN7Lb4bgPJb9tQk9kqK7DmEr8+A+Y3Mm9pb98QcUqm9rxantMfUEuQAzfZvb8SLkNf+fbNPqbeQ== dependencies: "@emoji-mart/data" "^1.2.1" - "@handlewithcare/prosemirror-inputrules" "^0.1.3" + "@handlewithcare/prosemirror-inputrules" "^0.1.4" "@shikijs/types" "^3" "@tanstack/store" "^0.7.7" "@tiptap/core" "^3.13.0" "@tiptap/extension-bold" "^3.13.0" "@tiptap/extension-code" "^3.13.0" - "@tiptap/extension-gapcursor" "^3.7.2" - "@tiptap/extension-horizontal-rule" "^3.7.2" + "@tiptap/extension-horizontal-rule" "^3.13.0" "@tiptap/extension-italic" "^3.13.0" "@tiptap/extension-link" "^3.13.0" - "@tiptap/extension-paragraph" "^3.7.2" + "@tiptap/extension-paragraph" "^3.13.0" "@tiptap/extension-strike" "^3.13.0" "@tiptap/extension-text" "^3.13.0" "@tiptap/extension-underline" "^3.13.0" + "@tiptap/extensions" "^3.13.0" "@tiptap/pm" "^3.13.0" emoji-mart "^5.6.0" fast-deep-equal "^3.1.3" @@ -1194,21 +1253,21 @@ y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/mantine@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.45.0.tgz#31862bec2926a6dd9697a10541959eb69fbee199" - integrity sha512-WNNhrkV7b+qOY0TnKN9Qqb1Zp0BJoz+jVQT6pW2RAyTVDq/vclevfPmLvnBwrtc5ZwL+q78+/srMfyi/nwG4NQ== +"@blocknote/mantine@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.46.1.tgz#05f82d41d28581913808129c97f54b0f04cbbe94" + integrity sha512-p4h0VRzL6DnFxa7MvuPx3ST/ZHO2LONfbXvoonwlVFxGCasVaV3V+vjdCgHAnBZqAViBRACZ2QD3QEn5//eJSA== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/react" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/react" "0.46.1" react-icons "^5.5.0" -"@blocknote/react@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.45.0.tgz#d605bea883fb000dba6de369c5cc85b4d940be0a" - integrity sha512-71Epu3xfqYHJx1rxdnV8//WCDAXg694/q7KzeRYivtJ/rw1OpW8B6Ngavk3i6m1OwtGLGlrRwW1A7nwm/x972Q== +"@blocknote/react@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.46.1.tgz#c900c747c7d98abfbab46cf696bb37e2cbe3fbf0" + integrity sha512-LIVxdzfIkwNJwkHNpB6zXKbPnNHUNHP9ohl+S9X6Htvz6nqudtj9xZKtgXsjNqTAvTYnbLshrgHaWS2wQ0DFVg== dependencies: - "@blocknote/core" "0.45.0" + "@blocknote/core" "0.46.1" "@emoji-mart/data" "^1.2.1" "@floating-ui/react" "^0.27.16" "@floating-ui/utils" "0.2.10" @@ -1223,13 +1282,13 @@ react-icons "^5.5.0" use-sync-external-store "1.6.0" -"@blocknote/server-util@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.45.0.tgz#66afd1553159655d8058c832d2db808ee6e96944" - integrity sha512-Wa3LGM12qhCPrzora9Mhy92SXkBs+JZfrtvubP9Cok7FDyOUW+d3myho3FoWwOwazcFOYQgfTWl2eIVRuBoPrw== +"@blocknote/server-util@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.46.1.tgz#69048b0e2a93f41f76b1cc200fa76a057fc09ea0" + integrity sha512-zcY4UKc20mThjBKlxd7usjoM4b/v0CHh9B2+oQ/oy1BWLXHRdRvdjqsNvSnaT09p59bT/+8O1oAzsjJGFtjYHA== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/react" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/react" "0.46.1" "@tiptap/core" "^3.13.0" "@tiptap/pm" "^3.13.0" jsdom "^25.0.1" @@ -1237,24 +1296,54 @@ y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/xl-docx-exporter@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.45.0.tgz#7dbde776351702697c419b74e93359e6104d58da" - integrity sha512-CY2B87lOzcBlNZ9A/SW1hbhteVKE0Wlr0gAGAn/3VBRWItUj2yrFVf9pMMHYQNMbKNFnJ2iuceb8iOaId2wkkg== +"@blocknote/xl-ai@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai/-/xl-ai-0.46.1.tgz#adecff5f7ec8fd19c510e8f70f529ca013e01d34" + integrity sha512-9+JkgMpH5Zf0aDR75WzkUSFmDw1sKXk79rZi9NUsBUKEwg+c3FWPqJDJQsxfn0FLkDc/1wb4rLy6kH2O2nViZQ== + dependencies: + "@ai-sdk/provider-utils" "^4.0.2" + "@ai-sdk/react" "^3.0.5" + "@blocknote/core" "0.46.1" + "@blocknote/mantine" "0.46.1" + "@blocknote/react" "0.46.1" + "@floating-ui/react" "^0.26.28" + "@handlewithcare/prosemirror-suggest-changes" "^0.1.8" + "@tiptap/core" "^3.13.0" + ai "^6.0.5" + lodash.isequal "^4.5.0" + lodash.merge "^4.6.2" + prosemirror-changeset "^2.3.1" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.3" + prosemirror-transform "^1.10.5" + prosemirror-view "^1.41.4" + react "^19.2.1" + react-dom "^19.2.1" + react-icons "^5.5.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.5" + y-prosemirror "^1.3.7" + +"@blocknote/xl-docx-exporter@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.46.1.tgz#2e367460c7e842bcb43fd1fddc05999854fe0e90" + integrity sha512-Yk27VN9ys3wLM7v+c+a1RYd6zfxb7ttC6m/IoHbzmjBXWghp+R/LlSQiM/vewrmNmUsNr3mL13c/1nmEHV03YA== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/xl-multi-column" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/xl-multi-column" "0.46.1" buffer "^6.0.3" docx "^9.5.1" image-meta "^0.2.2" -"@blocknote/xl-multi-column@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.45.0.tgz#c4822074e9325e3d1d73154362d191211f34268e" - integrity sha512-zbQrZFu35ymqPoSazmq58mUxrb08hEfFCj2SoOc9hPFqlYPAKKgCXrMJnNvk/CrX6FDGLTF0g5MrbUhCMHWYuw== +"@blocknote/xl-multi-column@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.46.1.tgz#d6cc883358198636ff24f9d2aa0308176d825602" + integrity sha512-X98zrg9nk5hkvq3Cv49BY3VUs0f+6/AiNLri29GYNscMAXYkzXanNTXVL2AMfzHtnw3usLXVqjX5J//POmzQHQ== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/react" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/react" "0.46.1" "@tiptap/core" "^3.13.0" prosemirror-model "^1.25.4" prosemirror-state "^1.4.4" @@ -1263,25 +1352,25 @@ prosemirror-view "^1.41.4" react-icons "^5.5.0" -"@blocknote/xl-odt-exporter@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/xl-odt-exporter/-/xl-odt-exporter-0.45.0.tgz#488c717eff9078b7177c682dc233a5a30d0befb2" - integrity sha512-KDJGr4Z9ylA+vPnAWTTXb5/K7jLls52MZeEeK77jCcd9jj7231SXXj2eWZSkh8ZAZMqkEi89vjUSm1/6hQkQ6A== +"@blocknote/xl-odt-exporter@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-odt-exporter/-/xl-odt-exporter-0.46.1.tgz#8014e7d4f1dafa87ce1ca983c66c2ed352132ac9" + integrity sha512-LoSGjXkuMNIu13B3phqJ4EL5acshf/GobCwGP/oeQonoNtVU6xl8D1m9FJkXiXUXRjgHXV5miSu3EC8x8GPs6w== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/xl-multi-column" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/xl-multi-column" "0.46.1" "@zip.js/zip.js" "^2.8.8" buffer "^6.0.3" image-meta "^0.2.2" -"@blocknote/xl-pdf-exporter@0.45.0": - version "0.45.0" - resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.45.0.tgz#e70bce0f7aab560a04f69d1d680e580e3d6418d9" - integrity sha512-RbLLny074ccA0ymyAszb01wL7vtfFmu6u9VO7Rh6TbfQTtt6tSKCRa1ycC6X28pvQrmNXZ/KA0Z7w6S6LZwKcg== +"@blocknote/xl-pdf-exporter@0.46.1": + version "0.46.1" + resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.46.1.tgz#c4aa5ee4caaf1dfb20f87d195b38da0236286508" + integrity sha512-b0gi7lzHZYpcRUrVmmQA9aTeRGCi78MMgdqOvUBf3LWCzE7/TBsmEBLSY9nO6gfo8q3dQBqlYxa54IbDVL9ZBw== dependencies: - "@blocknote/core" "0.45.0" - "@blocknote/react" "0.45.0" - "@blocknote/xl-multi-column" "0.45.0" + "@blocknote/core" "0.46.1" + "@blocknote/react" "0.46.1" + "@blocknote/xl-multi-column" "0.46.1" "@react-pdf/renderer" "^4.3.0" buffer "^6.0.3" docx "^9.5.1" @@ -1677,30 +1766,25 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f" integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA== -"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": +"@eslint-community/eslint-utils@^4.7.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/eslint-utils@^4.9.1": +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint-community/regexpp@^4.12.1": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== - "@eslint/config-array@^0.21.1": version "0.21.1" resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" @@ -1725,9 +1809,9 @@ "@types/json-schema" "^7.0.15" "@eslint/eslintrc@^3.3.1": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" - integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + version "3.3.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -1735,7 +1819,7 @@ globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.0" + js-yaml "^4.1.1" minimatch "^3.1.2" strip-json-comments "^3.1.1" @@ -1777,13 +1861,22 @@ "@floating-ui/core" "^1.7.3" "@floating-ui/utils" "^0.2.10" -"@floating-ui/react-dom@^2.1.6": +"@floating-ui/react-dom@^2.1.2", "@floating-ui/react-dom@^2.1.6": version "2.1.6" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.6.tgz#189f681043c1400561f62972f461b93f01bf2231" integrity sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw== dependencies: "@floating-ui/dom" "^1.7.4" +"@floating-ui/react@^0.26.28": + version "0.26.28" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.28.tgz#93f44ebaeb02409312e9df9507e83aab4a8c0dc7" + integrity sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw== + dependencies: + "@floating-ui/react-dom" "^2.1.2" + "@floating-ui/utils" "^0.2.8" + tabbable "^6.0.0" + "@floating-ui/react@^0.27.16": version "0.27.16" resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.16.tgz#6e485b5270b7a3296fdc4d0faf2ac9abf955a2f7" @@ -1793,7 +1886,7 @@ "@floating-ui/utils" "^0.2.10" tabbable "^6.0.0" -"@floating-ui/utils@0.2.10", "@floating-ui/utils@^0.2.10": +"@floating-ui/utils@0.2.10", "@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.8": version "0.2.10" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== @@ -1941,14 +2034,19 @@ dependencies: is-negated-glob "^1.0.0" -"@handlewithcare/prosemirror-inputrules@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.3.tgz#77364764d9dfae115dbf2cbbfe684a3b87652ac7" - integrity sha512-LjGitwgSFHICeU6Mfbt+0Bp4BuWyvHfDYJIf7rq1qdNO88tFcWV3CSqw75o/YbsnUObDgp5Dn+gXIQLRwiyCbg== +"@handlewithcare/prosemirror-inputrules@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz#d3cd2a9031b475f1e7ca14be2ca2d340d9f6fa36" + integrity sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw== dependencies: prosemirror-history "^1.4.1" prosemirror-transform "^1.0.0" +"@handlewithcare/prosemirror-suggest-changes@^0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-suggest-changes/-/prosemirror-suggest-changes-0.1.8.tgz#707d432376718d4618065b22aafbc55b9ce4ea5b" + integrity sha512-ewrJl4a8dTpPJNhqYySE2ZCjTRpXulWlUmFy3sbyJgPnGtN/zx7+8tbQ1OhHfMzZWfdmA8VjP9ecy+KO4HdOpA== + "@hocuspocus/common@^3.4.3": version "3.4.3" resolved "https://registry.yarnpkg.com/@hocuspocus/common/-/common-3.4.3.tgz#812c64287f358d138e90dad2a66d9841ccd72592" @@ -2689,7 +2787,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== @@ -6093,6 +6191,11 @@ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" @@ -6344,15 +6447,10 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.14.0.tgz#7c178962667888bc036f311a2ac434db6d2638f2" integrity sha512-+ErwDF74NzX4JV0nXMSIUT9V8FDdo85r0SaBZ8lb2NLmElaA3LDklcNV7SsoKlRcwsAXtFkqQbDwXLNGQLYSPQ== -"@tiptap/extension-gapcursor@^3.7.2": - version "3.10.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.10.2.tgz#d9b66196cae2ebfe7e1747a67b21713463832697" - integrity sha512-sBCu8enXm3W3BjnvvGBrzAiSuQSVZyhbQAgaFKjHJKBQRbek55EEbRA0ETUmHcSQbYf0D8hmDt2++HAyEASEsQ== - -"@tiptap/extension-horizontal-rule@^3.7.2": - version "3.10.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.10.2.tgz#4250f704bb5da0e9c05f0930cd3adc63571a7066" - integrity sha512-EkVomzUGfhTp6LF/6jKXKAHiR3bDnZRBVbegocGn5mAZB+5nItxafa7s37zzcPdPI+prnw/C9DRGsZf6pVb4dQ== +"@tiptap/extension-horizontal-rule@^3.13.0": + version "3.17.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.17.1.tgz#355f225b0159b92c8b6d10112abde5c716112e44" + integrity sha512-CHG6LBtxV+3qj5EcCRVlpvSW5udKD6KbnXIGhP+Tvy+OabLGzO4HNxz3+duDE0pMR4eKX1libsnqffj0vq7mnQ== "@tiptap/extension-italic@^3.13.0": version "3.14.0" @@ -6366,10 +6464,10 @@ dependencies: linkifyjs "^4.3.2" -"@tiptap/extension-paragraph@^3.7.2": - version "3.10.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.10.2.tgz#f37cc4fd8982b31c7a3088f4360cc25f4120e2f3" - integrity sha512-k84BMUxpeFTEIoUil4tnXF5viY4oUHXq4wz4JkO/LMEW6lAkO/PhJnJMMrcEJu0sox4aoNppcnS236RNXCiPpg== +"@tiptap/extension-paragraph@^3.13.0": + version "3.17.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.17.1.tgz#7ec297c62d2c8209a38f68488c5bb7c533f208a6" + integrity sha512-Vl+xAlINaPtX8XTPvPmeveYMEIMLs8gA7ItcKpyyo4cCzAfVCY3DKuWzOkQGUf7DKrhyJQZhpgLNMaq+h5sTSw== "@tiptap/extension-strike@^3.13.0": version "3.14.0" @@ -6386,7 +6484,7 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.14.0.tgz#7d9ac55419f353cdd3817b8f5d56a11e909b1251" integrity sha512-zmnWlsi2g/tMlThHby0Je9O+v24j4d+qcXF3nuzLUUaDsGCEtOyC9RzwITft59ViK+Nc2PD2W/J14rsB0j+qoQ== -"@tiptap/extensions@*", "@tiptap/extensions@3.14.0": +"@tiptap/extensions@*", "@tiptap/extensions@3.14.0", "@tiptap/extensions@^3.13.0": version "3.14.0" resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.14.0.tgz#8367d3d644cf68b85341e059f5685b13b5722b1a" integrity sha512-qQBVKqzU4ZVjRn8W0UbdfE4LaaIgcIWHOMrNnJ+PutrRzQ6ZzhmD/kRONvRWBfG9z3DU7pSKGwVYSR2hztsGuQ== @@ -6718,7 +6816,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.4", "@types/node@^22.7.5": +"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.4", "@types/node@^24.0.1": version "24.10.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.4.tgz#9d27c032a1b2c42a4eab8fb65c5856a8b8e098c4" integrity sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg== @@ -7332,6 +7430,11 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@vercel/oidc@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.1.0.tgz#066caee449b84079f33c7445fc862464fe10ec32" + integrity sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w== + "@vitejs/plugin-react@5.1.2": version "5.1.2" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz#46f47be184c05a18839cb8705d79578b469ac6eb" @@ -7598,6 +7701,16 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +ai@6.0.49, ai@^6.0.5: + version "6.0.49" + resolved "https://registry.yarnpkg.com/ai/-/ai-6.0.49.tgz#7db4d174af9ab8b51062ff1a935fbc32b127b30e" + integrity sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw== + dependencies: + "@ai-sdk/gateway" "3.0.22" + "@ai-sdk/provider" "3.0.5" + "@ai-sdk/provider-utils" "4.0.9" + "@opentelemetry/api" "1.9.0" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -8988,12 +9101,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -docx@*, docx@9.5.0, docx@^9.5.1: - version "9.5.0" - resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.0.tgz#586990c4ecf1c7e83290529997b33f2c029bbe68" - integrity sha512-WZggg9vVujFcTyyzfIVBBIxlCk51QvhLWl87wtI2zuBdz8C8C0mpRhEVwA2DZd7dXyY0AVejcEVDT9vn7Xm9FA== +docx@*, docx@9.5.1, docx@^9.5.1: + version "9.5.1" + resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.1.tgz#325c9c45dccf052e5780515d6068e80fdee81960" + integrity sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ== dependencies: - "@types/node" "^22.7.5" + "@types/node" "^24.0.1" hash.js "^1.1.7" jszip "^3.10.1" nanoid "^5.1.3" @@ -9634,9 +9747,9 @@ esprima@^4.0.0: integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -9696,6 +9809,11 @@ events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" + integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== + execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -11707,7 +11825,7 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: +js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== @@ -13418,7 +13536,7 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== -prosemirror-changeset@^2.3.0: +prosemirror-changeset@^2.3.0, prosemirror-changeset@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz#eee3299cfabc7a027694e9abdc4e85505e9dd5e7" integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ== @@ -13937,7 +14055,7 @@ react-dnd@^14.0.3: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@*, react-dom@19.2.3: +react-dom@*, react-dom@19.2.3, react-dom@^19.2.1: version "19.2.3" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17" integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg== @@ -14192,7 +14310,7 @@ react-window@^1.8.11: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@*, react@19.2.3: +react@*, react@19.2.3, react@^19.2.1: version "19.2.3" resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== @@ -15435,6 +15553,14 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swr@^2.2.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.3.8.tgz#aa15596321a34e575226a60576bade0b57adf7bf" + integrity sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.6.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -15532,6 +15658,11 @@ text-decoder@^1.1.0: dependencies: b4a "^1.6.4" +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" @@ -16904,6 +17035,11 @@ 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@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.9: version "5.0.9" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.9.tgz#389dcd0309b9c545d7a461bd3c54955962847654"