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