From 1a022450c6a1cbcf366473a40125943efd0cc014 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 28 Jan 2026 14:39:48 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20new=20Blockno?= =?UTF-8?q?te=20AI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We integrate the new Blocknote AI feature into Docs, enhancing the document editing experience with AI capabilities. --- CHANGELOG.md | 1 + .../e2e/__tests__/app-impress/config.spec.ts | 4 +- .../__tests__/app-impress/doc-editor.spec.ts | 167 ++++++++++- .../e2e/__tests__/app-impress/utils-editor.ts | 54 ++++ src/frontend/apps/impress/package.json | 4 + .../docs/doc-editor/assets/IconAI.svg | 6 + .../docs/doc-editor/assets/ai-loader.svg | 32 ++ .../docs/doc-editor/assets/wand_stars.svg | 6 + .../docs/doc-editor/components/AI/AIMenu.tsx | 275 ++++++++++++++++++ .../components/AI/AIToolbarButton.tsx | 109 +++++++ .../docs/doc-editor/components/AI/IconAI.tsx | 79 +++++ .../docs/doc-editor/components/AI/index.ts | 3 + .../docs/doc-editor/components/AI/useAI.tsx | 31 ++ .../doc-editor/components/BlockNoteEditor.tsx | 17 +- .../components/BlockNoteSuggestionMenu.tsx | 14 +- .../BlockNoteToolBar/BlockNoteToolbar.tsx | 3 + .../docs/doc-editor/components/DocEditor.tsx | 2 +- .../docs/doc-editor/hook/useSaveDoc.tsx | 21 +- .../src/features/docs/doc-editor/styles.tsx | 15 + .../features/home/components/HomeContent.tsx | 2 +- .../apps/impress/src/layouts/PageLayout.tsx | 2 +- src/frontend/yarn.lock | 202 ++++++++++++- 22 files changed, 1028 insertions(+), 21 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/ai-loader.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIMenu.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIToolbarButton.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/IconAI.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5a0730..b27dabe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ### Added +- ✨(frontend) integrate new Blocknote AI feature #1016 - 👷(docker) add arm64 platform support for image builds - ✨(tracking) add UTM parameters to shared document links - ✨(frontend) add floating bar with leftpanel collapse button #1876 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 87112e5e..bf51e148 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -93,9 +93,7 @@ test.describe('Config', () => { expect( await page.locator('button[data-test="convertMarkdown"]').count(), ).toBe(1); - expect(await page.locator('button[data-test="ai-actions"]').count()).toBe( - 0, - ); + await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); }); test('it checks that Crisp is trying to init from config endpoint', async ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 1d9f5281..454f446d 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 @@ -11,7 +11,12 @@ import { overrideConfig, verifyDocName, } from './utils-common'; -import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor'; +import { + getEditor, + mockAIResponse, + openSuggestionMenu, + writeInEditor, +} from './utils-editor'; import { connectOtherUserToDoc, updateShareLink } from './utils-share'; import { createRootSubPage, @@ -39,6 +44,7 @@ test.describe('Doc Editor', () => { .selectText(); const toolbar = page.locator('.bn-formatting-toolbar'); + await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeVisible(); await expect( toolbar.locator('button[data-test="comment-toolbar-button"]'), ).toBeVisible(); @@ -64,9 +70,6 @@ test.describe('Doc Editor', () => { await expect( toolbar.locator('button[data-test="createLink"]'), ).toBeVisible(); - await expect( - toolbar.locator('button[data-test="ai-actions"]'), - ).toBeVisible(); await expect( toolbar.locator('button[data-test="convertMarkdown"]'), ).toBeVisible(); @@ -93,14 +96,12 @@ test.describe('Doc Editor', () => { await expect(image).toHaveAttribute('role', 'presentation'); - await image.dblclick(); + await image.click(); + await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeHidden(); await expect( toolbar.locator('button[data-test="comment-toolbar-button"]'), ).toBeHidden(); - await expect( - toolbar.locator('button[data-test="ai-actions"]'), - ).toBeHidden(); await expect( toolbar.locator('button[data-test="convertMarkdown"]'), ).toBeHidden(); @@ -389,6 +390,156 @@ 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(); + }); + + test('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(); + }); + test('it checks the AI buttons', async ({ page, browserName }) => { await page.route(/.*\/ai-translate\//, async (route) => { const request = route.request(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts index 568466fa..56b6b2db 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Page } from '@playwright/test'; export const getEditor = async ({ page }: { page: Page }) => { @@ -30,3 +32,55 @@ export const writeInEditor = async ({ .fill(text); return editor; }; + +export const mockAIResponse = async (page: Page) => { + await page.route(/.*\/ai-proxy\//, async (route) => { + const req = route.request(); + + if (req.method() !== 'POST') { + return route.continue(); + } + + // Extract the block ID from the request's selectedBlocks + const requestData = req.postDataJSON(); + const messages = requestData?.messages || []; + const userMessage = messages.find((msg: any) => msg.role === 'user'); + const documentState = userMessage?.metadata?.documentState; + const selectedBlocks = documentState?.selectedBlocks || []; + const blockId = selectedBlocks[0]?.id || 'initialBlockId$'; + + const sse = [ + `data: {"type":"start"}\n\n`, + `data: {"type":"start-step"}\n\n`, + `data: ${JSON.stringify({ + type: 'tool-input-available', + toolCallId: 'chatcmpl-mock-0', + toolName: 'applyDocumentOperations', + input: { + operations: [ + { + type: 'update', + id: blockId, + block: '

Bonjour le monde

', + }, + ], + }, + })}\n\n`, + `data: {"type":"finish-step"}\n\n`, + `data: {"type":"finish","finishReason":"tool-calls"}\n\n`, + `data: [DONE]\n\n`, + ].join(''); + + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + 'x-vercel-ai-data-stream': 'v1', + 'x-accel-buffering': 'no', + Connection: 'keep-alive', + }, + body: sse, + }); + }); +}; diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 55a9e419..5e0d207c 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -19,10 +19,12 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", + "@ai-sdk/openai": "3.0.19", "@blocknote/code-block": "0.47.0", "@blocknote/core": "0.47.0", "@blocknote/mantine": "0.47.0", "@blocknote/react": "0.47.0", + "@blocknote/xl-ai": "0.47.0", "@blocknote/xl-docx-exporter": "0.47.0", "@blocknote/xl-multi-column": "0.47.0", "@blocknote/xl-odt-exporter": "0.47.0", @@ -44,6 +46,7 @@ "@sentry/nextjs": "10.38.0", "@tanstack/react-query": "5.90.21", "@tiptap/extensions": "*", + "ai": "6.0.49", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -71,6 +74,7 @@ "uuid": "13.0.0", "y-protocols": "1.0.7", "yjs": "*", + "zod": "3.25.28", "zustand": "5.0.11" }, "devDependencies": { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg new file mode 100644 index 00000000..8436d004 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/IconAI.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/ai-loader.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/ai-loader.svg new file mode 100644 index 00000000..b6ef9dda --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/ai-loader.svg @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg new file mode 100644 index 00000000..743b871a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/wand_stars.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIMenu.tsx new file mode 100644 index 00000000..f1f51ce9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIMenu.tsx @@ -0,0 +1,275 @@ +/** + * We have to override the default BlockNote AI Menu to customize the items shown to the user. + * + * See original implementation: + * https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-ai/src/components/AIMenu/AIMenu.tsx + */ +import { + useBlockNoteEditor, + useComponentsContext, + useExtension, + useExtensionState, +} from '@blocknote/react'; +import { + AIExtension, + AIMenuSuggestionItem, + PromptSuggestionMenu, + getDefaultAIMenuItems, +} from '@blocknote/xl-ai'; +import '@blocknote/xl-ai/style.css'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle } from 'styled-components'; + +import { Box } from '@/components/Box'; +import { Icon } from '@/components/Icon'; + +import IconWandStar from '../../assets/wand_stars.svg'; +import { + DocsBlockNoteEditor, + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; + +import { IconAI } from './IconAI'; + +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; + } + .--docs--ai-menu input[name="ai-prompt"]{ + padding-inline-start: 3rem; + } + .--docs--ai-menu .mantine-TextInput-section[data-position="left"] { + margin-inline: 0.75rem; + } + .--docs--ai-menu .mantine-TextInput-section[data-position="right"] { + inset-inline-end: 2rem; + } +`; + +export type AIMenuProps = { + items?: ( + editor: DocsBlockNoteEditor, + aiResponseStatus: + | 'user-input' + | 'thinking' + | 'ai-writing' + | 'error' + | 'user-reviewing' + | 'closed', + ) => AIMenuSuggestionItem[]; + onManualPromptSubmit?: (userPrompt: string) => void; +}; + +export const AIMenu = (props: AIMenuProps) => { + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + const [prompt, setPrompt] = useState(''); + const { t } = useTranslation(); + + const Components = useComponentsContext(); + + const ai = useExtension(AIExtension); + + const aiResponseStatus = useExtensionState(AIExtension, { + selector: (state) => + state.aiMenuState !== 'closed' ? state.aiMenuState.status : 'closed', + }); + + const { items: externalItems } = props; + // note, technically there might be a bug with this useMemo when quickly changing the selection and opening the menu + // would not call getDefaultAIMenuItems with the correct selection, because the component is reused and the memo not retriggered + // practically this should not happen (you can test it by using a high transition duration in useUIElementPositioning) + const items = useMemo(() => { + let items: AIMenuSuggestionItem[]; + if (externalItems) { + items = externalItems(editor, aiResponseStatus); + } else { + items = getDefaultAIMenuItems(editor, aiResponseStatus); + } + + /** + * Customizations to the default AI Menu items + */ + if (aiResponseStatus === 'user-input') { + if (editor.getSelection()) { + items = items + .filter((item) => ['simplify'].indexOf(item.key) === -1) + .map((item) => { + if (item.key === 'improve_writing') { + return { + ...item, + icon: , + }; + } else if (item.key === 'translate') { + return { + ...item, + icon: ( + + ), + }; + } + + return item; + }); + } else { + items = items.filter( + (item) => ['action_items', 'write_anything'].indexOf(item.key) === -1, + ); + } + } else if (aiResponseStatus === 'user-reviewing') { + items = items.map((item) => { + if (item.key === 'accept') { + return { + ...item, + icon: ( + + ), + }; + } + return item; + }); + } else if (aiResponseStatus === 'error') { + items.unshift({ + key: 'accept', + icon: , + title: t('Accept anyway'), + onItemClick: () => { + ai.acceptChanges(); + ai.closeAIMenu(); + }, + size: 'small', + }); + } + + // map from AI items to React Items required by PromptSuggestionMenu + return items.map((item) => { + return { + ...item, + onItemClick: () => { + item.onItemClick(setPrompt); + }, + }; + }); + }, [externalItems, aiResponseStatus, editor, t, ai]); + + const onManualPromptSubmitDefault = useCallback( + async (userPrompt: string) => { + await ai.invokeAI({ + userPrompt, + useSelection: editor.getSelection() !== undefined, + }); + }, + [ai, editor], + ); + + useEffect(() => { + // this is a bit hacky to run a useeffect to reset the prompt when the AI response is done + if ( + aiResponseStatus === 'ai-writing' || + aiResponseStatus === 'thinking' || + aiResponseStatus === 'user-reviewing' || + aiResponseStatus === 'error' + ) { + setPrompt(''); + } + }, [aiResponseStatus]); + + const placeholder = useMemo(() => { + if (aiResponseStatus === 'thinking') { + return t('Thinking...'); + } else if (aiResponseStatus === 'ai-writing') { + return t('Writing...'); + } else if (aiResponseStatus === 'error') { + return t('An error occurred...'); + } + + return t('Ask anything...'); + }, [aiResponseStatus, t]); + + const IconInput = useMemo(() => { + if (aiResponseStatus === 'thinking') { + return ; + } else if (aiResponseStatus === 'ai-writing') { + return ; + } else if (aiResponseStatus === 'error') { + return ; + } + + return ; + }, [aiResponseStatus]); + + const rightSection = useMemo(() => { + if (aiResponseStatus === 'thinking' || aiResponseStatus === 'ai-writing') { + if (!Components) { + return null; + } + + return ( + + ); + } + + return undefined; + }, [Components, ai, aiResponseStatus, t]); + + useEffect(() => { + const handleEscape = async (event: KeyboardEvent) => { + if (event.key === 'Escape') { + await ai.abort(); + ai.rejectChanges(); + ai.closeAIMenu(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [ai]); + + return ( + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIToolbarButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIToolbarButton.tsx new file mode 100644 index 00000000..4216ac5b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIToolbarButton.tsx @@ -0,0 +1,109 @@ +/** + * We have to override the default BlockNote AI Toolbar Button to customize its appearance. + * + * See original implementation: + * https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-ai/src/components/FormattingToolbar/AIToolbarButton.tsx + */ +import { FormattingToolbarExtension } from '@blocknote/core/extensions'; +import { + useBlockNoteEditor, + useComponentsContext, + useExtension, + useSelectedBlocks, +} from '@blocknote/react'; +import { AIExtension } from '@blocknote/xl-ai'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; + +import { IconAI } from './IconAI'; + +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); + const [isHighlighted, setIsHighlighted] = useState(false); + const selectedBlocks = useSelectedBlocks(editor); + const isContent = useMemo(() => { + return !!selectedBlocks.find((block) => block.content !== undefined); + }, [selectedBlocks]); + + if (!editor.isEditable || !Components || !isContent) { + return null; + } + + const onClick = () => { + const selection = editor.getSelection(); + if (!selection) { + throw new Error('No selection'); + } + + const position = selection.blocks[selection.blocks.length - 1].id; + + ai.openAIMenuAtBlock(position); + formattingToolbar.store.setState(false); + }; + + return ( + button.mantine-Button-root { + padding-inline: 0; + transition: all 0.1s ease-in; + & .mantine-Button-label { + padding-inline: ${spacingsTokens['2xs']}; + } + &:hover, + &:hover { + background-color: ${colorsTokens['gray-050']}; + } + } + `} + onMouseEnter={() => setIsHighlighted(true)} + onMouseLeave={() => setIsHighlighted(false)} + $direction="row" + className="--docs--ai-toolbar-button" + > + + + + {t('Ask AI')} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/IconAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/IconAI.tsx new file mode 100644 index 00000000..d6f0a15f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/IconAI.tsx @@ -0,0 +1,79 @@ +import { RuleSet, css } from 'styled-components'; + +import { Icon } from '@/components'; + +import IconAIBase from '../../assets/IconAI.svg'; +import IconAILoading from '../../assets/ai-loader.svg'; + +interface IconAIProps { + isError?: boolean; + isHighlighted?: boolean; + isLoading?: boolean; + width: string; + $css?: string | RuleSet; +} + +export const IconAI = ({ + isError, + isHighlighted, + isLoading, + width, + $css, +}: IconAIProps) => { + if (isError) { + return ( + } + /> + ); + } + + if (isLoading) { + return ( + } + /> + ); + } + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts new file mode 100644 index 00000000..3d701497 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/index.ts @@ -0,0 +1,3 @@ +export * from './AIMenu'; +export * from './AIToolbarButton'; +export * from './useAI'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx new file mode 100644 index 00000000..a43f4f6a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/useAI.tsx @@ -0,0 +1,31 @@ +import { AIExtension } from '@blocknote/xl-ai'; +import { DefaultChatTransport } from 'ai'; +import { useMemo } from 'react'; + +import { fetchAPI } from '@/api'; +import { useConfig } from '@/core'; +import { Doc } from '@/docs/doc-management'; + +export const useAI = (docId: Doc['id']) => { + const conf = useConfig().data; + + return useMemo(() => { + const extension = AIExtension({ + transport: new DefaultChatTransport({ + 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, + }); + }, + }), + agentCursor: conf?.AI_BOT, + }); + + return extension; + }, [conf?.AI_BOT, docId]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index e0be2f28..8c864130 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 @@ -12,6 +12,9 @@ import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; +import { AIMenuController } from '@blocknote/xl-ai'; +import { en as aiEn } from '@blocknote/xl-ai/locales'; +import '@blocknote/xl-ai/style.css'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,6 +39,7 @@ import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; +import { AIMenu, useAI } from './AI'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { cssComments, useComments } from './comments/'; @@ -99,6 +103,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { } const { uploadFile, errorAttachment } = useUploadFile(doc.id); + const aiExtension = useAI(doc.id); const collabName = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); @@ -168,6 +173,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { ...(multiColumnLocales && { multi_column: multiColumnLocales[lang as keyof typeof multiColumnLocales], + ai: aiEn, }), }, pasteHandler: ({ event, defaultPasteHandler }) => { @@ -190,7 +196,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, @@ -243,6 +257,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { comments={showComments} aria-label={t('Document editor')} > + {aiExtension && } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index e07d15bc..35d60549 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -8,9 +8,12 @@ import { useBlockNoteEditor, useDictionary, } from '@blocknote/react'; +import { getAISlashMenuItems } from '@blocknote/xl-ai'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useConfig } from '@/core'; + import { DocsBlockSchema, DocsInlineContentSchema, @@ -39,6 +42,7 @@ export const BlockNoteSuggestionMenu = () => { const fileBlocksName = dictionaryDate.slash_menu.file.group; const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); + const { data: conf } = useConfig(); const getSlashMenuItems = useMemo(() => { // We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks @@ -50,6 +54,7 @@ export const BlockNoteSuggestionMenu = () => { getMultiColumnSlashMenuItems?.(editor) || [], getPdfReactSlashMenuItems(editor, t, fileBlocksName), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), + conf?.AI_FEATURE_ENABLED ? getAISlashMenuItems(editor) : [], ); const index = combinedMenu.findIndex( @@ -66,7 +71,14 @@ export const BlockNoteSuggestionMenu = () => { return async (query: string) => Promise.resolve(filterSuggestionItems(newSlashMenuItems, query)); - }, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]); + }, [ + editor, + t, + fileBlocksName, + basicBlocksName, + conf?.AI_FEATURE_ENABLED, + getInterlinkingMenuItems, + ]); return ( { const formattingToolbar = useCallback(() => { return ( + {conf?.AI_FEATURE_ENABLED && } + {toolbarItems} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 17de12d2..0f72bfbd 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -52,7 +52,7 @@ export const DocEditorContainer = ({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index a5d1d585..fc5094d3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -35,7 +35,26 @@ export const useSaveDoc = ( _updatedDoc: Y.Doc, transaction: Y.Transaction, ) => { - setIsLocalChange(transaction.local); + /** + * When the AI edit the doc transaction.local is false, + * so we check if the origin constructor to know where + * the transaction comes from. + * "PluginKey" constructor comes from the current user, but transaction.local is more reliable + * "HocuspocusProvider" constructor comes from other users from the collaboration server, + * it seems quite reliable too. + * The AI constructor name seems to not be reliable enough, but by deduction if it's not local + * and not from other users, it has to be from the AI. + * + * TODO: see if we can get the local changes from the AI + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const transactionOrigin = transaction?.origin?.constructor?.name; + const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider'; + + const isAIChange = + !transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR; + + setIsLocalChange(transaction.local || isAIChange); }; yDoc.on('update', onUpdate); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index 6618c074..872cef50 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -1,6 +1,11 @@ import { css } from 'styled-components'; export const cssEditor = css` + .mantine-Menu-itemLabel, + .mantine-Button-label { + font-family: var(--c--components--button--font-family); + } + &, & > .bn-container, & .ProseMirror { @@ -149,6 +154,16 @@ export const cssEditor = css` font-style: italic; } + /** + * AI + */ + ins, + [data-type='modification'] { + background: var(--c--globals--colors--brand-100); + border-bottom: 2px solid var(--c--globals--colors--brand-300); + color: var(--c--globals--colors--brand-700); + } + /** * Divider */ diff --git a/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx b/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx index ea46ac87..5a516ab2 100644 --- a/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx +++ b/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx @@ -41,7 +41,7 @@ export function HomeContent() { aria-label={t('Main content')} $css={css` &:focus { - outline: 3px solid var(--c--theme--colors--primary-600); + outline: 3px solid var(--c--globals--colors--primary-600); outline-offset: -3px; } &:focus:not(:focus-visible) { diff --git a/src/frontend/apps/impress/src/layouts/PageLayout.tsx b/src/frontend/apps/impress/src/layouts/PageLayout.tsx index b4a70a97..d92b28b6 100644 --- a/src/frontend/apps/impress/src/layouts/PageLayout.tsx +++ b/src/frontend/apps/impress/src/layouts/PageLayout.tsx @@ -36,7 +36,7 @@ export function PageLayout({ $css={css` flex-grow: 1; &:focus { - outline: 3px solid var(--c--theme--colors--primary-600); + outline: 3px solid var(--c--globals--colors--primary-600); outline-offset: -3px; } &:focus:not(:focus-visible) { diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 87ba1470..48528494 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -17,6 +17,74 @@ 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/gateway@3.0.55": + version "3.0.55" + resolved "https://registry.yarnpkg.com/@ai-sdk/gateway/-/gateway-3.0.55.tgz#0a74425037ed39756ce59b153235bf3ba13cd43a" + integrity sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw== + dependencies: + "@ai-sdk/provider" "3.0.8" + "@ai-sdk/provider-utils" "4.0.15" + "@vercel/oidc" "3.1.0" + +"@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.15", "@ai-sdk/provider-utils@^4.0.2": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz#d585c7c89cfdf13697a40be5768ecd907a251585" + integrity sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w== + dependencies: + "@ai-sdk/provider" "3.0.8" + "@standard-schema/spec" "^1.1.0" + eventsource-parser "^3.0.6" + +"@ai-sdk/provider-utils@4.0.9": + 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/provider@3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-3.0.8.tgz#fd7fac7533c03534ac1d3fb710a6b96e2aa00263" + integrity sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/react@^3.0.5": + version "3.0.103" + resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-3.0.103.tgz#3e0da588714fea4972e06d68f1f67b8dc47b8608" + integrity sha512-9kpQpOVv4Jf2I7lX/GCaO6u5F03e8QkVNIsneWJTH8P/txwPoaAp6T0rt9VVTE8bH65sEv/tZo9Ohst0GP/flQ== + dependencies: + "@ai-sdk/provider-utils" "4.0.15" + ai "6.0.101" + 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" @@ -1296,6 +1364,36 @@ y-protocols "^1.0.6" yjs "^13.6.27" +"@blocknote/xl-ai@0.47.0": + version "0.47.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-ai/-/xl-ai-0.47.0.tgz#fff198e65b05758ef1776bb24be4acc93917143a" + integrity sha512-czx1DyO5bMy2nBCQxesbMQyEZhFpqA6pRUyhnhUENHKievlUZgymRAH3DyO3Yl3STdZkwAiWQDkhVIQiecRNwA== + dependencies: + "@ai-sdk/provider-utils" "^4.0.2" + "@ai-sdk/react" "^3.0.5" + "@blocknote/core" "0.47.0" + "@blocknote/mantine" "0.47.0" + "@blocknote/react" "0.47.0" + "@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.3" + react-dom "^19.2.3" + 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.47.0": version "0.47.0" resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.47.0.tgz#b78ee9082538410efc5867cf276ec325a632ef23" @@ -1831,6 +1929,13 @@ dependencies: "@floating-ui/utils" "^0.2.10" +"@floating-ui/core@^1.7.4": + version "1.7.4" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.4.tgz#4a006a6e01565c0f87ba222c317b056a2cffd2f4" + integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg== + dependencies: + "@floating-ui/utils" "^0.2.10" + "@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.7.4": version "1.7.4" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77" @@ -1839,6 +1944,21 @@ "@floating-ui/core" "^1.7.3" "@floating-ui/utils" "^0.2.10" +"@floating-ui/dom@^1.7.5": + version "1.7.5" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.5.tgz#60bfc83a4d1275b2a90db76bf42ca2a5f2c231c2" + integrity sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg== + dependencies: + "@floating-ui/core" "^1.7.4" + "@floating-ui/utils" "^0.2.10" + +"@floating-ui/react-dom@^2.1.2": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz#529475cc16ee4976ba3387968117e773d9aa703e" + integrity sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg== + dependencies: + "@floating-ui/dom" "^1.7.5" + "@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" @@ -1846,6 +1966,15 @@ 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" @@ -1855,7 +1984,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== @@ -2011,6 +2140,11 @@ 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.4": version "3.4.4" resolved "https://registry.yarnpkg.com/@hocuspocus/common/-/common-3.4.4.tgz#a888fbd6dff2f0b8947c76b7841bddb89eb4d795" @@ -2825,7 +2959,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== @@ -6557,6 +6691,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" @@ -7607,6 +7746,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.4": version "5.1.4" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz#5b477e060bf612a7394c4febacc5de33a219b0e4" @@ -7878,6 +8022,26 @@ 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.101, ai@^6.0.5: + version "6.0.101" + resolved "https://registry.yarnpkg.com/ai/-/ai-6.0.101.tgz#ddfc38440085efbd89624b52911d4dbef2a3fd6f" + integrity sha512-Ur/NgbgOp1rdhyDiKDk6EOpSgd1g5ADlbcD1cjQJtQsnmhEngz3Rf8nK5JetDh0vnbLy2aEBpaQeL+zvLRWuaA== + dependencies: + "@ai-sdk/gateway" "3.0.55" + "@ai-sdk/provider" "3.0.8" + "@ai-sdk/provider-utils" "4.0.15" + "@opentelemetry/api" "1.9.0" + +ai@6.0.49: + 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" @@ -9999,6 +10163,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" @@ -13717,6 +13886,13 @@ prosemirror-changeset@^2.3.0: dependencies: prosemirror-transform "^1.0.0" +prosemirror-changeset@^2.3.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz#8d8ea0290cb9545c298ec427ac3a8f298c39170f" + integrity sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng== + dependencies: + prosemirror-transform "^1.0.0" + prosemirror-collab@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33" @@ -14252,7 +14428,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.4: +react-dom@*, react-dom@19.2.4, react-dom@^19.2.3: version "19.2.4" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== @@ -14507,7 +14683,7 @@ react-window@^1.8.11: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@*, react@19.2.4: +react@*, react@19.2.4, react@^19.2.3: version "19.2.4" resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== @@ -15717,6 +15893,14 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swr@^2.2.5: + version "2.4.0" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.0.tgz#cd11e368cb13597f61ee3334428aa20b5e81f36e" + integrity sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw== + 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" @@ -15821,6 +16005,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" @@ -17185,6 +17374,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.11: version "5.0.11" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"