mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-25 17:15:01 +02:00
Compare commits
16 Commits
fix/link-p
...
refacto/bl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86bde354a5 | ||
|
|
8ec31d75d7 | ||
|
|
2c4c65b05c | ||
|
|
610a469a08 | ||
|
|
c8c58ddbdb | ||
|
|
b6b0748ab3 | ||
|
|
79b86b069b | ||
|
|
96a759400a | ||
|
|
0ec06e81d6 | ||
|
|
b90e6271d9 | ||
|
|
980f882f2f | ||
|
|
270d87b0a4 | ||
|
|
dd68b5a1b3 | ||
|
|
91aa9d6acb | ||
|
|
08b04dea90 | ||
|
|
cc7ed88498 |
1
.github/workflows/docker-hub.yml
vendored
1
.github/workflows/docker-hub.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'refacto/blocknote-ai'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -17,7 +17,6 @@ from rest_framework import serializers
|
||||
|
||||
from core import choices, enums, models, utils, validators
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
Converter,
|
||||
@@ -792,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_text(self, value):
|
||||
"""Ensure the text field is not empty."""
|
||||
def validate_messages(self, messages):
|
||||
"""Validate messages structure."""
|
||||
# Ensure each message has the required fields
|
||||
for message in messages:
|
||||
if (
|
||||
not isinstance(message, dict)
|
||||
or "role" not in message
|
||||
or "content" not in message
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Each message must have 'role' and 'content' fields"
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
def validate_model(self, value):
|
||||
"""Validate model value is the same than settings.AI_MODEL"""
|
||||
if value != settings.AI_MODEL:
|
||||
raise serializers.ValidationError(f"{value} is not a valid model")
|
||||
|
||||
if len(value.strip()) == 0:
|
||||
raise serializers.ValidationError("Text field cannot be empty.")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -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/<resource_id>/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 @@ class DocumentViewSet(
|
||||
@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/<resource_id>/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/<resource_id>/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)
|
||||
if not settings.AI_FEATURE_ENABLED:
|
||||
raise ValidationError("AI feature is not enabled.")
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
action = serializer.validated_data["action"]
|
||||
ai_service = AIService()
|
||||
|
||||
response = AIService().transform(text, action)
|
||||
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"),
|
||||
)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
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
|
||||
|
||||
@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/<resource_id>/ai-translate
|
||||
with expected data:
|
||||
- text: str
|
||||
- language: str [settings.LANGUAGES]
|
||||
Return JSON response with the translated text.
|
||||
"""
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
language = serializer.validated_data["language"]
|
||||
|
||||
response = AIService().translate(text, language)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
def _reject_invalid_ips(self, ips):
|
||||
"""
|
||||
@@ -2337,7 +2307,10 @@ class ConfigView(drf.views.APIView):
|
||||
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",
|
||||
|
||||
@@ -783,8 +783,7 @@ class Document(MP_Node, BaseModel):
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
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":"<id$>", "block":"<p>...</p>" }
|
||||
IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element.
|
||||
|
||||
Add:
|
||||
{ "type":"add", "referenceId":"<id$>", "position":"before|after", "blocks":["<p>...</p>"] }
|
||||
IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS.
|
||||
Each item MUST be a STRING containing a SINGLE valid HTML element.
|
||||
|
||||
Delete:
|
||||
{ "type":"delete", "id":"<id$>" }
|
||||
|
||||
IDs ALWAYS end with "$". Use ids EXACTLY as provided.
|
||||
|
||||
Return ONLY the JSON tool input. No prose, no markdown.
|
||||
"""
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
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."""
|
||||
"""
|
||||
Backward-compatible proxy service for your existing viewset:
|
||||
|
||||
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)
|
||||
stream_proxy(provider, url, method, headers, body) -> yields bytes
|
||||
|
||||
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},
|
||||
],
|
||||
)
|
||||
Plus: hardening payload so BlockNote tool calls are valid.
|
||||
"""
|
||||
|
||||
content = response.choices[0].message.content
|
||||
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")
|
||||
|
||||
if not content:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
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
|
||||
|
||||
return {"answer": content}
|
||||
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 transform(self, text, action):
|
||||
"""Transform text based on specified action."""
|
||||
system_content = AI_ACTIONS[action]
|
||||
return self.call_ai_api(system_content, text)
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
686
src/backend/core/tests/documents/test_api_documents_ai_proxy.py
Normal file
686
src/backend/core/tests/documents/test_api_documents_ai_proxy.py
Normal file
@@ -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."]
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,10 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@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"],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 @@ from core.services.ai_services import AIService
|
||||
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 @@ pytestmark = pytest.mark.django_db
|
||||
("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",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__client_invalid_response(mock_create):
|
||||
"""Fail when the client response is invalid"""
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=None))]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="AI response does not contain an answer",
|
||||
match="Failed to proxy AI request: Mocked client error",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
AIService().proxy({"messages": [{"role": "user", "content": "hello"}]})
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success(mock_create):
|
||||
def test_services_ai_proxy_success(mock_create):
|
||||
"""The AI request should work as expect when called with valid arguments."""
|
||||
|
||||
mock_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"}]})
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut"}
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_services_ai_proxy_with_stream(mock_create):
|
||||
"""The AI request should work as expect when called with valid arguments."""
|
||||
|
||||
mock_create.return_value = {
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "test-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Salut"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = AIService().proxy(
|
||||
{"messages": [{"role": "user", "content": "hello"}]}, stream=True
|
||||
)
|
||||
|
||||
expected_response = {
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "test-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Salut"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
assert response == expected_response
|
||||
mock_create.assert_called_once_with(
|
||||
messages=[{"role": "user", "content": "hello"}], stream=True
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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": "<p>Bonjour le monde</p>"}]}',
|
||||
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' }),
|
||||
page.getByRole('option', { name: 'Improve Writing' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Rephrase' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Summarize' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
page.getByRole('option', { name: 'Fix Spelling' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Language' }).hover();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'English', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'French', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'German', exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('option', { name: 'Translate' }).click();
|
||||
await page.getByPlaceholder('Ask AI anything…').fill('French');
|
||||
await page.getByPlaceholder('Ask AI anything…').press('Enter');
|
||||
await expect(editor.getByText('Albert AI')).toBeVisible();
|
||||
await page
|
||||
.locator('p.bn-mt-suggestion-menu-item-title')
|
||||
.getByText('Accept')
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'English', exact: true }).click();
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
|
||||
// Check Suggestion menu
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await expect(page.getByText('Write with AI')).toBeVisible();
|
||||
|
||||
// Reload the page to check that the AI change is still there
|
||||
await page.goto(page.url());
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
});
|
||||
|
||||
[
|
||||
{ ai_transform: false, ai_translate: false },
|
||||
{ ai_transform: true, ai_translate: false },
|
||||
{ ai_transform: false, ai_translate: true },
|
||||
].forEach(({ ai_transform, ai_translate }) => {
|
||||
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
|
||||
role: 'owner',
|
||||
user: {
|
||||
email: 'super@owner.com',
|
||||
full_name: 'Super Owner',
|
||||
},
|
||||
test(`it checks ai_proxy ability`, async ({ page, browserName }) => {
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
|
||||
role: 'owner',
|
||||
user: {
|
||||
email: 'super@owner.com',
|
||||
full_name: 'Super Owner',
|
||||
},
|
||||
],
|
||||
abilities: {
|
||||
destroy: true, // Means owner
|
||||
link_configuration: true,
|
||||
ai_transform,
|
||||
ai_translate,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'restricted',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-ai',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').selectText();
|
||||
|
||||
if (!ai_transform && !ai_translate) {
|
||||
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
if (ai_transform) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
|
||||
if (ai_translate) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
],
|
||||
abilities: {
|
||||
destroy: true, // Means owner
|
||||
link_configuration: true,
|
||||
ai_proxy: false,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'restricted',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-ai-proxy',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').selectText();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await expect(page.getByText('Write with AI')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it downloads unsafe files', async ({ page, browserName }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './checkDocMediaStatus';
|
||||
export * from './useCreateDocUpload';
|
||||
export * from './useDocAITransform';
|
||||
export * from './useDocAITranslate';
|
||||
|
||||
@@ -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<DocAITransformResponse> => {
|
||||
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<DocAITransformResponse>;
|
||||
};
|
||||
|
||||
export function useDocAITransform() {
|
||||
return useMutation<DocAITransformResponse, APIError, DocAITransform>({
|
||||
mutationFn: docAITransform,
|
||||
});
|
||||
}
|
||||
@@ -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<DocAITranslateResponse> => {
|
||||
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<DocAITranslateResponse>;
|
||||
};
|
||||
|
||||
export function useDocAITranslate() {
|
||||
return useMutation<DocAITranslateResponse, APIError, DocAITranslate>({
|
||||
mutationFn: docAITranslate,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.89904 7.9473C6.0847 7.9473 6.19816 7.84673 6.23941 7.6456C6.31677 7.17114 6.39671 6.76887 6.47922 6.43881C6.56174 6.10359 6.66746 5.82768 6.79639 5.61108C6.92532 5.38932 7.09551 5.21139 7.30696 5.0773C7.5184 4.93806 7.78916 4.82718 8.11922 4.74466C8.44928 4.66215 8.85928 4.58737 9.34922 4.52033C9.56066 4.49454 9.66639 4.3785 9.66639 4.17221C9.66639 3.98139 9.56066 3.87051 9.34922 3.83957C8.8696 3.77768 8.46475 3.70548 8.13469 3.62297C7.80979 3.53529 7.53903 3.42441 7.32243 3.29033C7.11098 3.15624 6.93822 2.98089 6.80413 2.76429C6.6752 2.54253 6.5669 2.26662 6.47922 1.93656C6.39671 1.60649 6.31677 1.20165 6.23941 0.722032C6.19816 0.515743 6.0847 0.412598 5.89904 0.412598C5.70306 0.412598 5.58702 0.515743 5.55092 0.722032C5.47872 1.1965 5.40136 1.60134 5.31885 1.93656C5.23633 2.26662 5.12803 2.53995 4.99394 2.75655C4.86501 2.97316 4.69483 3.15108 4.48338 3.29033C4.27193 3.42441 4.00118 3.53272 3.67112 3.61523C3.34106 3.69775 2.93364 3.77253 2.44886 3.83957C2.23741 3.87051 2.13169 3.98139 2.13169 4.17221C2.13169 4.36819 2.23741 4.48422 2.44886 4.52033C3.04709 4.60284 3.52929 4.70083 3.89546 4.81429C4.26162 4.92774 4.54784 5.0902 4.75413 5.30164C4.96042 5.51309 5.1203 5.80705 5.23376 6.18353C5.35237 6.55485 5.45809 7.04221 5.55092 7.6456C5.59218 7.84673 5.70822 7.9473 5.89904 7.9473ZM2.53395 9.27786C2.6732 9.27786 2.75829 9.20824 2.78923 9.06899C2.83565 8.77503 2.87691 8.54296 2.91301 8.37277C2.95426 8.20774 3.01357 8.08138 3.09093 7.99371C3.17345 7.90604 3.30238 7.839 3.47772 7.79258C3.65307 7.74617 3.90061 7.69459 4.22036 7.63786C4.35961 7.61208 4.42923 7.52956 4.42923 7.39032C4.42923 7.25623 4.35961 7.17629 4.22036 7.15051C3.90061 7.09894 3.65307 7.05252 3.47772 7.01126C3.30753 6.96485 3.1786 6.90038 3.09093 6.81787C3.00841 6.73019 2.94911 6.60126 2.91301 6.43107C2.87691 6.26089 2.83565 6.02623 2.78923 5.72711C2.75313 5.57756 2.66804 5.50278 2.53395 5.50278C2.40502 5.50278 2.3225 5.57756 2.2864 5.72711C2.23483 6.02108 2.19099 6.25057 2.15489 6.4156C2.11879 6.58063 2.05949 6.70699 1.97697 6.79466C1.89961 6.87717 1.77326 6.94164 1.59791 6.98805C1.42773 7.03447 1.18276 7.08862 0.863011 7.15051C0.713451 7.17629 0.638672 7.25623 0.638672 7.39032C0.638672 7.52956 0.721187 7.61208 0.886218 7.63786C1.19565 7.68944 1.43546 7.73843 1.60565 7.78485C1.77584 7.8261 1.89961 7.89057 1.97697 7.97824C2.05949 8.06592 2.11879 8.19227 2.15489 8.3573C2.19099 8.52749 2.23483 8.75956 2.2864 9.05352C2.32766 9.20308 2.41018 9.27786 2.53395 9.27786Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.4 21L3 19.6L10.525 12.05L6 10.925L10.95 7.85L10.525 2L15 5.775L20.4 3.575L18.225 9L22 13.45L16.15 13.05L13.05 18L11.925 13.475L4.4 21ZM5 8L3 6L5 4L7 6L5 8ZM13.875 12.925L15.075 10.95L17.4 11.125L15.9 9.35L16.775 7.2L14.625 8.075L12.85 6.6L13.025 8.9L11.05 10.125L13.3 10.675L13.875 12.925ZM18 21L16 19L18 17L20 19L18 21Z"
|
||||
fill="#303030"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -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 (
|
||||
<>
|
||||
<AIMenuStyle />
|
||||
<AIMenuDefault
|
||||
items={(editor: DocsBlockNoteEditor, aiResponseStatus) => {
|
||||
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: <IconWandStar />,
|
||||
};
|
||||
} else if (item.key === 'translate') {
|
||||
return {
|
||||
...item,
|
||||
icon: (
|
||||
<Icon
|
||||
iconName="translate"
|
||||
$color="inherit"
|
||||
$size="18px"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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: (
|
||||
<Icon
|
||||
iconName="check_circle"
|
||||
$color="inherit"
|
||||
$size="18px"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
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 (
|
||||
<Box
|
||||
$css={css`
|
||||
& > 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"
|
||||
>
|
||||
<Components.Generic.Toolbar.Button
|
||||
className="bn-button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['xs']}
|
||||
$padding={{ right: '2xs' }}
|
||||
>
|
||||
<Text
|
||||
className="--docs--icon-bg"
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$css={css`
|
||||
border: 1px solid var(--c--theme--colors--greyscale-100);
|
||||
transition: all 0.1s ease-in;
|
||||
`}
|
||||
$radius="100%"
|
||||
$padding="0.15rem"
|
||||
>
|
||||
<IconAI width="16px" />
|
||||
</Text>
|
||||
{t('Ask AI')}
|
||||
</Box>
|
||||
</Components.Generic.Toolbar.Button>
|
||||
<Box
|
||||
$background={colorsTokens['gray-100']}
|
||||
$width="1px"
|
||||
$height="70%"
|
||||
$margin={{ left: '2px' }}
|
||||
$css={css`
|
||||
align-self: center;
|
||||
`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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<HTMLDivElement>(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')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
{aiAllowed && AIMenuController && AIMenu && (
|
||||
<AIMenuController aiMenu={AIMenu} />
|
||||
)}
|
||||
<BlockNoteSuggestionMenu aiAllowed={aiAllowed} />
|
||||
<BlockNoteToolbar aiAllowed={aiAllowed} />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
@@ -311,7 +339,7 @@ export const BlockNoteReader = ({
|
||||
slashMenu={false}
|
||||
comments={false}
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
<BlockNoteToolbar aiAllowed={false} />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { Block } from '@blocknote/core';
|
||||
import {
|
||||
ComponentProps,
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import {
|
||||
Loader,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useDocOptions, useDocStore } from '@/docs/doc-management/';
|
||||
|
||||
import {
|
||||
AITransformActions,
|
||||
useDocAITransform,
|
||||
useDocAITranslate,
|
||||
} from '../../api';
|
||||
|
||||
type LanguageTranslate = {
|
||||
value: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
const sortByPopularLanguages = (
|
||||
languages: LanguageTranslate[],
|
||||
popularLanguages: string[],
|
||||
) => {
|
||||
languages.sort((a, b) => {
|
||||
const indexA = popularLanguages.indexOf(a.value);
|
||||
const indexB = popularLanguages.indexOf(b.value);
|
||||
|
||||
// If both languages are in the popular list, sort based on their order in popularLanguages
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
|
||||
// If only a is in the popular list, it should come first
|
||||
if (indexA !== -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// If only b is in the popular list, it should come first
|
||||
if (indexB !== -1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If neither a nor b is in the popular list, maintain their relative order
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
export function AIGroupButton() {
|
||||
const editor = useBlockNoteEditor();
|
||||
const Components = useComponentsContext();
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
const { t } = useTranslation();
|
||||
const { currentDoc } = useDocStore();
|
||||
const { data: docOptions } = useDocOptions();
|
||||
|
||||
const languages = useMemo(() => {
|
||||
const languages = docOptions?.actions.POST.language.choices;
|
||||
|
||||
if (!languages) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortByPopularLanguages(languages, [
|
||||
'fr',
|
||||
'en',
|
||||
'de',
|
||||
'es',
|
||||
'it',
|
||||
'pt',
|
||||
'nl',
|
||||
'pl',
|
||||
]);
|
||||
|
||||
return languages;
|
||||
}, [docOptions?.actions.POST.language.choices]);
|
||||
|
||||
const show = useMemo(() => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
if (!show || !editor.isEditable || !Components || !currentDoc || !languages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAITransform = currentDoc.abilities.ai_transform;
|
||||
const canAITranslate = currentDoc.abilities.ai_translate;
|
||||
|
||||
if (!canAITransform && !canAITranslate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button bn-menu-item --docs--ai-actions-menu-trigger"
|
||||
data-test="ai-actions"
|
||||
label="AI"
|
||||
mainTooltip={t('AI Actions')}
|
||||
icon={<Icon iconName="auto_awesome" $size="md" />}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
className="bn-menu-dropdown bn-drag-handle-menu --docs--ai-actions-menu"
|
||||
sub={true}
|
||||
>
|
||||
{canAITransform && (
|
||||
<>
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="text_fields" $size="s" />}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="refresh" $size="s" />}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="summarize" $size="s" />}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="check" $size="s" />}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="beautify"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="draw" $size="s" />}
|
||||
>
|
||||
{t('Beautify')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="emojify"
|
||||
docId={currentDoc.id}
|
||||
icon={<Icon iconName="emoji_emotions" $size="s" />}
|
||||
>
|
||||
{t('Emojify')}
|
||||
</AIMenuItemTransform>
|
||||
</>
|
||||
)}
|
||||
{canAITranslate && (
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item --docs--ai-translate-menu-trigger"
|
||||
subTrigger={true}
|
||||
>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
<Icon iconName="translate" $size="s" />
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown --docs--ai-translate-menu"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
key={language.value}
|
||||
language={language.value}
|
||||
docId={currentDoc.id}
|
||||
>
|
||||
{language.display_name}
|
||||
</AIMenuItemTranslate>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
)}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item is derived from Mantime, some props seem lacking or incorrect.
|
||||
*/
|
||||
type ItemDefault = ComponentProps['Generic']['Menu']['Item'];
|
||||
type ItemProps = Omit<ItemDefault, 'onClick'> & {
|
||||
rightSection?: ReactNode;
|
||||
closeMenuOnClick?: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
interface AIMenuItemTransformProps {
|
||||
action: AITransformActions;
|
||||
docId: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItemTransform = ({
|
||||
docId,
|
||||
action,
|
||||
children,
|
||||
icon,
|
||||
}: PropsWithChildren<AIMenuItemTransformProps>) => {
|
||||
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 (
|
||||
<AIMenuItem icon={icon} requestAI={requestAIAction} isPending={isPending}>
|
||||
{children}
|
||||
</AIMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface AIMenuItemTranslateProps {
|
||||
language: string;
|
||||
docId: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItemTranslate = ({
|
||||
children,
|
||||
docId,
|
||||
icon,
|
||||
language,
|
||||
}: PropsWithChildren<AIMenuItemTranslateProps>) => {
|
||||
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 += '<p><br/></p>';
|
||||
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 (
|
||||
<AIMenuItem
|
||||
icon={icon}
|
||||
requestAI={requestAITranslate}
|
||||
isPending={isPending}
|
||||
>
|
||||
{children}
|
||||
</AIMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface AIMenuItemProps {
|
||||
requestAI: (blocks: Block[]) => Promise<void>;
|
||||
isPending: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const AIMenuItem = ({
|
||||
requestAI,
|
||||
isPending,
|
||||
children,
|
||||
icon,
|
||||
}: PropsWithChildren<AIMenuItemProps>) => {
|
||||
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<ItemProps>;
|
||||
|
||||
return (
|
||||
<Item
|
||||
closeMenuOnClick={false}
|
||||
icon={icon}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void handleAIAction();
|
||||
}}
|
||||
rightSection={isPending ? <Loader size="small" /> : undefined}
|
||||
>
|
||||
{children}
|
||||
</Item>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -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<void>>();
|
||||
const { t } = useTranslation();
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
let toolbarItems = getFormattingToolbarItems([
|
||||
@@ -69,18 +68,17 @@ export const BlockNoteToolbar = () => {
|
||||
const formattingToolbar = useCallback(() => {
|
||||
return (
|
||||
<FormattingToolbar>
|
||||
{aiAllowed && AIToolbarButton && <AIToolbarButton />}
|
||||
|
||||
<CommentToolbarButton />
|
||||
|
||||
{toolbarItems}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
{conf?.AI_FEATURE_ENABLED && <AIGroupButton key="AIButton" />}
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
);
|
||||
}, [toolbarItems, conf?.AI_FEATURE_ENABLED]);
|
||||
}, [toolbarItems, aiAllowed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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:
|
||||
"@blocknote/core" "0.45.0"
|
||||
"@blocknote/xl-multi-column" "0.45.0"
|
||||
"@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.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"
|
||||
|
||||
Reference in New Issue
Block a user