mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-13 10:27:07 +02:00
Compare commits
15 Commits
config/inc
...
feature/ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa8ca0cd8 | ||
|
|
55785e9d14 | ||
|
|
3fe432bf61 | ||
|
|
e370a23e85 | ||
|
|
83045f5d28 | ||
|
|
3883d80ccb | ||
|
|
08a1e3c0e4 | ||
|
|
d118f9249d | ||
|
|
a584e9eeb0 | ||
|
|
ef8dd98b7b | ||
|
|
ae82d137c2 | ||
|
|
77282907cb | ||
|
|
c7d425b651 | ||
|
|
e63e92e731 | ||
|
|
36c0b3dda2 |
@@ -39,3 +39,6 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
|||||||
|
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
AI_API_KEY=password
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
@@ -17,6 +18,9 @@ from django.db.models import (
|
|||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
decorators,
|
decorators,
|
||||||
exceptions,
|
exceptions,
|
||||||
@@ -367,6 +371,71 @@ class DocumentViewSet(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
|
@decorators.action(detail=True, methods=["post"], url_path="ai")
|
||||||
|
def ai(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Process text using AI based on the specified action.
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise exceptions.PermissionDenied("Authentication required.")
|
||||||
|
|
||||||
|
action = request.data.get("action")
|
||||||
|
text = request.data.get("text")
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
base_url=settings.AI_BASE_URL,
|
||||||
|
api_key=settings.AI_API_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
action_configs = {
|
||||||
|
"prompt": {
|
||||||
|
"system_content": 'Answer to the prompt. The output should be in markdown format. Send the data back in this json format: {"answer_prompt": "Your markdown answer"}. Do not give any other information.',
|
||||||
|
"response_key": 'answer_prompt'
|
||||||
|
},
|
||||||
|
"correct": {
|
||||||
|
"system_content": 'You are a text corrector. Only correct the grammar and spelling of the given markdown text. Keep the language and markdown formatting in which the text was originally written. Return only a JSON in the following format: {"corrected_text": "your corrected markdown text"}. Do not provide any other information.',
|
||||||
|
"response_key": 'corrected_text'
|
||||||
|
},
|
||||||
|
"rephrase": {
|
||||||
|
"system_content": 'You are a writer. Rephrase the given markdown text. Keep the language and markdown formatting in which the text was originally written. Return only a JSON in the following format: {"rephrased_text": "your rephrased markdown text"}. Do not provide any other information.',
|
||||||
|
"response_key": 'rephrased_text'
|
||||||
|
},
|
||||||
|
"summarize": {
|
||||||
|
"system_content": 'You are a writer. Summarize the given markdown text. Keep the language in which the text was originally written and use appropriate markdown formatting. Return only a JSON in the following format: {"summary": "your markdown summary"}. Do not provide any other information.',
|
||||||
|
"response_key": 'summary'
|
||||||
|
},
|
||||||
|
"translate_en": {
|
||||||
|
"system_content": 'You are an English translator. Translate the given markdown text to English, preserving the markdown formatting. Return only a JSON in the following format: {"text": "Your translated markdown text in English"}. Do not provide any other information.',
|
||||||
|
"response_key": 'text'
|
||||||
|
},
|
||||||
|
"translate_de": {
|
||||||
|
"system_content": 'You are a German translator. Translate the given markdown text to German, preserving the markdown formatting. Return only a JSON in the following format: {"text": "Your translated markdown text in German"}. Do not provide any other information.',
|
||||||
|
"response_key": 'text'
|
||||||
|
},
|
||||||
|
"translate_fr": {
|
||||||
|
"system_content": 'You are a French translator. Translate the given markdown text to French, preserving the markdown formatting. Return only a JSON in the following format: {"text": "Your translated markdown text in French"}. Do not provide any other information.',
|
||||||
|
"response_key": 'text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if action not in action_configs:
|
||||||
|
return drf_response.Response({"error": "Invalid action"}, status=400)
|
||||||
|
|
||||||
|
config = action_configs[action]
|
||||||
|
try:
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="meta-llama/Meta-Llama-3.1-70B-Instruct",
|
||||||
|
response_format={ "type": "json_object"},
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": config["system_content"]},
|
||||||
|
{"role": "user", "content": json.dumps({"mardown_input": text})},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
corrected_response = json.loads(response.choices[0].message.content)
|
||||||
|
return drf_response.Response(corrected_response[config["response_key"]])
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
return drf_response.Response({"error": f"Error processing AI response: {str(e)}"}, status=500)
|
||||||
|
|
||||||
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||||
def versions_list(self, request, *args, **kwargs):
|
def versions_list(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -387,6 +387,12 @@ class Base(Configuration):
|
|||||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
AI_BASE_URL = values.Value(
|
||||||
|
None, environ_name="AI_BASE_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
AI_API_KEY = values.Value(
|
||||||
|
None, environ_name="AI_API_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ dependencies = [
|
|||||||
"jsonschema==4.23.0",
|
"jsonschema==4.23.0",
|
||||||
"markdown==3.7",
|
"markdown==3.7",
|
||||||
"nested-multipart-parser==1.5.0",
|
"nested-multipart-parser==1.5.0",
|
||||||
|
"openai==1.44.1",
|
||||||
"psycopg[binary]==3.2.1",
|
"psycopg[binary]==3.2.1",
|
||||||
"PyJWT==2.9.0",
|
"PyJWT==2.9.0",
|
||||||
"pypandoc==1.13",
|
"pypandoc==1.13",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 DocAIParams = {
|
||||||
|
docId: string;
|
||||||
|
text: string;
|
||||||
|
action: AIActions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocAIResponse = string;
|
||||||
|
|
||||||
|
export const DocAI = async ({
|
||||||
|
docId,
|
||||||
|
...params
|
||||||
|
}: DocAIParams): Promise<DocAIResponse> => {
|
||||||
|
const response = await fetchAPI(`documents/${docId}/ai/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...params,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new APIError('Failed to get AI', await errorCauses(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<DocAIResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAIRewrite() {
|
||||||
|
return useMutation<DocAIResponse, APIError, DocAIParams>({
|
||||||
|
mutationFn: DocAI,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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 '../../doc-management';
|
||||||
|
import { AIActions, useAIRewrite } from '../api/useAIRewrite';
|
||||||
|
|
||||||
|
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 } = useAIRewrite();
|
||||||
|
|
||||||
|
const handleAIAction = useCallback(async () => {
|
||||||
|
const selectedBlocks = editor.getSelection()?.blocks;
|
||||||
|
|
||||||
|
if (!selectedBlocks || selectedBlocks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
|
||||||
|
const newText = await requestAI({
|
||||||
|
docId,
|
||||||
|
text: markdown,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockMarkdown = await editor.tryParseMarkdownToBlocks(newText);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -130,7 +130,7 @@ export const BlockNoteContent = ({
|
|||||||
editable={doc.abilities.partial_update && !isVersion}
|
editable={doc.abilities.partial_update && !isVersion}
|
||||||
theme="light"
|
theme="light"
|
||||||
>
|
>
|
||||||
<BlockNoteToolbar />
|
<BlockNoteToolbar doc={doc} />
|
||||||
</BlockNoteView>
|
</BlockNoteView>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,13 +16,25 @@ import {
|
|||||||
import { forEach, isArray } from 'lodash';
|
import { forEach, isArray } from 'lodash';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
export const BlockNoteToolbar = () => {
|
import { Doc } from '../../doc-management';
|
||||||
|
|
||||||
|
// import { AIButton } from './AIButton';
|
||||||
|
import { AIGroupButton } from './AIButton';
|
||||||
|
|
||||||
|
interface BlockNoteToolbarProps {
|
||||||
|
doc: Doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockNoteToolbar = ({ doc }: BlockNoteToolbarProps) => {
|
||||||
return (
|
return (
|
||||||
<FormattingToolbarController
|
<FormattingToolbarController
|
||||||
formattingToolbar={() => (
|
formattingToolbar={() => (
|
||||||
<FormattingToolbar>
|
<FormattingToolbar>
|
||||||
<BlockTypeSelect key="blockTypeSelect" />
|
<BlockTypeSelect key="blockTypeSelect" />
|
||||||
|
|
||||||
|
{/* Extra button to convert from markdown to json */}
|
||||||
|
<AIGroupButton key="AIButton" doc={doc} />
|
||||||
|
|
||||||
{/* Extra button to convert from markdown to json */}
|
{/* Extra button to convert from markdown to json */}
|
||||||
<MarkdownButton key="customButton" />
|
<MarkdownButton key="customButton" />
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,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-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-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>",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,17 @@ main ::-webkit-scrollbar-thumb:hover,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: inherit;
|
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); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user