diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e174b20..7938a3d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to ## [Unreleased] +## Added + +- ✨(backend) annotate number of accesses on documents in list view #411 +- ✨(backend) allow users to mark/unmark documents as favorite #411 + +## Changes + +- ♻️(frontend) Improve Ai translations #478 +- 🐛(frontend) Fix hidden menu on Firefox #468 +- ⚡️(backend) optimize number of queries on document list view #411 + ## [1.8.2] - 2024-11-28 @@ -28,8 +39,6 @@ and this project adheres to ## Added -- ✨(backend) annotate number of accesses on documents in list view #411 -- ✨(backend) allow users to mark/unmark documents as favorite #411 - 🌐(backend) add German translation #259 - 🌐(frontend) add German translation #255 - ✨(frontend) add a broadcast store #387 @@ -41,7 +50,6 @@ and this project adheres to ## Changed -- ⚡️(backend) optimize number of queries on document list view #411 - 🚸(backend) improve users similarity search and sort results #391 - ♻️(frontend) simplify stores #402 - ✨(frontend) update $css Box props type to add styled components RuleSet #423 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 9a81fc47e..1ae17e688 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -426,11 +426,12 @@ class AITranslateSerializer(serializers.Serializer): language = serializers.ChoiceField( choices=tuple(enums.ALL_LANGUAGES.items()), required=True ) - text = serializers.CharField(required=True) + text = serializers.JSONField(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.") + if not isinstance(value, dict): + raise serializers.ValidationError("Text field must be a json object.") + return value diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 75c0f5c4c..df7f1dbb8 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -680,9 +680,9 @@ def ai_translate(self, request, *args, **kwargs): """ POST /api/v1.0/documents//ai-translate with expected data: - - text: str + - text: json - language: str [settings.LANGUAGES] - Return JSON response with the translated text. + Return the same json but with the value updated, keep the keys accordingly. """ # Check permissions first self.get_object() diff --git a/src/backend/core/services/ai_services.py b/src/backend/core/services/ai_services.py index b7c70405c..e94ef55f4 100644 --- a/src/backend/core/services/ai_services.py +++ b/src/backend/core/services/ai_services.py @@ -15,29 +15,33 @@ "Answer the prompt in markdown format. Return JSON: " '{"answer": "Your markdown answer"}. ' "Do not provide any other information." + "Preserve the language." ), "correct": ( "Correct grammar and spelling of the markdown text, " "preserving language and markdown formatting. " 'Return JSON: {"answer": "your corrected markdown text"}. ' "Do not provide any other information." + "Preserve the language." ), "rephrase": ( "Rephrase the given markdown text, " "preserving language and markdown formatting. " 'Return JSON: {"answer": "your rephrased markdown text"}. ' "Do not provide any other information." + "Preserve the language." ), "summarize": ( "Summarize the markdown text, preserving language and markdown formatting. " 'Return JSON: {"answer": "your markdown summary"}. ' "Do not provide any other information." + "Preserve the language." ), } AI_TRANSLATE = ( - "Translate the markdown text to {language:s}, preserving markdown formatting. " - 'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. ' + "Translate to {language:s} for every value of the json provided." + "Keep the same json but with the value updated, keep the keys accordingly." "Do not provide any other information." ) @@ -62,7 +66,7 @@ def call_ai_api(self, system_content, text): response_format={"type": "json_object"}, messages=[ {"role": "system", "content": system_content}, - {"role": "user", "content": json.dumps({"markdown_input": text})}, + {"role": "user", "content": json.dumps({"answer": text})}, ], ) diff --git a/src/backend/core/tests/documents/test_api_documents_ai_transform.py b/src/backend/core/tests/documents/test_api_documents_ai_transform.py index 91e16e4a5..51322ada2 100644 --- a/src/backend/core/tests/documents/test_api_documents_ai_transform.py +++ b/src/backend/core/tests/documents/test_api_documents_ai_transform.py @@ -86,9 +86,10 @@ def test_api_documents_ai_transform_anonymous_success(mock_create): "Summarize the markdown text, preserving language and markdown formatting. " 'Return JSON: {"answer": "your markdown summary"}. Do not provide any other ' "information." + "Preserve the language." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": "Hello"}'}, ], ) @@ -163,9 +164,10 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro "content": ( 'Answer the prompt in markdown format. Return JSON: {"answer": ' '"Your markdown answer"}. Do not provide any other information.' + "Preserve the language." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": "Hello"}'}, ], ) @@ -239,9 +241,10 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te "content": ( 'Answer the prompt in markdown format. Return JSON: {"answer": ' '"Your markdown answer"}. Do not provide any other information.' + "Preserve the language." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": "Hello"}'}, ], ) diff --git a/src/backend/core/tests/documents/test_api_documents_ai_translate.py b/src/backend/core/tests/documents/test_api_documents_ai_translate.py index 21547e7aa..2e026419d 100644 --- a/src/backend/core/tests/documents/test_api_documents_ai_translate.py +++ b/src/backend/core/tests/documents/test_api_documents_ai_translate.py @@ -86,16 +86,18 @@ def test_api_documents_ai_translate_anonymous_success(mock_create): """ document = factories.DocumentFactory(link_reach="public", link_role="editor") - answer = '{"answer": "Salut"}' + answer = '{"answer": {"tid-1": "Ola"}}' 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"}) + response = APIClient().post( + url, {"text": {"tid-1": "Hello"}, "language": "es"}, format="json" + ) assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} + assert response.json() == {"answer": {"tid-1": "Ola"}} mock_create.assert_called_once_with( model="llama", response_format={"type": "json_object"}, @@ -103,12 +105,12 @@ def test_api_documents_ai_translate_anonymous_success(mock_create): { "role": "system", "content": ( - "Translate the markdown text to Spanish, preserving markdown formatting. " - 'Return JSON: {"answer": "your translated markdown text in Spanish"}. ' + "Translate to Spanish for every value of the json provided." + "Keep the same json but with the value updated, keep the keys accordingly." "Do not provide any other information." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": {"tid-1": "Hello"}}'}, ], ) @@ -164,16 +166,18 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro document = factories.DocumentFactory(link_reach=reach, link_role=role) - answer = '{"answer": "Salut"}' + answer = '{"answer": {"tid-1": "Ola"}}' mock_create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content=answer))] ) url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es-co"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es-co"}, format="json" + ) assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} + assert response.json() == {"answer": {"tid-1": "Ola"}} mock_create.assert_called_once_with( model="llama", response_format={"type": "json_object"}, @@ -181,13 +185,12 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro { "role": "system", "content": ( - "Translate the markdown text to Colombian Spanish, " - "preserving markdown formatting. Return JSON: " - '{"answer": "your translated markdown text in Colombian Spanish"}. ' + "Translate to Colombian Spanish for every value of the json provided." + "Keep the same json but with the value updated, keep the keys accordingly." "Do not provide any other information." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": {"tid-1": "Hello"}}'}, ], ) @@ -242,16 +245,18 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te document=document, team="lasuite", role=role ) - answer = '{"answer": "Salut"}' + answer = '{"answer": {"tid-1": "Ola"}}' mock_create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content=answer))] ) url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" - response = client.post(url, {"text": "Hello", "language": "es-co"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es-co"}, format="json" + ) assert response.status_code == 200 - assert response.json() == {"answer": "Salut"} + assert response.json() == {"answer": {"tid-1": "Ola"}} mock_create.assert_called_once_with( model="llama", response_format={"type": "json_object"}, @@ -259,19 +264,18 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te { "role": "system", "content": ( - "Translate the markdown text to Colombian Spanish, " - "preserving markdown formatting. Return JSON: " - '{"answer": "your translated markdown text in Colombian Spanish"}. ' + "Translate to Colombian Spanish for every value of the json provided." + "Keep the same json but with the value updated, keep the keys accordingly." "Do not provide any other information." ), }, - {"role": "user", "content": '{"markdown_input": "Hello"}'}, + {"role": "user", "content": '{"answer": {"tid-1": "Hello"}}'}, ], ) -def test_api_documents_ai_translate_empty_text(): - """The text should not be empty when requesting AI translate.""" +def test_api_documents_ai_translate_should_be_json(): + """The text should be a json when requesting AI translate.""" user = factories.UserFactory() client = APIClient() @@ -283,7 +287,7 @@ def test_api_documents_ai_translate_empty_text(): response = client.post(url, {"text": " ", "language": "es"}) assert response.status_code == 400 - assert response.json() == {"text": ["This field may not be blank."]} + assert response.json() == {"text": ["Text field must be a json object."]} def test_api_documents_ai_translate_invalid_action(): @@ -296,7 +300,9 @@ def test_api_documents_ai_translate_invalid_action(): 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"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "invalid"}, format="json" + ) assert response.status_code == 400 assert response.json() == {"language": ['"invalid" is not a valid choice.']} @@ -322,13 +328,17 @@ def test_api_documents_ai_translate_throttling_document(mock_create): for _ in range(3): user = factories.UserFactory() client.force_login(user) - response = client.post(url, {"text": "Hello", "language": "es"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es"}, format="json" + ) 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"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es"}, format="json" + ) assert response.status_code == 429 assert response.json() == { @@ -356,13 +366,17 @@ def test_api_documents_ai_translate_throttling_user(mock_create): 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"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es"}, format="json" + ) 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"}) + response = client.post( + url, {"text": {"tid-1": "Hello"}, "language": "es"}, format="json" + ) assert response.status_code == 429 assert response.json() == { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d41660a5d..91570125e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -262,7 +262,7 @@ test.describe('Doc Editor', () => { if (request.method().includes('POST')) { await route.fulfill({ json: { - answer: 'Bonjour le monde', + answer: { 'tid-0': 'Bonjour le monde' }, }, }); } else { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx index 504d79b3e..c21b66f7c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useDocAITranslate.tsx @@ -4,12 +4,12 @@ import { APIError, errorCauses, fetchAPI } from '@/api'; export type DocAITranslate = { docId: string; - text: string; + text: Record; language: string; }; export type DocAITranslateResponse = { - answer: string; + answer: Record; }; export const docAITranslate = async ({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AIButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AIButton.tsx index 31c98291d..4ac58dd27 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AIButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AIButton.tsx @@ -1,3 +1,4 @@ +import { Block } from '@blocknote/core'; import { ComponentProps, useBlockNoteEditor, @@ -21,6 +22,12 @@ import { useDocAITransform, useDocAITranslate, } from '../api/'; +import { + Node, + addIdToTextNodes, + extractTextWithId, + updateTextsWithId, +} from '../utilsAI'; type LanguageTranslate = { value: string; @@ -212,14 +219,23 @@ const AIMenuItemTransform = ({ icon, }: PropsWithChildren) => { const { mutateAsync: requestAI, isPending } = useDocAITransform(); + const editor = useBlockNoteEditor(); + + const requestAIAction = async (selectedBlocks: Block[]) => { + const text = await editor.blocksToMarkdownLossy(selectedBlocks); - const requestAIAction = async (markdown: string) => { const responseAI = await requestAI({ - text: markdown, + text, action, docId, }); - return responseAI.answer; + + if (!responseAI || !responseAI.answer) { + throw new Error('No response from AI'); + } + + const markdown = await editor.tryParseMarkdownToBlocks(responseAI.answer); + editor.replaceBlocks(selectedBlocks, markdown); }; return ( @@ -242,14 +258,28 @@ const AIMenuItemTranslate = ({ language, }: PropsWithChildren) => { const { mutateAsync: requestAI, isPending } = useDocAITranslate(); + const editor = useBlockNoteEditor(); + + const requestAITranslate = async (selectedBlocks: Block[]) => { + addIdToTextNodes(selectedBlocks as Node); + const content = extractTextWithId(selectedBlocks as Node); - const requestAITranslate = async (markdown: string) => { const responseAI = await requestAI({ - text: markdown, + text: content, language, docId, }); - return responseAI.answer; + + if (!responseAI || !responseAI.answer) { + throw new Error('No response from AI'); + } + + updateTextsWithId(selectedBlocks as Node, responseAI.answer); + selectedBlocks.forEach((block) => { + if (editor.getBlock(block)) { + editor.replaceBlocks([block], [block]); + } + }); }; return ( @@ -264,7 +294,7 @@ const AIMenuItemTranslate = ({ }; interface AIMenuItemProps { - requestAI: (markdown: string) => Promise; + requestAI: (blocks: Block[]) => Promise; isPending: boolean; icon?: ReactNode; } @@ -280,31 +310,17 @@ const AIMenuItem = ({ const editor = useBlockNoteEditor(); const handleAIError = useHandleAIError(); - const handleAIAction = async () => { + const handleAIAction = () => { let selectedBlocks = editor.getSelection()?.blocks; if (!selectedBlocks || selectedBlocks.length === 0) { selectedBlocks = [editor.getTextCursorPosition().block]; - if (!selectedBlocks || selectedBlocks.length === 0) { return; } } - const markdown = await editor.blocksToMarkdownLossy(selectedBlocks); - - try { - const responseAI = await requestAI(markdown); - - if (!responseAI) { - return; - } - - const blockMarkdown = await editor.tryParseMarkdownToBlocks(responseAI); - editor.replaceBlocks(selectedBlocks, blockMarkdown); - } catch (error) { - handleAIError(error); - } + requestAI(selectedBlocks).catch(handleAIError); }; if (!Components) { @@ -338,6 +354,7 @@ const useHandleAIError = () => { return; } + console.error('AI', error); toast(t('AI seems busy! Please try again.'), VariantType.ERROR); }; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/utilsAI.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/utilsAI.ts new file mode 100644 index 000000000..c71ded886 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/utilsAI.ts @@ -0,0 +1,145 @@ +import { Block as CoreBlock } from '@blocknote/core'; + +type Block = Omit & { + tid?: string; + type?: string; + text?: string; + content: Block[] | Block; + children?: Block[] | Block; +}; +export type Node = Block[] | Block; + +let idCounter = 0; + +// Function to generate a unique id +function generateId() { + return `tid-${idCounter++}`; +} + +// Function to add a unique id to each text node +export function addIdToTextNodes(node: Node) { + if (Array.isArray(node)) { + node.forEach((child) => addIdToTextNodes(child)); + } else if (typeof node === 'object' && node !== null) { + if (node?.type === 'text') { + node.tid = generateId(); + } + + // Recursively process content and children + if (node.content) { + addIdToTextNodes(node.content); + } + + if (node.children) { + addIdToTextNodes(node.children); + } + + // Handle table content + if ( + !Array.isArray(node.content) && + node?.type === 'table' && + node.content && + node.content.type === 'tableContent' + ) { + const tableContent = node.content; + if (tableContent.rows) { + tableContent.rows.forEach((row) => { + if (row.cells) { + row.cells.forEach((cell) => { + addIdToTextNodes(cell as unknown as Node); + }); + } + }); + } + } + } +} + +// Function to extract texts with their tids into a flat JSON object +export function extractTextWithId( + node: Node, + texts: Record = {}, +) { + if (Array.isArray(node)) { + node.forEach((child) => extractTextWithId(child, texts)); + } else if (typeof node === 'object' && node !== null) { + if (node?.type === 'text' && node.tid) { + texts[node.tid] = node.text || ''; + } + + // Recursively process content and children + if (node.content) { + extractTextWithId(node.content, texts); + } + + if (node.children) { + extractTextWithId(node.children, texts); + } + + // Handle table content + if ( + !Array.isArray(node.content) && + node?.type === 'table' && + node.content && + node.content.type === 'tableContent' + ) { + const tableContent = node.content; + if (tableContent.rows) { + tableContent.rows.forEach((row) => { + if (row.cells) { + row.cells.forEach((cell) => { + extractTextWithId(cell as unknown as Node, texts); + }); + } + }); + } + } + } + return texts; +} + +// Function to update the original JSON using a second JSON containing updated texts +export function updateTextsWithId( + node: Node, + updatedTexts: Record, +) { + if (Array.isArray(node)) { + node.forEach((child) => updateTextsWithId(child, updatedTexts)); + } else if (typeof node === 'object' && node !== null) { + if ( + node?.type === 'text' && + node.tid && + updatedTexts[node.tid] !== undefined + ) { + node.text = updatedTexts[node.tid]; + } + + // Recursively process content and children + if (node.content) { + updateTextsWithId(node.content, updatedTexts); + } + + if (node.children) { + updateTextsWithId(node.children, updatedTexts); + } + + // Handle table content + if ( + !Array.isArray(node.content) && + node?.type === 'table' && + node.content && + node.content.type === 'tableContent' + ) { + const tableContent = node.content; + if (tableContent.rows) { + tableContent.rows.forEach((row) => { + if (row.cells) { + row.cells.forEach((cell) => { + updateTextsWithId(cell as unknown as Node, updatedTexts); + }); + } + }); + } + } + } +}