From 1070b91d2fd2f83f10dc8a9caff22af98a11b8f5 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 26 Feb 2026 11:47:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A9(project)=20add=20more=20backend=20?= =?UTF-8?q?AI=20feature=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blocknote AI feature is a bit flaky, we want to be able to disable it if to much issues arise, without having to do a new release. We add a bunch of feature flags to be able to disable the AI features if needed: - add AI_FEATURE_BLOCKNOTE_ENABLED, to display or not the feature powered by blocknote - add AI_FEATURE_LEGACY_ENABLED, to display or not the legacy AI features --- docs/env.md | 2 + docs/installation/compose.md | 2 + env.d/development/common | 2 + env.d/production.dist/backend | 2 + src/backend/core/api/viewsets.py | 4 +- .../documents/test_api_documents_ai_proxy.py | 9 +- src/backend/core/tests/test_api_config.py | 4 + .../core/tests/test_services_ai_services.py | 2 + src/backend/impress/settings.py | 13 + .../e2e/__tests__/app-impress/config.spec.ts | 20 - .../e2e/__tests__/app-impress/doc-ai.spec.ts | 339 ++++++++++++++++ .../__tests__/app-impress/doc-editor.spec.ts | 362 +----------------- .../e2e/__tests__/app-impress/utils-common.ts | 2 + .../impress/src/core/config/api/useConfig.tsx | 2 + .../doc-editor/components/BlockNoteEditor.tsx | 14 +- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 9 +- 16 files changed, 397 insertions(+), 391 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-ai.spec.ts diff --git a/docs/env.md b/docs/env.md index 913bd6d4..cdde8b8d 100644 --- a/docs/env.md +++ b/docs/env.md @@ -13,6 +13,8 @@ These are the environment variables you can set for the `impress-backend` contai | 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_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false | +| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true | | AI_MODEL | AI Model to use | | | AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | diff --git a/docs/installation/compose.md b/docs/installation/compose.md index f8bb49a9..26559e40 100644 --- a/docs/installation/compose.md +++ b/docs/installation/compose.md @@ -138,6 +138,8 @@ AI is disabled by default. To enable it, the following environment variables mus ```env AI_FEATURE_ENABLED=true # is false by default +AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default +AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it AI_BASE_URL=https://openaiendpoint.com AI_API_KEY= AI_MODEL= e.g. llama diff --git a/env.d/development/common b/env.d/development/common index f43d5779..3b0c0c88 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -64,6 +64,8 @@ USER_RECONCILIATION_FORM_URL=http://localhost:3000 # AI AI_FEATURE_ENABLED=true +AI_FEATURE_BLOCKNOTE_ENABLED=true +AI_FEATURE_LEGACY_ENABLED=true AI_BASE_URL=https://openaiendpoint.com AI_API_KEY=password AI_MODEL=llama diff --git a/env.d/production.dist/backend b/env.d/production.dist/backend index 106b64c0..e0e41d05 100644 --- a/env.d/production.dist/backend +++ b/env.d/production.dist/backend @@ -58,6 +58,8 @@ OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"] # AI #AI_FEATURE_ENABLED=true # is false by default +#AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default +#AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it #AI_BASE_URL=https://openaiendpoint.com #AI_API_KEY= #AI_MODEL= e.g. llama diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3f91a564..270dc94a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1852,7 +1852,7 @@ class DocumentViewSet( # Check permissions first self.get_object() - if not settings.AI_FEATURE_ENABLED: + if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_BLOCKNOTE_ENABLED: raise ValidationError("AI feature is not enabled.") ai_service = AIService() @@ -2572,6 +2572,8 @@ class ConfigView(drf.views.APIView): array_settings = [ "AI_BOT", "AI_FEATURE_ENABLED", + "AI_FEATURE_BLOCKNOTE_ENABLED", + "AI_FEATURE_LEGACY_ENABLED", "API_USERS_SEARCH_QUERY_MIN_LENGTH", "COLLABORATION_WS_URL", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", diff --git a/src/backend/core/tests/documents/test_api_documents_ai_proxy.py b/src/backend/core/tests/documents/test_api_documents_ai_proxy.py index 5229bdc3..443fa762 100644 --- a/src/backend/core/tests/documents/test_api_documents_ai_proxy.py +++ b/src/backend/core/tests/documents/test_api_documents_ai_proxy.py @@ -23,6 +23,8 @@ def ai_settings(settings): settings.AI_BASE_URL = "http://localhost-ai:12345/" settings.AI_API_KEY = "test-key" settings.AI_FEATURE_ENABLED = True + settings.AI_FEATURE_BLOCKNOTE_ENABLED = True + settings.AI_FEATURE_LEGACY_ENABLED = True settings.LANGFUSE_PUBLIC_KEY = None settings.AI_VERCEL_SDK_VERSION = 6 @@ -239,9 +241,12 @@ def test_api_documents_ai_proxy_success(mock_stream, via, role, mock_user_teams) mock_stream.assert_called_once() -def test_api_documents_ai_proxy_ai_feature_disabled(settings): +@pytest.mark.parametrize( + "setting_to_disable", ["AI_FEATURE_ENABLED", "AI_FEATURE_BLOCKNOTE_ENABLED"] +) +def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable): """When AI_FEATURE_ENABLED is False, the endpoint returns 400.""" - settings.AI_FEATURE_ENABLED = False + setattr(settings, setting_to_disable, False) user = factories.UserFactory() diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 41b5493f..098052f5 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -21,6 +21,8 @@ pytestmark = pytest.mark.django_db @override_settings( AI_BOT={"name": "Test Bot", "color": "#000000"}, AI_FEATURE_ENABLED=False, + AI_FEATURE_BLOCKNOTE_ENABLED=False, + AI_FEATURE_LEGACY_ENABLED=False, API_USERS_SEARCH_QUERY_MIN_LENGTH=6, COLLABORATION_WS_URL="http://testcollab/", COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True, @@ -47,6 +49,8 @@ def test_api_config(is_authenticated): assert response.json() == { "AI_BOT": {"name": "Test Bot", "color": "#000000"}, "AI_FEATURE_ENABLED": False, + "AI_FEATURE_BLOCKNOTE_ENABLED": False, + "AI_FEATURE_LEGACY_ENABLED": False, "API_USERS_SEARCH_QUERY_MIN_LENGTH": 6, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, diff --git a/src/backend/core/tests/test_services_ai_services.py b/src/backend/core/tests/test_services_ai_services.py index 8412aca5..ff4e12d1 100644 --- a/src/backend/core/tests/test_services_ai_services.py +++ b/src/backend/core/tests/test_services_ai_services.py @@ -29,6 +29,8 @@ def ai_settings(settings): settings.AI_BASE_URL = "http://example.com" settings.AI_API_KEY = "test-key" settings.AI_FEATURE_ENABLED = True + settings.AI_FEATURE_BLOCKNOTE_ENABLED = True + settings.AI_FEATURE_LEGACY_ENABLED = True settings.LANGFUSE_PUBLIC_KEY = None settings.AI_VERCEL_SDK_VERSION = 6 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 113eabe9..b75da3d4 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -710,9 +710,22 @@ class Base(Configuration): "hour": 100, "day": 500, } + # Master settings to enable AI features, if you set it to False, + # all AI features will be disabled even if the other settings are enabled. AI_FEATURE_ENABLED = values.BooleanValue( default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None ) + # Far better UI but more flaky for the moment + # ⚠️ AGPL license, be sure to comply with the Blocknote license + # if you enable it (https://www.blocknotejs.org/) + AI_FEATURE_BLOCKNOTE_ENABLED = values.BooleanValue( + default=False, environ_name="AI_FEATURE_BLOCKNOTE_ENABLED", environ_prefix=None + ) + # UI with less features but more stable + # MIT friendly license, you can enable it without worrying about the license + AI_FEATURE_LEGACY_ENABLED = values.BooleanValue( + default=True, environ_name="AI_FEATURE_LEGACY_ENABLED", environ_prefix=None + ) AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) AI_VERCEL_SDK_VERSION = values.IntegerValue( 6, environ_name="AI_VERCEL_SDK_VERSION", environ_prefix=None diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index bf51e148..a11d897d 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -76,26 +76,6 @@ test.describe('Config', () => { expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/'); }); - test('it checks the AI feature flag from config endpoint', async ({ - page, - browserName, - }) => { - await overrideConfig(page, { - AI_FEATURE_ENABLED: false, - }); - - await page.goto('/'); - - await createDoc(page, 'doc-ai-feature', browserName, 1); - - await page.locator('.bn-block-outer').last().fill('Anything'); - await page.getByText('Anything').selectText(); - expect( - await page.locator('button[data-test="convertMarkdown"]').count(), - ).toBe(1); - await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); - }); - test('it checks that Crisp is trying to init from config endpoint', async ({ page, }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-ai.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-ai.spec.ts new file mode 100644 index 00000000..99f82923 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-ai.spec.ts @@ -0,0 +1,339 @@ +/* eslint-disable playwright/no-conditional-expect */ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + mockedDocument, + overrideConfig, + verifyDocName, +} from './utils-common'; +import { + mockAIResponse, + openSuggestionMenu, + writeInEditor, +} from './utils-editor'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc AI feature', () => { + [ + { + AI_FEATURE_ENABLED: false, + selector: 'Ask AI', + }, + { + AI_FEATURE_ENABLED: true, + AI_FEATURE_BLOCKNOTE_ENABLED: false, + selector: 'Ask AI', + }, + { + AI_FEATURE_ENABLED: true, + AI_FEATURE_LEGACY_ENABLED: false, + selector: 'AI', + }, + ].forEach((config) => { + test(`it checks the AI feature flag from config endpoint: ${JSON.stringify(config)}`, async ({ + page, + browserName, + }) => { + await overrideConfig(page, config); + + await page.goto('/'); + + await createDoc(page, 'doc-ai-feature', browserName, 1); + + await page.locator('.bn-block-outer').last().fill('Anything'); + await page.getByText('Anything').selectText(); + expect( + await page.locator('button[data-test="convertMarkdown"]').count(), + ).toBe(1); + await expect( + page.getByRole('button', { name: config.selector, exact: true }), + ).toBeHidden(); + }); + }); + + test('it checks the AI feature and accepts changes', async ({ + page, + browserName, + }) => { + await overrideConfig(page, { + AI_BOT: { + name: 'Albert AI', + color: '#8bc6ff', + }, + }); + + await mockAIResponse(page); + + await page.goto('/'); + + await createDoc(page, 'doc-ai', browserName, 1); + + await openSuggestionMenu({ page }); + await page.getByText('Ask AI').click(); + await expect( + page.getByRole('option', { name: 'Continue Writing' }), + ).toBeVisible(); + await expect(page.getByRole('option', { name: 'Summarize' })).toBeVisible(); + + await page.keyboard.press('Escape'); + + const editor = await writeInEditor({ page, text: 'Hello World' }); + await editor.getByText('Hello World').selectText(); + + // Check from toolbar + await page.getByRole('button', { name: 'Ask AI' }).click(); + + await expect( + page.getByRole('option', { name: 'Improve Writing' }), + ).toBeVisible(); + await expect( + page.getByRole('option', { name: 'Fix Spelling' }), + ).toBeVisible(); + await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible(); + + await page.getByRole('option', { name: 'Translate' }).click(); + await page + .getByRole('textbox', { name: 'Ask anything...' }) + .fill('Translate into french'); + await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter'); + await expect(editor.getByText('Albert AI')).toBeVisible(); + await page + .locator('p.bn-mt-suggestion-menu-item-title') + .getByText('Accept') + .click(); + + await expect(editor.getByText('Bonjour le monde')).toBeVisible(); + + // Check Suggestion menu + await page.locator('.bn-block-outer').last().fill('/'); + await expect(page.getByText('Write with AI')).toBeVisible(); + + // 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(); + }); + + test('it reverts with the AI feature', async ({ page, browserName }) => { + await overrideConfig(page, { + AI_BOT: { + name: 'Albert AI', + color: '#8bc6ff', + }, + }); + + await mockAIResponse(page); + + await page.goto('/'); + + await createDoc(page, 'doc-ai', browserName, 1); + + const editor = await writeInEditor({ page, text: 'Hello World' }); + await editor.getByText('Hello World').selectText(); + + // Check from toolbar + await page.getByRole('button', { name: 'Ask AI' }).click(); + + await page.getByRole('option', { name: 'Translate' }).click(); + await page + .getByRole('textbox', { name: 'Ask anything...' }) + .fill('Translate into french'); + await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter'); + await expect(editor.getByText('Albert AI')).toBeVisible(); + await expect(editor.getByText('Bonjour le monde')).toBeVisible(); + await page + .locator('p.bn-mt-suggestion-menu-item-title') + .getByText('Revert') + .click(); + + await expect(editor.getByText('Hello World')).toBeVisible(); + }); + + test('it checks the AI buttons feature legacy', async ({ + page, + browserName, + }) => { + await page.route(/.*\/ai-translate\//, async (route) => { + const request = route.request(); + if (request.method().includes('POST')) { + await route.fulfill({ + json: { + answer: 'Hallo Welt', + }, + }); + } else { + await route.continue(); + } + }); + + await createDoc(page, 'doc-ai', browserName, 1); + + await page.locator('.bn-block-outer').last().fill('Hello World'); + + const editor = page.locator('.ProseMirror'); + await editor.getByText('Hello').selectText(); + + await page.getByRole('button', { name: 'AI', exact: true }).click(); + + await expect( + page.getByRole('menuitem', { name: 'Use as prompt' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Rephrase' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Summarize' }), + ).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Language' }), + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Language' }).hover(); + await expect( + page.getByRole('menuitem', { name: 'English', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'French', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'German', exact: true }), + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'German', exact: true }).click(); + + await expect(editor.getByText('Hallo Welt')).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', + }, + }, + ], + 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', exact: true }), + ).toBeHidden(); + return; + } + + await page.getByRole('button', { name: 'AI', exact: true }).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(); + } + }); + }); + + 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_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(); + }); +}); 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 fa67c658..51583cd4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable playwright/no-conditional-expect */ import path from 'path'; import { expect, test } from '@playwright/test'; @@ -7,16 +6,10 @@ import cs from 'convert-stream'; import { createDoc, goToGridDoc, - mockedDocument, overrideConfig, verifyDocName, } from './utils-common'; -import { - getEditor, - mockAIResponse, - openSuggestionMenu, - writeInEditor, -} from './utils-editor'; +import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor'; import { connectOtherUserToDoc, updateShareLink } from './utils-share'; import { createRootSubPage, @@ -390,359 +383,6 @@ test.describe('Doc Editor', () => { await expect(image).toHaveAttribute('aria-hidden', 'true'); }); - test('it checks the AI feature and accepts changes', async ({ - page, - browserName, - }) => { - await overrideConfig(page, { - AI_BOT: { - name: 'Albert AI', - color: '#8bc6ff', - }, - }); - - await mockAIResponse(page); - - await page.goto('/'); - - await createDoc(page, 'doc-ai', browserName, 1); - - await openSuggestionMenu({ page }); - await page.getByText('Ask AI').click(); - await expect( - page.getByRole('option', { name: 'Continue Writing' }), - ).toBeVisible(); - await expect(page.getByRole('option', { name: 'Summarize' })).toBeVisible(); - - await page.keyboard.press('Escape'); - - const editor = await writeInEditor({ page, text: 'Hello World' }); - await editor.getByText('Hello World').selectText(); - - // Check from toolbar - await page.getByRole('button', { name: 'Ask AI' }).click(); - - await expect( - page.getByRole('option', { name: 'Improve Writing' }), - ).toBeVisible(); - await expect( - page.getByRole('option', { name: 'Fix Spelling' }), - ).toBeVisible(); - await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible(); - - await page.getByRole('option', { name: 'Translate' }).click(); - await page - .getByRole('textbox', { name: 'Ask anything...' }) - .fill('Translate into french'); - await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter'); - await expect(editor.getByText('Albert AI')).toBeVisible(); - await page - .locator('p.bn-mt-suggestion-menu-item-title') - .getByText('Accept') - .click(); - - await expect(editor.getByText('Bonjour le monde')).toBeVisible(); - - // Check Suggestion menu - await page.locator('.bn-block-outer').last().fill('/'); - await expect(page.getByText('Write with AI')).toBeVisible(); - - // 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(); - }); - - test('it reverts with the AI feature', async ({ page, browserName }) => { - await overrideConfig(page, { - AI_BOT: { - name: 'Albert AI', - color: '#8bc6ff', - }, - }); - - await mockAIResponse(page); - - await page.goto('/'); - - await createDoc(page, 'doc-ai', browserName, 1); - - const editor = await writeInEditor({ page, text: 'Hello World' }); - await editor.getByText('Hello World').selectText(); - - // Check from toolbar - await page.getByRole('button', { name: 'Ask AI' }).click(); - - await page.getByRole('option', { name: 'Translate' }).click(); - await page - .getByRole('textbox', { name: 'Ask anything...' }) - .fill('Translate into french'); - await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter'); - await expect(editor.getByText('Albert AI')).toBeVisible(); - await expect(editor.getByText('Bonjour le monde')).toBeVisible(); - await page - .locator('p.bn-mt-suggestion-menu-item-title') - .getByText('Revert') - .click(); - - await expect(editor.getByText('Hello World')).toBeVisible(); - }); - - /** - * We have to skip this test for the CI for now, we cannot assert - * it because of `process.env.NEXT_PUBLIC_PUBLISH_AS_MIT` that is set - * at build time. - * It can be interesting to keep it for local tests. - */ - test.skip('it checks the AI buttons feature', async ({ - page, - browserName, - }) => { - await page.route(/.*\/ai-translate\//, async (route) => { - const request = route.request(); - if (request.method().includes('POST')) { - await route.fulfill({ - json: { - answer: 'Bonjour le monde', - }, - }); - } else { - await route.continue(); - } - }); - - await createDoc(page, 'doc-ai', browserName, 1); - - await page.locator('.bn-block-outer').last().fill('Hello World'); - - const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); - - await page.getByRole('button', { name: 'AI' }).click(); - - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Rephrase' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Summarize' }), - ).toBeVisible(); - await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'Language' }).hover(); - await expect( - page.getByRole('menuitem', { name: 'English', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'French', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'German', exact: true }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'English', exact: true }).click(); - - await expect(editor.getByText('Bonjour le monde')).toBeVisible(); - }); - - /** - * We have to skip this test for the CI for now, we cannot assert - * it because of `process.env.NEXT_PUBLIC_PUBLISH_AS_MIT` that is set - * at build time. - * It can be interesting to keep it for local tests. - */ - test.skip('it checks the AI buttons', async ({ page, browserName }) => { - await page.route(/.*\/ai-translate\//, async (route) => { - const request = route.request(); - if (request.method().includes('POST')) { - await route.fulfill({ - json: { - answer: 'Bonjour le monde', - }, - }); - } else { - await route.continue(); - } - }); - - await createDoc(page, 'doc-ai', browserName, 1); - - await page.locator('.bn-block-outer').last().fill('Hello World'); - - const editor = page.locator('.ProseMirror'); - await editor.getByText('Hello').selectText(); - - await page.getByRole('button', { name: 'AI', exact: true }).click(); - - await expect( - page.getByRole('menuitem', { name: 'Use as prompt' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Rephrase' }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Summarize' }), - ).toBeVisible(); - await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'Language' }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'Language' }).hover(); - await expect( - page.getByRole('menuitem', { name: 'English', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'French', exact: true }), - ).toBeVisible(); - await expect( - page.getByRole('menuitem', { name: 'German', exact: true }), - ).toBeVisible(); - - await page.getByRole('menuitem', { name: 'English', exact: true }).click(); - - await expect(editor.getByText('Bonjour le monde')).toBeVisible(); - }); - - /** - * We have to skip this test for the CI for now, we cannot assert - * it because of `process.env.NEXT_PUBLIC_PUBLISH_AS_MIT` that is set - * at build time. - * It can be interesting to keep it for local tests. - */ - [ - { ai_transform: false, ai_translate: false }, - { ai_transform: true, ai_translate: false }, - { ai_transform: false, ai_translate: true }, - ].forEach(({ ai_transform, ai_translate }) => { - test.skip(`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', - }, - }, - ], - 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', exact: true }), - ).toBeHidden(); - return; - } - - await page.getByRole('button', { name: 'AI', exact: true }).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(); - } - }); - }); - - 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_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 }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 78a97982..920497d2 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -14,6 +14,8 @@ export const CONFIG = { color: '#8bc6ff', }, AI_FEATURE_ENABLED: true, + AI_FEATURE_BLOCKNOTE_ENABLED: true, + AI_FEATURE_LEGACY_ENABLED: true, API_USERS_SEARCH_QUERY_MIN_LENGTH: 3, CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 03f74209..936747d8 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -29,6 +29,8 @@ interface ThemeCustomization { export interface ConfigResponse { AI_BOT: { name: string; color: string }; AI_FEATURE_ENABLED?: boolean; + AI_FEATURE_BLOCKNOTE_ENABLED?: boolean; + AI_FEATURE_LEGACY_ENABLED?: boolean; API_USERS_SEARCH_QUERY_MIN_LENGTH?: number; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 28731c2d..36f70114 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -105,8 +105,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { 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 aiBlockNoteAllowed = !!( + conf?.AI_FEATURE_ENABLED && + conf?.AI_FEATURE_BLOCKNOTE_ENABLED && + doc.abilities?.ai_proxy + ); + const aiExtension = useAI?.(doc.id, aiBlockNoteAllowed); const collabName = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); @@ -268,11 +272,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { comments={showComments} aria-label={t('Document editor')} > - {aiAllowed && AIMenuController && AIMenu && ( + {aiBlockNoteAllowed && AIMenuController && AIMenu && ( )} - - + + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index eec8b33a..968a4936 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -79,7 +79,7 @@ export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => { {toolbarItems} {/* Extra button to do some AI powered actions - only if AIToolbarButton is not available because of MIT license */} - {conf?.AI_FEATURE_ENABLED && !AIToolbarButton && ( + {conf?.AI_FEATURE_ENABLED && conf?.AI_FEATURE_LEGACY_ENABLED && ( )} @@ -87,7 +87,12 @@ export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => { ); - }, [toolbarItems, aiAllowed, conf?.AI_FEATURE_ENABLED]); + }, [ + toolbarItems, + aiAllowed, + conf?.AI_FEATURE_ENABLED, + conf?.AI_FEATURE_LEGACY_ENABLED, + ]); return ( <>