Skip to content

Commit

Permalink
save
Browse files Browse the repository at this point in the history
  • Loading branch information
AntoLC committed Sep 20, 2024
1 parent 480c574 commit 4daa42c
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 6 deletions.
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ bootstrap: \

# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
@$(COMPOSE) build app-dev
.PHONY: build

down: ## stop and remove containers, networks, images, and volumes
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ services:
- "1081:1080"

minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query';

import { APIError, errorCauses, fetchAPI } from '@/api';

export type AIActions =
| 'prompt'
| 'rephrase'
| 'summarize'
| 'translate'
| 'correct'
| 'translate_fr'
| 'translate_en'
| 'translate_de';

export type AIParams = {
docId: string;
text: string;
action: AIActions;
};

export type AIResponse = {
answer: string;
};

export const AI = async ({
docId,
...params
}: AIParams): Promise<AIResponse> => {
const response = await fetchAPI(`ai/`, {
method: 'POST',
body: JSON.stringify({
...params,
}),
});

if (!response.ok) {
throw new APIError('Failed to request ai', await errorCauses(response));
}

return response.json() as Promise<AIResponse>;
};

export function useAI() {
return useMutation<AIResponse, APIError, AIParams>({
mutationFn: AI,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
ComponentProps,
} from '@blocknote/react';
import { mergeRefs } from "@mantine/hooks";

import { ReactNode, useMemo, forwardRef, useRef, useCallback, useState, createContext } from 'react';

import {
Menu as MantineMenu,
} from "@mantine/core";

import {Box, Text } from '@/components';
import { Doc } from '@/features/docs/doc-management';

import { AIActions, useAI } from '../api/useAI';

import { useTranslation } from 'react-i18next';

interface AIGroupButtonProps {
doc: Doc;
}

export function AIGroupButton({ doc }: AIGroupButtonProps) {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();

const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);

if (!show || !editor.isEditable || !Components) {
return null;
}

return (
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Trigger>
<Components.FormattingToolbar.Button
className="bn-button"
data-test="ai-actions"
label="AI"
mainTooltip={t('AI Actions')}
>
AI
</Components.FormattingToolbar.Button>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
<AIMenuItem action="prompt" docId={doc.id} icon={<Text $isMaterialIcon $size="s">text_fields</Text>}>
{t('Use as prompt')}
</AIMenuItem>
<AIMenuItem action="rephrase" docId={doc.id} icon={<Text $isMaterialIcon $size="s">refresh</Text>}>
{t('Rephrase')}
</AIMenuItem>
<AIMenuItem action="summarize" docId={doc.id} icon={<Text $isMaterialIcon $size="s">summarize</Text>}>
{t('Summarize')}
</AIMenuItem>
<AIMenuItem action="correct" docId={doc.id} icon={<Text $isMaterialIcon $size="s">check</Text>}>
{t('Correct')}
</AIMenuItem>
<TranslateMenu docId={doc.id} />
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
);
}

interface AIMenuItemProps {
action: AIActions;
docId: Doc['id'];
children: ReactNode;
icon: ReactNode;
}

const AIMenuItem = ({ action, docId, children, icon }: AIMenuItemProps) => {
const editor = useBlockNoteEditor();
const Components = useComponentsContext()!;
const { mutateAsync: requestAI, isPending } = useAI();

const handleAIAction = useCallback(async () => {
const selectedBlocks = editor.getSelection()?.blocks;

if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}

const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
const responseAI = await requestAI({
docId,
text: markdown,
action,
});

if(!responseAI.answer) {
return;
}

const blockMarkdown = await editor.tryParseMarkdownToBlocks(responseAI.answer);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
}, [editor, requestAI, docId, action]);

return (
<Components.Generic.Menu.Item
closeMenuOnClick={false}
icon={icon}
onClick={handleAIAction}
rightSection={isPending && <Box className="loader" />}
>
{children}
</Components.Generic.Menu.Item>
);
};

interface TranslateMenuProps {
docId: Doc['id'];
}

const TranslateMenu = ({ docId }: TranslateMenuProps) => {
const Components = useComponentsContext()!;
const { t } = useTranslation();

return (
<SubMenu position="right" sub={true} icon={<Text $isMaterialIcon $size="s">translate</Text>} close>
<Components.Generic.Menu.Trigger sub={true}>
<Components.Generic.Menu.Item subTrigger={true}>
{t('Translate')}
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-color-picker-dropdown">
<AIMenuItem action="translate_en" docId={docId}>
{t('English')}
</AIMenuItem>
<AIMenuItem action="translate_fr" docId={docId}>
{t('French')}
</AIMenuItem>
<AIMenuItem action="translate_de" docId={docId}>
{t('German')}
</AIMenuItem>
</Components.Generic.Menu.Dropdown>
</SubMenu>
);
};

const SubMenuContext = createContext<
| {
onMenuMouseOver: () => void;
onMenuMouseLeave: () => void;
}
| undefined
>(undefined);


const SubMenu = forwardRef<
HTMLButtonElement,
ComponentProps["Generic"]["Menu"]["Root"]
>((props, ref) => {
const {
children,
onOpenChange,
position,
icon,
sub, // not used
...rest
} = props;

const [opened, setOpened] = useState(false);

const itemRef = useRef<HTMLButtonElement | null>(null);

const menuCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>();

const mouseLeave = useCallback(() => {
if (menuCloseTimer.current) {
clearTimeout(menuCloseTimer.current);
}
menuCloseTimer.current = setTimeout(() => {
setOpened(false);
}, 250);
}, []);

const mouseOver = useCallback(() => {
if (menuCloseTimer.current) {
clearTimeout(menuCloseTimer.current);
}
setOpened(true);
}, []);

return (
<SubMenuContext.Provider
value={{
onMenuMouseOver: mouseOver,
onMenuMouseLeave: mouseLeave,
}}>
<MantineMenu.Item
className="bn-menu-item bn-mt-sub-menu-item"
closeMenuOnClick={false}
ref={mergeRefs(ref, itemRef)}
onMouseOver={mouseOver}
onMouseLeave={mouseLeave}
leftSection={icon}>
<MantineMenu
portalProps={{
target: itemRef.current
? itemRef.current.parentElement!
: undefined,
}}
middlewares={{ flip: true, shift: true, inline: false, size: true }}
trigger={"hover"}
opened={opened}
onClose={() => onOpenChange?.(false)}
onOpen={() => onOpenChange?.(true)}
position={position}>
{children}
</MantineMenu>
</MantineMenu.Item>
</SubMenuContext.Provider>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const BlockNoteContent = ({
editable={doc.abilities.partial_update && !isVersion}
theme="light"
>
<BlockNoteToolbar />
<BlockNoteToolbar doc={doc} />
</BlockNoteView>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ import {
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';

export const BlockNoteToolbar = () => {
import { Doc } from '../../doc-management';

import { AIGroupButton } from './AIButton';

interface BlockNoteToolbarProps {
doc: Doc;
}

export const BlockNoteToolbar = ({ doc }: BlockNoteToolbarProps) => {
return (
<FormattingToolbarController
formattingToolbar={() => (
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />

{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" doc={doc} />

{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />

Expand Down
11 changes: 10 additions & 1 deletion src/frontend/apps/impress/src/i18n/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,16 @@
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
"accessibility-not-audit": "<strong>docs.numerique.gouv.fr</strong> n'est pas en conformité avec le RGAA 4.1. Le site n'a <strong>pas encore été audité.</strong>",
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante.",
"AI Actions": "Actions IA",
"Use as prompt": "Utiliser comme prompt",
"Rephrase": "Reformuler",
"Summarize": "Résumer",
"Correct": "Corriger",
"Translate": "Traduire",
"English": "Anglais",
"French": "Français",
"German": "Allemand"
}
}
}
14 changes: 14 additions & 0 deletions src/frontend/apps/impress/src/pages/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,17 @@ main ::-webkit-scrollbar-thumb:hover,
cursor: pointer;
outline: inherit;
}

.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 71%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

0 comments on commit 4daa42c

Please sign in to comment.