From 74d6e67c3bcbcb980818a7445b6fd3eee9d52b73 Mon Sep 17 00:00:00 2001 From: ReFFaT <102167552+ReFFaT@users.noreply.github.com> Date: Fri, 13 Sep 2024 13:18:49 +0300 Subject: [PATCH] feat(yfm): add GPT extensions (#361) --- README.md | 2 + demo/Playground.tsx | 33 +- demo/gptPlugin/PlaygroundGPT.stories.tsx | 16 + demo/gptPlugin/PlaygroundGPT.tsx | 55 +++ demo/gptPlugin/gptWidgetOptions.tsx | 75 +++++ demo/gptPlugin/md-content.ts | 1 + docs/how-to-connect-gpt-extensions.md | 197 +++++++++++ package-lock.json | 10 + package.json | 1 + src/bundle/config/action-names.ts | 1 + src/bundle/config/icons.ts | 6 +- src/bundle/config/wysiwyg.ts | 12 + .../yfm/GPT/ErrorScreen/ErrorScreen.scss | 25 ++ .../yfm/GPT/ErrorScreen/ErrorScreen.tsx | 42 +++ src/extensions/yfm/GPT/ErrorScreen/types.ts | 16 + .../yfm/GPT/GptDialog/GptDialog.scss | 100 ++++++ .../yfm/GPT/GptDialog/GptDialog.tsx | 306 +++++++++++++++++ .../LoadingScreen/LoadingScreen.scss | 30 ++ .../GptDialog/LoadingScreen/LoadingScreen.tsx | 33 ++ .../GPT/IconRefuge/IconRefuge.classname.tsx | 3 + .../yfm/GPT/IconRefuge/IconRefuge.scss | 9 + .../yfm/GPT/IconRefuge/IconRefuge.tsx | 53 +++ .../yfm/GPT/IconRefuge/IconRefuge.types.d.ts | 13 + src/extensions/yfm/GPT/IconRefuge/index.ts | 2 + .../yfm/GPT/PresetList/PresetList.tsx | 94 ++++++ .../yfm/GPT/PresetList/Presetlist.scss | 9 + src/extensions/yfm/GPT/actions.ts | 13 + src/extensions/yfm/GPT/commands.ts | 14 + src/extensions/yfm/GPT/constants.ts | 15 + .../yfm/GPT/gptExtension/gptExtension.ts | 57 ++++ src/extensions/yfm/GPT/gptExtension/view.scss | 23 ++ src/extensions/yfm/GPT/gptExtension/view.tsx | 318 ++++++++++++++++++ src/extensions/yfm/GPT/hooks/useGpt.tsx | 217 ++++++++++++ src/extensions/yfm/GPT/hooks/useGptHotKeys.ts | 11 + .../hooks/useOverflowingHorizontalItems.tsx | 104 ++++++ src/extensions/yfm/GPT/hooks/usePresetList.ts | 49 +++ src/extensions/yfm/GPT/index.ts | 2 + src/extensions/yfm/GPT/plugin.ts | 70 ++++ src/extensions/yfm/GPT/toolbar.ts | 22 ++ src/extensions/yfm/GPT/utils.ts | 41 +++ src/extensions/yfm/index.ts | 1 + src/i18n/gpt/dialog/en.json | 16 + src/i18n/gpt/dialog/index.ts | 8 + src/i18n/gpt/dialog/ru.json | 16 + src/i18n/gpt/errors/en.json | 5 + src/i18n/gpt/errors/index.ts | 8 + src/i18n/gpt/errors/ru.json | 5 + src/i18n/gpt/extension/en.json | 6 + src/i18n/gpt/extension/index.ts | 8 + src/i18n/gpt/extension/ru.json | 6 + src/i18n/gpt/loading/en.json | 3 + src/i18n/gpt/loading/index.ts | 8 + src/i18n/gpt/loading/ru.json | 3 + src/i18n/menubar/en.json | 1 + src/i18n/menubar/ru.json | 1 + src/icons/GPT.tsx | 56 +++ src/icons/GPTLoading.tsx | 28 ++ src/icons/index.ts | 4 +- src/react-utils/useAutoFocus.ts | 4 +- 59 files changed, 2278 insertions(+), 9 deletions(-) create mode 100644 demo/gptPlugin/PlaygroundGPT.stories.tsx create mode 100644 demo/gptPlugin/PlaygroundGPT.tsx create mode 100644 demo/gptPlugin/gptWidgetOptions.tsx create mode 100644 demo/gptPlugin/md-content.ts create mode 100644 docs/how-to-connect-gpt-extensions.md create mode 100644 src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.scss create mode 100644 src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.tsx create mode 100644 src/extensions/yfm/GPT/ErrorScreen/types.ts create mode 100644 src/extensions/yfm/GPT/GptDialog/GptDialog.scss create mode 100644 src/extensions/yfm/GPT/GptDialog/GptDialog.tsx create mode 100644 src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.scss create mode 100644 src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.tsx create mode 100644 src/extensions/yfm/GPT/IconRefuge/IconRefuge.classname.tsx create mode 100644 src/extensions/yfm/GPT/IconRefuge/IconRefuge.scss create mode 100644 src/extensions/yfm/GPT/IconRefuge/IconRefuge.tsx create mode 100644 src/extensions/yfm/GPT/IconRefuge/IconRefuge.types.d.ts create mode 100644 src/extensions/yfm/GPT/IconRefuge/index.ts create mode 100644 src/extensions/yfm/GPT/PresetList/PresetList.tsx create mode 100644 src/extensions/yfm/GPT/PresetList/Presetlist.scss create mode 100644 src/extensions/yfm/GPT/actions.ts create mode 100644 src/extensions/yfm/GPT/commands.ts create mode 100644 src/extensions/yfm/GPT/constants.ts create mode 100644 src/extensions/yfm/GPT/gptExtension/gptExtension.ts create mode 100644 src/extensions/yfm/GPT/gptExtension/view.scss create mode 100644 src/extensions/yfm/GPT/gptExtension/view.tsx create mode 100644 src/extensions/yfm/GPT/hooks/useGpt.tsx create mode 100644 src/extensions/yfm/GPT/hooks/useGptHotKeys.ts create mode 100644 src/extensions/yfm/GPT/hooks/useOverflowingHorizontalItems.tsx create mode 100644 src/extensions/yfm/GPT/hooks/usePresetList.ts create mode 100644 src/extensions/yfm/GPT/index.ts create mode 100644 src/extensions/yfm/GPT/plugin.ts create mode 100644 src/extensions/yfm/GPT/toolbar.ts create mode 100644 src/extensions/yfm/GPT/utils.ts create mode 100644 src/i18n/gpt/dialog/en.json create mode 100644 src/i18n/gpt/dialog/index.ts create mode 100644 src/i18n/gpt/dialog/ru.json create mode 100644 src/i18n/gpt/errors/en.json create mode 100644 src/i18n/gpt/errors/index.ts create mode 100644 src/i18n/gpt/errors/ru.json create mode 100644 src/i18n/gpt/extension/en.json create mode 100644 src/i18n/gpt/extension/index.ts create mode 100644 src/i18n/gpt/extension/ru.json create mode 100644 src/i18n/gpt/loading/en.json create mode 100644 src/i18n/gpt/loading/index.ts create mode 100644 src/i18n/gpt/loading/ru.json create mode 100644 src/icons/GPT.tsx create mode 100644 src/icons/GPTLoading.tsx diff --git a/README.md b/README.md index 204b2f98..7ba8a357 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Read more: - [How to add Latex extension](docs/how-to-connect-latex-extension.md) - [How to add Mermaid extension](docs/how-to-connect-mermaid-extension.md) - [How to write extension](docs/how-to-create-extension.md) +- [How to add gpt extension](docs/how-to-connect-gpt-extensions.md) + ### i18n diff --git a/demo/Playground.tsx b/demo/Playground.tsx index 4f277ae7..3cc2464b 100644 --- a/demo/Playground.tsx +++ b/demo/Playground.tsx @@ -7,9 +7,11 @@ import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; import { MarkdownEditorMode, MarkdownEditorView, + MarkdownEditorViewProps, MarkupString, NumberInput, RenderPreview, + UseMarkdownEditorProps, logger, markupToolbarConfigs, useMarkdownEditor, @@ -59,6 +61,8 @@ const wCommandMenuConfig = wysiwygToolbarConfigs.wCommandMenuConfig.concat( wysiwygToolbarConfigs.wYfmHtmlBlockItemData, ); +wCommandMenuConfig.unshift(wysiwygToolbarConfigs.wGptItemData); + export type PlaygroundProps = { initial?: MarkupString; allowHTML?: boolean; @@ -77,7 +81,20 @@ export type PlaygroundProps = { escapeConfig?: EscapeConfig; onChangeEditorType?: (mode: MarkdownEditorMode) => void; onChangeSplitModeEnabled?: (splitModeEnabled: boolean) => void; -}; +} & Pick< + UseMarkdownEditorProps, + | 'needToSetDimensionsForUploadedImages' + | 'extraExtensions' + | 'renderPreview' + | 'extensionOptions' +> & + Pick< + MarkdownEditorViewProps, + | 'markupHiddenActionsConfig' + | 'wysiwygHiddenActionsConfig' + | 'markupToolbarConfig' + | 'wysiwygToolbarConfig' + >; logger.setLogger({ metrics: console.info, @@ -101,6 +118,9 @@ export const Playground = React.memo((props) => { stickyToolbar, renderPreviewDefined, height, + extraExtensions, + extensionOptions, + wysiwygToolbarConfig, escapeConfig, } = props; const [editorMode, setEditorMode] = React.useState( @@ -145,8 +165,9 @@ export const Playground = React.memo((props) => { : undefined, extensionOptions: { commandMenu: {actions: wCommandMenuConfig}, + ...extensionOptions, }, - extraExtensions: (builder) => + extraExtensions: (builder) => { builder .use(Math, { loadRuntimeScript: () => { @@ -166,6 +187,7 @@ export const Playground = React.memo((props) => { ); }, }) + .use(FoldingHeading) .use(YfmHtmlBlock, { useConfig: useYfmHtmlBlockStyles, sanitize: getSanitizeYfmHtmlBlock({options: defaultOptions}), @@ -178,8 +200,9 @@ export const Playground = React.memo((props) => { } { @@ -311,7 +334,7 @@ export const Playground = React.memo((props) => { toaster={toaster} className={b('editor-view')} stickyToolbar={Boolean(stickyToolbar)} - wysiwygToolbarConfig={wToolbarConfig} + wysiwygToolbarConfig={wysiwygToolbarConfig ?? wToolbarConfig} markupToolbarConfig={mToolbarConfig} settingsVisible={settingsVisible} editor={mdEditor} diff --git a/demo/gptPlugin/PlaygroundGPT.stories.tsx b/demo/gptPlugin/PlaygroundGPT.stories.tsx new file mode 100644 index 00000000..09ed3f1f --- /dev/null +++ b/demo/gptPlugin/PlaygroundGPT.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import type {StoryFn} from '@storybook/react'; + +import {PlaygroundGPT} from './PlaygroundGPT'; + +export default { + title: 'Markdown Editor / YFM examples', + component: PlaygroundGPT, +}; + +type PlaygroundStoryProps = {}; +export const Playground: StoryFn = (props) => ; + +Playground.storyName = 'GPT'; diff --git a/demo/gptPlugin/PlaygroundGPT.tsx b/demo/gptPlugin/PlaygroundGPT.tsx new file mode 100644 index 00000000..7edadbe4 --- /dev/null +++ b/demo/gptPlugin/PlaygroundGPT.tsx @@ -0,0 +1,55 @@ +import React, {useState} from 'react'; + +import cloneDeep from 'lodash/cloneDeep'; + +import { + type MarkupString, + gptExtension, + logger, + wGptToolbarItem, + wysiwygToolbarConfigs, +} from '../../src'; +import {Playground} from '../Playground'; + +import {gptWidgetProps} from './gptWidgetOptions'; +import {initialMdContent} from './md-content'; + +import '../Playground.scss'; + +const wToolbarConfig = cloneDeep(wysiwygToolbarConfigs.wToolbarConfig); +wToolbarConfig.unshift([wGptToolbarItem]); + +logger.setLogger({ + metrics: console.info, + action: (data) => console.info(`Action: ${data.action}`, data), + ...console, +}); + +export const PlaygroundGPT = React.memo(() => { + const [yfmRaw, setYfmRaw] = React.useState(initialMdContent); + + const [showedAlertGpt, setShowedAlertGpt] = useState(true); + + const wSelectionMenuConfig = [[wGptToolbarItem], ...wysiwygToolbarConfigs.wSelectionMenuConfig]; + return ( + + builder.use( + gptExtension, + gptWidgetProps(setYfmRaw, { + showedGptAlert: Boolean(showedAlertGpt), + onCloseGptAlert: () => { + setShowedAlertGpt(false); + }, + }), + ) + } + extensionOptions={{selectionContext: {config: wSelectionMenuConfig}}} + wysiwygToolbarConfig={wToolbarConfig} + /> + ); +}); + +PlaygroundGPT.displayName = 'GPT'; diff --git a/demo/gptPlugin/gptWidgetOptions.tsx b/demo/gptPlugin/gptWidgetOptions.tsx new file mode 100644 index 00000000..004d869d --- /dev/null +++ b/demo/gptPlugin/gptWidgetOptions.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import type {GptWidgetOptions} from '../../src/extensions/yfm/GPT/gptExtension/gptExtension'; + +const gptRequestHandler = async ({ + markup, + customPrompt, + promptData, +}: { + markup: string; + customPrompt?: string; + promptData: unknown; +}) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + let gptResponseMarkup = markup; + if (customPrompt) { + gptResponseMarkup = markup + ` \`enhanced with ${customPrompt}\``; + } else if (promptData === 'do-uno-reverse') { + gptResponseMarkup = gptResponseMarkup.replace(/[\wа-яА-ЯёЁ]+/g, (match) => + match.split('').reverse().join(''), + ); + } else if (promptData === 'do-shout-out') { + gptResponseMarkup = gptResponseMarkup.toLocaleUpperCase(); + } + + return { + rawText: gptResponseMarkup, + }; +}; + +export const gptWidgetProps = ( + setYfmRaw: (yfmRaw: string) => void, + gptAlertProps?: GptWidgetOptions['gptAlertProps'], +): GptWidgetOptions => { + return { + answerRender: (data) =>
{data.rawText}
, + customPromptPlaceholder: 'Ask GPT to edit the text highlighted text', + disabledPromptPlaceholder: 'Ask GPT to generate the text', + gptAlertProps: gptAlertProps, + promptPresets: [ + { + hotKey: 'control+3', + data: 'do-uno-reverse', + display: 'Use the uno card', + key: 'do-uno-reverse', + }, + { + hotKey: 'control+4', + data: 'do-shout-out', + display: 'Make the text flashy', + key: 'do-shout-out', + }, + ], + onCustomPromptApply: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onPromptPresetClick: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onTryAgain: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onLike: async () => {}, + onDislike: async () => {}, + onApplyResult: (markup) => { + setYfmRaw(markup); + }, + onUpdate: (event) => { + if (event?.rawText) { + setYfmRaw(event.rawText); + } + }, + }; +}; diff --git a/demo/gptPlugin/md-content.ts b/demo/gptPlugin/md-content.ts new file mode 100644 index 00000000..97af389a --- /dev/null +++ b/demo/gptPlugin/md-content.ts @@ -0,0 +1 @@ +export const initialMdContent = `Markdown Editor GPT Playground`; diff --git a/docs/how-to-connect-gpt-extensions.md b/docs/how-to-connect-gpt-extensions.md new file mode 100644 index 00000000..14b8f37d --- /dev/null +++ b/docs/how-to-connect-gpt-extensions.md @@ -0,0 +1,197 @@ +## How to connect GPT extensions to editor + +First to integrate this extension, you need to use the following versions of the packages: + + @gravity-ui/markdown-editor version 13.18.0 or higher + +### 1. Add extension usage to extension builder + +```ts + import {gptExtension} from '@gravity-ui/markdown-editor'; + + builder.use( + gptExtension, + // The next step add gptWidgetProps, + ) +``` +### 2. Add gpt extensions props + +```ts + import {gptExtension, type GptWidgetOptions} from '@gravity-ui/markdown-editor'; + + const gptWidgetProps: GptWidgetOptions = { + // add params + } + + builder.use( + gptExtension, + gptWidgetProps, // use params + ) +``` + +#### Example of implementation + + setYfmRaw - a setter to change the text in an editor + + gptRequestHandler - your function to implement GPT response + +```ts + import {gptExtension, type GptWidgetOptions} from '@gravity-ui/markdown-editor'; + + const gptRequestHandler = async ({ + markup, + customPrompt, + promptData, + }: { + markup: string; + customPrompt?: string; + promptData: unknown; + }) => { + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + let gptResponseMarkup = markup; + + if (customPrompt) { + gptResponseMarkup = markup + ` \`enhanced with ${customPrompt}\``; + } else if (promptData === 'do-uno-reverse') { + gptResponseMarkup = gptResponseMarkup.replace(/[\wа-яА-ЯёЁ]+/g, (match) => + match.split('').reverse().join(''), + ); + } else if (promptData === 'do-shout-out') { + gptResponseMarkup = gptResponseMarkup.toLocaleUpperCase(); + } + + return { + rawText: gptResponseMarkup, + }; + }; + + const gptWidgetProps: GptWidgetOptions = { + answerRender: (data) =>
{data.rawText}
, + customPromptPlaceholder: 'Ask Yandex GPT to edit the text highlighted text', + disabledPromptPlaceholder: 'Ask Yandex GPT to generate the text', + gptAlertProps: { + showedGptAlert: true; + onCloseGptAlert: () => {}; + }, + promptPresets: [ + { + hotKey: 'control+3', + data: 'do-uno-reverse', + display: 'Use the uno card', + key: 'do-uno-reverse', + }, + { + hotKey: 'control+4', + data: 'do-shout-out', + display: 'Make the text flashy', + key: 'do-shout-out', + }, + ], + onCustomPromptApply: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onPromptPresetClick: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onTryAgain: async ({markup, customPrompt, promptData}) => { + return gptRequestHandler({markup, customPrompt, promptData}); + }, + onApplyResult: (markup) => { + setYfmRaw(markup); + }, + onUpdate: (event) => { + if (event?.rawText) { + setYfmRaw(event.rawText); + } + }, + } + + builder.use( + gptExtension, + gptWidgetProps, + ) +``` + +### 3. Add extension to menubar and toolbar and command menu config for editor + +#### Add in tool bar + +```ts + import { + wGptToolbarItem, + wysiwygToolbarConfig, + MarkdownEditorView, + useMarkdownEditor + } from '@gravity-ui/markdown-editor'; + + wysiwygToolbarConfig.unshift([wGptToolbarItem]); + + const mdEditor = useMarkdownEditor({ + // editor options + }) + + +``` +#### Add in menu bar +```ts + import { + wGptToolbarItem, + wysiwygToolbarConfig, + wysiwygToolbarConfigs, + MarkdownEditorView, + useMarkdownEditor + } from '@gravity-ui/markdown-editor'; + + const wSelectionMenuConfig = [[wGptToolbarItem], ...wysiwygToolbarConfigs.wSelectionMenuConfig]; + + const mdEditor = useMarkdownEditor({ + ... + extensionOptions: { + selectionContext: {config: wSelectionMenuConfig}, + }, + ... + }) + + +``` + +#### Add in command menu config (/) +```ts + import { + wysiwygToolbarConfigs, + MarkdownEditorView, + useMarkdownEditor + } from '@gravity-ui/markdown-editor'; + + const wCommandMenuConfig = wysiwygToolbarConfigs.wCommandMenuConfig // main commands + + wCommandMenuConfig.unshift(wysiwygToolbarConfigs.wGptItemData); // add gpt command + + const mdEditor = useMarkdownEditor({ + ... + extensionOptions: { + commandMenu: {actions: wCommandMenuConfig}, + }, + ... + }) + + + +``` + +### 4. Done, You can use the extension! diff --git a/package-lock.json b/package-lock.json index 95596b05..0576fe72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "prosemirror-utils": "1.2.0", "prosemirror-view": "1.33.6", "react-error-boundary": "^3.1.4", + "react-hotkeys-hook": "4.5.0", "react-use": "^17.3.2", "tslib": "^2.3.1" }, @@ -24258,6 +24259,15 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "dev": true }, + "node_modules/react-hotkeys-hook": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.5.0.tgz", + "integrity": "sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-inspector": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", diff --git a/package.json b/package.json index ed96bbca..3210a510 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "prosemirror-utils": "1.2.0", "prosemirror-view": "1.33.6", "react-error-boundary": "^3.1.4", + "react-hotkeys-hook": "4.5.0", "react-use": "^17.3.2", "tslib": "^2.3.1" }, diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index 24a09b8a..29e127ae 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -38,6 +38,7 @@ const names = [ 'math_block', 'tabs', 'mermaid', + 'gpt', ] as const; type ItemsType = L extends readonly (infer T)[] ? T : never; diff --git a/src/bundle/config/icons.ts b/src/bundle/config/icons.ts index ec263759..72d51c0f 100644 --- a/src/bundle/config/icons.ts +++ b/src/bundle/config/icons.ts @@ -39,6 +39,7 @@ import { UnderlineIcon, UndoIcon, } from '../../icons'; +import GPTIcon from '../../icons/GPT'; import {ToolbarIconData} from '../../toolbar/types'; type Icon = @@ -80,7 +81,8 @@ type Icon = | 'tabs' | 'mermaid' | 'html' - | 'foldingHeading'; + | 'foldingHeading' + | 'gpt'; type Icons = Record; @@ -139,4 +141,6 @@ export const icons: Icons = { mermaid: {data: MermaidIcon}, foldingHeading: {data: FoldingHeadingIcon}, + + gpt: {data: GPTIcon}, }; diff --git a/src/bundle/config/wysiwyg.ts b/src/bundle/config/wysiwyg.ts index 2b810906..63a9eabf 100644 --- a/src/bundle/config/wysiwyg.ts +++ b/src/bundle/config/wysiwyg.ts @@ -6,6 +6,7 @@ import type { SelectionContextItemData, } from '../../extensions/behavior/SelectionContext'; // for typings from Math +import {gptHotKeys} from '../../extensions/yfm/GPT/constants'; import type {} from '../../extensions/yfm/Math'; import {i18n as i18nHint} from '../../i18n/hints'; import {i18n} from '../../i18n/menubar'; @@ -466,6 +467,17 @@ export const wYfmHtmlBlockItemData: WToolbarSingleItemData = { isEnable: (e) => e.actions.createYfmHtmlBlock.isEnable(), }; +export const wGptItemData: WToolbarSingleItemData = { + id: ActionName.gpt, + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'gpt'), + hotkey: gptHotKeys.openGptKeyTooltip, + icon: icons.gpt, + exec: (e) => e.actions.addGptWidget.run({}), + isActive: (e) => e.actions.addGptWidget.isActive(), + isEnable: (e) => e.actions.addGptWidget.isEnable(), +}; + export const wCommandMenuConfig: WToolbarItemData[] = [ ...wHeadingListConfig.data, ...wListsListConfig.data, diff --git a/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.scss b/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.scss new file mode 100644 index 00000000..e2f706be --- /dev/null +++ b/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.scss @@ -0,0 +1,25 @@ +.g-md-gpt-dialog-error-screen { + &__content { + display: flex; + align-items: center; + + margin-bottom: var(--g-spacing-2); + } + + &__icon { + color: var(--g-color-base-danger-heavy); + } + + &__text { + flex: auto; + + font-size: var(--g-text-body-2-font-size); + font-weight: 500; + } + + &__buttons { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + } +} diff --git a/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.tsx b/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.tsx new file mode 100644 index 00000000..558ede0c --- /dev/null +++ b/src/extensions/yfm/GPT/ErrorScreen/ErrorScreen.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type {FC} from 'react'; + +import {CircleXmarkFill} from '@gravity-ui/icons'; +import {Button} from '@gravity-ui/uikit'; + +import {cn} from '../../../../classname'; +import {i18n} from '../../../../i18n/gpt/errors'; +import {IconRefuge} from '../IconRefuge/IconRefuge'; + +import './ErrorScreen.scss'; + +type ErrorScreenProps = { + onRetry: () => void; + onStartAgain: () => void; +}; + +export const cnGptDialogErrorScreen = cn('gpt-dialog-error-screen'); + +export const ErrorScreen: FC = ({onRetry, onStartAgain}) => { + return ( +
+
+ + {i18n('error-text')} +
+
+ + +
+
+ ); +}; diff --git a/src/extensions/yfm/GPT/ErrorScreen/types.ts b/src/extensions/yfm/GPT/ErrorScreen/types.ts new file mode 100644 index 00000000..da9f2d40 --- /dev/null +++ b/src/extensions/yfm/GPT/ErrorScreen/types.ts @@ -0,0 +1,16 @@ +export type PromptPreset = { + display: string; + key: string; + data?: PromptData; + hotKey?: string; +}; + +export type CommonAnswer = { + rawText: string; +}; + +export type GptRequestData = { + markup: string; + customPrompt?: string; + promptData?: PromptData; +}; diff --git a/src/extensions/yfm/GPT/GptDialog/GptDialog.scss b/src/extensions/yfm/GPT/GptDialog/GptDialog.scss new file mode 100644 index 00000000..028d7365 --- /dev/null +++ b/src/extensions/yfm/GPT/GptDialog/GptDialog.scss @@ -0,0 +1,100 @@ +.g-md-gpt-dialog { + &__header-top { + display: flex; + align-items: center; + } + + &__custom-prompt-input { + margin-right: var(--g-spacing-2); + + .g-text-input__control { + font-size: var(--g-text-body-2-font-size); + } + } + + &__header-bottom { + margin-top: var(--g-spacing-2); + } + + &__custom-prompt { + display: flex; + flex: auto; + align-items: center; + } + + &__alone-presets-text { + margin-right: var(--g-spacing-8); + } + + &__alone-presets { + display: flex; + flex: auto; + align-items: center; + + width: 100%; + + font-size: var(--g-text-body-2-font-size); + font-weight: 500; + + .g-md-gpt-dialog-preset-list { + flex: auto; + } + } + + &__answer-title { + font-size: var(--g-text-body-2-font-size); + font-weight: 500; + } + + &__gpt-icon { + margin-right: var(--g-spacing-1); + } + + &__try-again-button { + margin-right: var(--g-spacing-2); + } + + &__body { + margin: var(--g-spacing-2) calc(-1 * var(--g-spacing-3)); + padding: var(--g-spacing-4) var(--g-spacing-3); + + border-top: 1px solid var(--g-color-line-generic); + } + + &__answer { + overflow: auto; + + max-height: 300px; + } + + &__answer-actions { + display: flex; + align-items: center; + } + + &__feedback-message { + margin-left: var(--g-spacing-2); + } + + &__like-button { + margin-right: var(--g-spacing-2); + } + + &__close-button { + margin-right: var(--g-spacing-2); + margin-left: auto; + } + + &__footer { + display: flex; + align-items: center; + + margin-top: var(--g-spacing-2); + } + + &__description-alert { + min-height: 34px; + margin-top: var(--g-spacing-3); + padding: var(--g-spacing-1) var(--g-spacing-2); + } +} diff --git a/src/extensions/yfm/GPT/GptDialog/GptDialog.tsx b/src/extensions/yfm/GPT/GptDialog/GptDialog.tsx new file mode 100644 index 00000000..b305889b --- /dev/null +++ b/src/extensions/yfm/GPT/GptDialog/GptDialog.tsx @@ -0,0 +1,306 @@ +import type {FC} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; + +import {ArrowRight, ArrowRotateLeft, ThumbsDown, ThumbsUp} from '@gravity-ui/icons'; +import {ActionTooltip, Alert, AlertProps, Button, Icon, TextInput} from '@gravity-ui/uikit'; + +import {cn} from '../../../../classname'; +import {i18n} from '../../../../i18n/gpt/dialog'; +import gptIcon from '../../../../icons/GPT'; +import {useAutoFocus} from '../../../../react-utils/useAutoFocus'; +import {ErrorScreen} from '../ErrorScreen/ErrorScreen'; +import type {CommonAnswer, GptRequestData, PromptPreset} from '../ErrorScreen/types'; +import {IconRefuge} from '../IconRefuge/IconRefuge'; +import {PresetList} from '../PresetList/PresetList'; +import {gptHotKeys} from '../constants'; +import {useGpt} from '../hooks/useGpt'; +import {useGptHotKeys} from '../hooks/useGptHotKeys'; +import {getAlertGptInfo, getDisableReplaceButtonText, getInputPlaceHolder} from '../utils'; + +import {LoadingScreen} from './LoadingScreen/LoadingScreen'; + +import './GptDialog.scss'; + +export type GptDialogProps< + AnswerData extends CommonAnswer = CommonAnswer, + PromptData extends unknown = unknown, +> = { + markup: string; + answerRender: (data: AnswerData) => JSX.Element; + onApplyResult: (markup: string) => void; + promptPresets?: PromptPreset[]; + disablePromptPresets?: boolean; + customPromptPlaceholder?: string; + disabledPromptPlaceholder?: string; + onCustomPromptApply?: (data: GptRequestData) => Promise; + onPromptPresetClick?: (data: GptRequestData) => Promise; + onTryAgain?: (data: GptRequestData) => Promise; + onLike?: (data: GptRequestData) => Promise; + onDislike?: (data: GptRequestData) => Promise; + onClose?: () => void; + onUpdate?: (value: AnswerData | undefined) => void; + gptAlertProps?: { + showedGptAlert: boolean; + onCloseGptAlert?: () => void; + message?: string; + theme?: AlertProps['theme']; + className?: string; + }; +}; + +export const cnGptDialog = cn('gpt-dialog'); + +export const GptDialog: FC = ({ + markup, + answerRender, + promptPresets, + disablePromptPresets, + customPromptPlaceholder, + disabledPromptPlaceholder, + onCustomPromptApply, + onPromptPresetClick, + onTryAgain, + onApplyResult, + onClose, + onLike, + onDislike, + onUpdate, + gptAlertProps, +}) => { + const { + answer, + customPrompt, + loading, + mode, + feedbackType, + feedbackTypeLoading, + handleLike, + handleDislike, + handleCustomPromptUpdate, + handleCustomPromptKeyPress, + handleCustomPromptApply, + handlePresetClick, + handleTryAgain, + handleFreshStart, + handleApplyResult, + showAnswer, + showError, + showAnswerActions, + showTryAgainButton, + } = useGpt({ + markup, + promptPresets, + onLike, + onDislike, + onCustomPromptApply, + onPromptPresetClick, + onTryAgain, + onApplyResult, + onUpdate, + }); + + const gptAlert = gptAlertProps; + + const customPromptContainerRef = useRef(null); + + const [showedGptAlert, setShowedGptAlert] = useState(gptAlert?.showedGptAlert); + + const {alertMessage, alertTheme, alertClassName} = getAlertGptInfo(gptAlert); + + const onCloseAlert = useCallback(() => { + if (gptAlert) { + gptAlert.showedGptAlert = false; + } + gptAlert?.onCloseGptAlert?.(); + + setShowedGptAlert(false); + }, [gptAlert]); + + useAutoFocus(customPromptContainerRef, [showAnswer]); + + useGptHotKeys(gptHotKeys.tryAgainGpt, handleTryAgain); + useGptHotKeys(gptHotKeys.freshStartGpt, handleFreshStart); + useGptHotKeys(gptHotKeys.applyResultGpt, handleApplyResult); + + const replaceButtonText = getDisableReplaceButtonText(disablePromptPresets); + + const inputPlaceholderText = getInputPlaceHolder( + disablePromptPresets, + disabledPromptPlaceholder, + customPromptPlaceholder, + ); + + const tryAgainButton = ( + + + + ); + + let content = null; + + if (loading) { + content = ; + } else if (showError) { + content = ; + } else { + content = ( + <> +
+
+ + {(mode === 'only-custom' || mode === 'custom-and-presets') && + (showAnswer ? ( + + {i18n('answer-title')} + + ) : ( +
+ + +
+ ))} + {mode === 'only-presets' && ( +
+ + {i18n('only-presets-title')} + +
+ )} +
+ {(mode === 'custom-and-presets' || mode === 'only-presets') && + ((showTryAgainButton && ( +
+ {tryAgainButton} + + + +
+ )) || + (!disablePromptPresets && ( +
+ +
+ )) || + (disablePromptPresets && showedGptAlert && ( + + )))} +
+ {showAnswer && ( + <> +
+
{answerRender(answer!)}
+
+
+ {showAnswerActions && ( +
+ + + {feedbackType && ( + + {i18n('feedback-message')} + + )} +
+ )} + + + + + + +
+ + )} + + ); + } + + return
{content}
; +}; diff --git a/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.scss b/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.scss new file mode 100644 index 00000000..81a9dad9 --- /dev/null +++ b/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.scss @@ -0,0 +1,30 @@ +.g-md-gpt-dialog-loading-screen { + &__header { + display: flex; + align-items: center; + + margin-bottom: 8px; + } + + &__icon { + margin-right: 4px; + } + + &__text { + margin-right: 8px; + + font-size: var(--g-text-body-2-font-size); + font-weight: 500; + } + + &__skeleton-small-button { + width: 90px; + height: 28px; + margin-right: 8px; + } + + &__skeleton-medium-button { + width: 120px; + height: 28px; + } +} diff --git a/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.tsx b/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 00000000..221472ad --- /dev/null +++ b/src/extensions/yfm/GPT/GptDialog/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import type {FC} from 'react'; + +import {cn} from '@bem-react/classname'; +import {Skeleton} from '@gravity-ui/uikit'; + +import {i18n} from '../../../../../i18n/gpt/loading'; +import GPTLoading from '../../../../../icons/GPTLoading'; +import {IconRefuge} from '../../IconRefuge/IconRefuge'; + +import './LoadingScreen.scss'; + +export const cnGptDialogLoadingScreen = cn('gpt-dialog-loading-screen'); + +export const LoadingScreen: FC = () => { + return ( +
+
+ + {i18n('loading-text')} +
+
+ + +
+
+ ); +}; diff --git a/src/extensions/yfm/GPT/IconRefuge/IconRefuge.classname.tsx b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.classname.tsx new file mode 100644 index 00000000..21fb0e99 --- /dev/null +++ b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.classname.tsx @@ -0,0 +1,3 @@ +import {cn} from '@bem-react/classname'; + +export const cnIconRefuge = cn('icon-refuge'); diff --git a/src/extensions/yfm/GPT/IconRefuge/IconRefuge.scss b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.scss new file mode 100644 index 00000000..6e68c506 --- /dev/null +++ b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.scss @@ -0,0 +1,9 @@ +.icon-refuge { + display: inline-flex; + justify-content: center; + align-items: center; + + &_inline { + display: inline-flex; + } +} diff --git a/src/extensions/yfm/GPT/IconRefuge/IconRefuge.tsx b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.tsx new file mode 100644 index 00000000..cbc9bd1c --- /dev/null +++ b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type {FC} from 'react'; + +import {Icon} from '@gravity-ui/uikit'; + +import {cnIconRefuge} from './IconRefuge.classname'; +import type {IconRefugeProps} from './IconRefuge.types'; + +import './IconRefuge.scss'; + +/** + * Creates a div wrapper above the icon, which sets the min-width and min-height + * equal to its refugeSize prop, puts the original icon in the middle and + * proxies the rest of the propses to it. + * + * The component is made in order to add margins to icons that have + * margins are indented, but their svg exactly wrap around the edges of the image + * + * It seems that all icons will be square, if not, then you can simply add + * refugeWidth and refugeHeight + */ + +export const IconRefuge: FC = ({ + refugeSize, + containerClassName, + containerStyle, + widthOnly, + inlineIcon, + title, + 'aria-label': ariaLabel, + ...props +}) => { + if (props.size === refugeSize && !title && !ariaLabel && !containerStyle) { + return ; + } + + return ( +
+ +
+ ); +}; + +IconRefuge.displayName = 'Icon'; diff --git a/src/extensions/yfm/GPT/IconRefuge/IconRefuge.types.d.ts b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.types.d.ts new file mode 100644 index 00000000..105acaf6 --- /dev/null +++ b/src/extensions/yfm/GPT/IconRefuge/IconRefuge.types.d.ts @@ -0,0 +1,13 @@ +import type {CSSProperties} from 'react'; + +import type {IconProps} from '@gravity-ui/uikit'; + +export type IconRefugeProps = IconProps & { + refugeSize: number; + widthOnly?: boolean; + inlineIcon?: boolean; + containerClassName?: string; + containerStyle?: CSSProperties; + title?: string; + 'aria-label'?: string; +}; diff --git a/src/extensions/yfm/GPT/IconRefuge/index.ts b/src/extensions/yfm/GPT/IconRefuge/index.ts new file mode 100644 index 00000000..c308d05b --- /dev/null +++ b/src/extensions/yfm/GPT/IconRefuge/index.ts @@ -0,0 +1,2 @@ +export {IconRefuge} from './IconRefuge'; +export type {IconRefugeProps} from './IconRefuge.types'; diff --git a/src/extensions/yfm/GPT/PresetList/PresetList.tsx b/src/extensions/yfm/GPT/PresetList/PresetList.tsx new file mode 100644 index 00000000..2073a5e4 --- /dev/null +++ b/src/extensions/yfm/GPT/PresetList/PresetList.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type {FC} from 'react'; + +import {ActionTooltip, Button, DropdownMenu} from '@gravity-ui/uikit'; + +import {cn} from '../../../../classname'; +import {i18n} from '../../../../i18n/gpt/dialog'; +import {type PromptPreset} from '../ErrorScreen/types'; +import type {GptDialogProps} from '../GptDialog/GptDialog'; +import {gptHotKeys} from '../constants'; +import {useGptHotKeys} from '../hooks/useGptHotKeys'; +import {usePresetList} from '../hooks/usePresetList'; + +import './Presetlist.scss'; + +export type PresetListProps = { + disablePromptPresets: GptDialogProps['disablePromptPresets']; + promptPresets: GptDialogProps['promptPresets']; + onPresetClick: (data: PromptData) => void; +}; + +type PresetItemType = { + preset: PromptPreset; + onPresetClick: PresetListProps['onPresetClick']; + disablePromptPresets?: PresetListProps['disablePromptPresets']; + hotKey: string; +}; + +export const cnGptDialogPresetList = cn('gpt-dialog-preset-list'); + +const PresetItem: FC = ({preset, onPresetClick, disablePromptPresets, hotKey}) => { + useGptHotKeys( + hotKey, + () => { + onPresetClick(preset.data); + }, + {enableOnFormTags: true}, + ); + + return ( + + + + ); +}; + +export const PresetList: FC = ({ + disablePromptPresets, + promptPresets, + onPresetClick, +}) => { + const {presetsContainerRef, visiblePresets, hiddenPresets, showMoreButton, measured} = + usePresetList({ + promptPresets, + onPresetClick, + }); + + return ( +
+ {visiblePresets.map((preset, index) => ( + + ))} + {showMoreButton && ( + + {i18n('more')} + + } + items={hiddenPresets} + /> + )} +
+ ); +}; diff --git a/src/extensions/yfm/GPT/PresetList/Presetlist.scss b/src/extensions/yfm/GPT/PresetList/Presetlist.scss new file mode 100644 index 00000000..c39638bc --- /dev/null +++ b/src/extensions/yfm/GPT/PresetList/Presetlist.scss @@ -0,0 +1,9 @@ +.g-md-gpt-dialog-preset-list { + &__preset + &__preset { + margin-left: var(--g-spacing-2); + } + + &__more-button-wrapper { + margin-left: var(--g-spacing-2); + } +} diff --git a/src/extensions/yfm/GPT/actions.ts b/src/extensions/yfm/GPT/actions.ts new file mode 100644 index 00000000..27f9c068 --- /dev/null +++ b/src/extensions/yfm/GPT/actions.ts @@ -0,0 +1,13 @@ +import type {ActionSpec, ExtensionDeps} from 'src/core'; + +import {runGpt} from './commands'; + +export const showGptWidget: (deps: ExtensionDeps) => ActionSpec = (_deps) => ({ + isActive() { + return false; + }, + isEnable() { + return true; + }, + run: runGpt, +}); diff --git a/src/extensions/yfm/GPT/commands.ts b/src/extensions/yfm/GPT/commands.ts new file mode 100644 index 00000000..dd921f22 --- /dev/null +++ b/src/extensions/yfm/GPT/commands.ts @@ -0,0 +1,14 @@ +import {Command, TextSelection} from 'prosemirror-state'; + +import {GptWidgetMeta, pluginKey} from './plugin'; + +export const runGpt: Command = (state, dispatch) => { + const {selection: sel} = state; + + const meta: GptWidgetMeta = {action: 'show', from: sel.from, to: sel.to}; + const tr = state.tr.setMeta(pluginKey, meta); + + dispatch?.(tr.setSelection(TextSelection.create(tr.doc, sel.to))); + + return true; +}; diff --git a/src/extensions/yfm/GPT/constants.ts b/src/extensions/yfm/GPT/constants.ts new file mode 100644 index 00000000..d5c14797 --- /dev/null +++ b/src/extensions/yfm/GPT/constants.ts @@ -0,0 +1,15 @@ +import type {PopupProps} from '@gravity-ui/uikit/build/esm/components/Popup/Popup'; + +export const WIDGET_DECO_CLASS_NAME = 'g-md-gpt-widget-deco'; +export const WIDGET_DECO_SPEC_FLAG = 'gpt_widget_deco'; +export const gptPopupPlacement: PopupProps['placement'] = ['bottom-start', 'top-start']; + +export const gptHotKeys = { + openGptKey: 'Mod-h', + openGptKeyTooltip: 'Mod+h', + presetsKey: (key: string) => `Control+${key}`, + tryAgainGpt: 'Control+t', + freshStartGpt: 'Control+r', + applyResultGpt: 'Enter', + closeGpt: 'Escape', +}; diff --git a/src/extensions/yfm/GPT/gptExtension/gptExtension.ts b/src/extensions/yfm/GPT/gptExtension/gptExtension.ts new file mode 100644 index 00000000..fa4f38d0 --- /dev/null +++ b/src/extensions/yfm/GPT/gptExtension/gptExtension.ts @@ -0,0 +1,57 @@ +import {Action, ExtensionWithOptions} from 'src/core'; + +import type {CommonAnswer} from '../ErrorScreen/types'; +import {showGptWidget} from '../actions'; +import {runGpt} from '../commands'; +import {gptHotKeys} from '../constants'; +import {gptWidgetPlugin} from '../plugin'; + +import type {GptWidgetDecoViewParams} from './view'; + +export const gptActionName = 'addGptWidget'; + +export type GptWidgetOptions< + AnswerData extends CommonAnswer = CommonAnswer, + PromptData extends unknown = unknown, +> = Pick< + GptWidgetDecoViewParams, + | 'gptPopupContainer' + | 'answerRender' + | 'onApplyResult' + | 'promptPresets' + | 'customPromptPlaceholder' + | 'disabledPromptPlaceholder' + | 'onCustomPromptApply' + | 'onPromptPresetClick' + | 'onTryAgain' + | 'onLike' + | 'onDislike' + | 'onClose' + | 'onUpdate' + | 'gptAlertProps' +>; + +export const gptExtension: ExtensionWithOptions = (builder, options) => { + builder.addAction(gptActionName, showGptWidget); + builder.addPlugin(({serializer, markupParser}) => { + return gptWidgetPlugin({ + ...options, + serializer, + parser: markupParser, + }); + }); + builder.addKeymap( + () => ({ + [gptHotKeys.openGptKey]: runGpt, + }), + builder.Priority.VeryLow, + ); +}; + +declare global { + namespace WysiwygEditor { + interface Actions { + [gptActionName]: Action<{}>; + } + } +} diff --git a/src/extensions/yfm/GPT/gptExtension/view.scss b/src/extensions/yfm/GPT/gptExtension/view.scss new file mode 100644 index 00000000..29178944 --- /dev/null +++ b/src/extensions/yfm/GPT/gptExtension/view.scss @@ -0,0 +1,23 @@ +.g-md-gpt-widget-deco { + background-color: var(--g-color-base-info-light-hover); +} + +.g-md-gpt-popup { + --layer-navigation-bar: 900; + pointer-events: initial; + + &[class] { + border-radius: var(--g-spacing-2); + } + + &__content { + width: 720px; + padding: var(--g-spacing-3); + + pointer-events: all; + + box-shadow: + -8px 2px 16px -4px var(--g-color-private-purple-150), + 8px 2px 16px -4px var(--g-color-private-blue-150); + } +} diff --git a/src/extensions/yfm/GPT/gptExtension/view.tsx b/src/extensions/yfm/GPT/gptExtension/view.tsx new file mode 100644 index 00000000..9c036cd2 --- /dev/null +++ b/src/extensions/yfm/GPT/gptExtension/view.tsx @@ -0,0 +1,318 @@ +import React, {useCallback, useEffect} from 'react'; + +import {Popup} from '@gravity-ui/uikit'; +import type {PopupProps} from '@gravity-ui/uikit'; +import {Slice} from 'prosemirror-model'; +import type {EditorState, PluginView} from 'prosemirror-state'; +import {TextSelection} from 'prosemirror-state'; +import {EditorView} from 'prosemirror-view'; +import {useMount} from 'react-use'; + +import {Parser, Serializer} from 'src/core'; + +import {cn} from '../../../../classname'; +import {getReactRendererFromState} from '../../../behavior'; +import type {CommonAnswer} from '../ErrorScreen/types'; +import type {GptDialogProps} from '../GptDialog/GptDialog'; +import {GptDialog} from '../GptDialog/GptDialog'; +import {WIDGET_DECO_CLASS_NAME, WIDGET_DECO_SPEC_FLAG, gptPopupPlacement} from '../constants'; +import type {GptWidgetMeta} from '../plugin'; +import {pluginKey} from '../plugin'; + +import './view.scss'; + +export const cnGptPopup = cn('gpt-popup'); + +export type GptWidgetDecoViewParams< + AnswerData extends CommonAnswer = CommonAnswer, + PromptData extends unknown = unknown, +> = Omit, 'markup' | 'onApplyResult'> & { + serializer: Serializer; + parser: Parser; +} & { + onApplyResult?: GptDialogProps['onApplyResult']; + gptPopupContainer?: PopupProps['container']; +}; + +export class GptWidgetDecoView implements Required { + private readonly _view; + private readonly _renderer; + + private _decoElem: Element | null = null; + private _params: GptWidgetDecoViewParams; + private _serializer: Serializer; + private _parser: Parser; + private _confirmOpen: boolean; + + constructor(view: EditorView, params: GptWidgetDecoViewParams) { + this._view = view; + + this._params = params; + this._serializer = params.serializer; + this._parser = params.parser; + this._confirmOpen = false; + + this._renderer = getReactRendererFromState(view.state).createItem( + 'gpt-widget-view', + this._render.bind(this), + ); + } + + update(view: EditorView, prevState: EditorState): void { + const {state, dispatch} = view; + + if (this._decoElem && !state.selection.eq(prevState.selection)) { + const transaction = state.tr; + const meta: GptWidgetMeta = {action: 'hide'}; + + dispatch(transaction.setMeta(pluginKey, meta)); + this._view.focus(); + + return; + } + + const decoElements = Array.from(view.dom.querySelectorAll(`.${WIDGET_DECO_CLASS_NAME}`)); + + this._decoElem = decoElements.at(-1) ?? null; + this._renderer.rerender(); + } + + destroy(): void { + this._resetState(); + + this._renderer.remove(); + } + + private _resetState() { + this._decoElem = null; + this._confirmOpen = false; + } + + private _onConfirmCancel = () => { + this._confirmOpen = false; + + this._renderer.rerender(); + }; + + private _onConfirmOk = () => { + this._onClose(); + }; + + private _render() { + if (!this._decoElem) return null; + + const markup = this._getContentOfDecoration(); + + if (markup === undefined) { + return null; + } + + return ( + + ); + } + + private _onGptAnswerUpdate: NonNullable = (answer) => { + this._params.onUpdate?.(answer); + }; + + private _onSubmit: NonNullable = (answer: string) => { + const deco = this._getCurrentDecoration(); + + if (!deco) return; + + const {from, to} = deco; + + const answerNode = this._parser.parse(answer); + + const tr = this._view.state.tr; + const meta: GptWidgetMeta = {action: 'hide'}; + + tr.setMeta(pluginKey, meta); + tr.replace(from, to, new Slice(answerNode.content, 1, 1)); + tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(to))); + + this._view.dispatch(tr); + + setTimeout(() => { + this._view.focus(); + }, 0); + + this._params.onApplyResult?.(answer); + + this._resetState(); + }; + + private _onClose = () => { + const deco = this._getCurrentDecoration(); + + if (!deco) { + return; + } + + const tr = this._view.state.tr; + const meta: GptWidgetMeta = {action: 'hide'}; + + tr.setSelection(TextSelection.create(tr.doc, deco.from, deco.to)); + + tr.setMeta(pluginKey, meta); + + this._view.dispatch(tr); + + setTimeout(() => { + this._view.focus(); + }, 0); + + this._params.onClose?.(); + + this._resetState(); + }; + + private _getContentOfDecoration(): string | undefined { + const deco = this._getCurrentDecoration(); + + if (!deco) return undefined; + + const {from, to} = deco; + + try { + const fragment = this._view.state.doc.slice(from, to, true).content; + const yfmMarkup = this._serializer.serialize(fragment); + + return yfmMarkup; + } catch (error) { + console.error(error); + + return this._view.state.doc.textBetween(from, to, '\n', ''); + } + } + + private _getCurrentDecoration() { + return this._getPluginState()?.find( + undefined, + undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return + (spec) => spec[WIDGET_DECO_SPEC_FLAG], + )[0]; + } + + private _getPluginState() { + return pluginKey.getState(this._view.state); + } +} + +type WidgetProps = Pick & { + markup: string; + onClose: () => void; + confirmOpen: boolean; + onConfirmOk: () => void; + onConfirmCancel: () => void; +} & GptDialogProps; + +function Widget({ + markup, + anchorRef, + answerRender, + promptPresets, + disablePromptPresets, + customPromptPlaceholder, + disabledPromptPlaceholder, + onCustomPromptApply, + onApplyResult, + onPromptPresetClick, + onTryAgain, + onLike, + onDislike, + onClose, + onUpdate, + container, + gptAlertProps, +}: WidgetProps) { + useEffect(() => { + // rerender the popup + window.dispatchEvent(new CustomEvent('scroll')); + }, [anchorRef]); + + useMount(() => { + if (anchorRef?.current && 'scrollIntoView' in anchorRef.current) { + anchorRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }); + + const handleUpdate = useCallback( + (result?: CommonAnswer) => { + onUpdate?.(result); + }, + [onUpdate], + ); + + return ( + <> + + + + + ); +} diff --git a/src/extensions/yfm/GPT/hooks/useGpt.tsx b/src/extensions/yfm/GPT/hooks/useGpt.tsx new file mode 100644 index 00000000..efb31fca --- /dev/null +++ b/src/extensions/yfm/GPT/hooks/useGpt.tsx @@ -0,0 +1,217 @@ +import {useCallback, useState} from 'react'; + +import type {TextInputProps} from '@gravity-ui/uikit'; + +import type {CommonAnswer, GptRequestData} from '../ErrorScreen/types'; +import type {GptDialogProps} from '../GptDialog/GptDialog'; +import type {PresetListProps} from '../PresetList/PresetList'; +import {isEnter} from '../utils'; + +type UseGptProps< + AnswerData extends CommonAnswer = CommonAnswer, + PromptData extends unknown = unknown, +> = Pick< + GptDialogProps, + | 'markup' + | 'promptPresets' + | 'onCustomPromptApply' + | 'onPromptPresetClick' + | 'onTryAgain' + | 'onLike' + | 'onDislike' + | 'onApplyResult' + | 'onUpdate' +>; + +export const useGpt = < + AnswerData extends CommonAnswer = CommonAnswer, + PromptData extends unknown = unknown, +>({ + markup, + promptPresets, + onCustomPromptApply, + onPromptPresetClick, + onTryAgain, + onLike, + onDislike, + onApplyResult, + onUpdate, +}: UseGptProps) => { + const [answer, setAnswer] = useState(); + const [lastRequestData, setLastRequestData] = useState>(); + const [customPrompt, setCustomPrompt] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [feedbackType, setFeedbackType] = useState<'like' | 'dislike' | undefined>(undefined); + const [feedbackTypeLoading, setFeedbackTypeLoading] = useState<'like' | 'dislike' | undefined>( + undefined, + ); + + const makeRequest: ( + requestFunction: UseGptProps['onPromptPresetClick'], + data: GptRequestData, + ) => Promise = useCallback( + async (requestFunction, data) => { + if (!requestFunction) { + return; + } + + let result; + + try { + setLoading(true); + setError(false); + setLastRequestData(data); + + result = await requestFunction(data); + + if (result) { + setAnswer(result); + setFeedbackType(undefined); + } + } catch (error) { + console.error(error); + + setError(true); + } finally { + setLoading(false); + + onUpdate?.(result); + } + }, + [onUpdate], + ); + + const handleLike = useCallback(async () => { + if (!onLike || !lastRequestData) { + return; + } + + try { + setFeedbackType(undefined); + setFeedbackTypeLoading('like'); + + await onLike(lastRequestData); + + setFeedbackType('like'); + } catch (error) { + console.error(error); + } finally { + setFeedbackTypeLoading(undefined); + } + }, [lastRequestData, onLike]); + + const handleDislike = useCallback(async () => { + if (!onDislike || !lastRequestData) { + return; + } + + try { + setFeedbackType(undefined); + setFeedbackTypeLoading('dislike'); + + await onDislike(lastRequestData); + + setFeedbackType('dislike'); + } catch (error) { + console.error(error); + } finally { + setFeedbackTypeLoading(undefined); + } + }, [lastRequestData, onDislike]); + + const handleCustomPromptApply = useCallback(async () => { + if (!customPrompt) { + return; + } + + const gptRequestData: GptRequestData = { + markup, + customPrompt, + }; + + await makeRequest(onCustomPromptApply, gptRequestData); + }, [customPrompt, makeRequest, markup, onCustomPromptApply]); + + const handleCustomPromptKeyPress = useCallback>( + async (event) => { + if (!isEnter(event)) { + return; + } + + await handleCustomPromptApply(); + }, + [handleCustomPromptApply], + ); + + const handlePresetClick = useCallback['onPresetClick']>( + async (data) => { + const gptRequestData: GptRequestData = { + markup, + promptData: data, + }; + + await makeRequest(onPromptPresetClick, gptRequestData); + }, + [makeRequest, markup, onPromptPresetClick], + ); + + const handleTryAgain = useCallback(async () => { + if (!lastRequestData) { + return; + } + + await makeRequest(onTryAgain, lastRequestData); + }, [lastRequestData, makeRequest, onTryAgain]); + + const handleApplyResult = useCallback(() => { + onApplyResult(answer?.rawText ?? ''); + }, [answer?.rawText, onApplyResult]); + + const handleFreshStart = useCallback(() => { + setError(false); + setLastRequestData(undefined); + setAnswer(undefined); + setCustomPrompt(''); + setFeedbackType(undefined); + + onUpdate?.(undefined); + }, [onUpdate]); + + const showTryAgainButton = Boolean(lastRequestData && onTryAgain && !loading); + const showAnswer = Boolean(answer && !loading && !error); + const showError = error && !loading; + const showAnswerActions = (onLike || onDislike) && showAnswer; + + let mode: 'custom-and-presets' | 'only-custom' | 'only-presets' = 'custom-and-presets'; + + if (onCustomPromptApply && !promptPresets?.length) { + mode = 'only-custom'; + } else if (!onCustomPromptApply && promptPresets?.length) { + mode = 'only-presets'; + } + + return { + answer, + customPrompt, + lastRequestData, + loading, + error, + mode, + feedbackType, + feedbackTypeLoading, + handleLike, + handleDislike, + handleCustomPromptUpdate: setCustomPrompt, + handleCustomPromptKeyPress, + handleCustomPromptApply, + handlePresetClick, + handleTryAgain, + handleFreshStart, + handleApplyResult, + showTryAgainButton, + showAnswer, + showError, + showAnswerActions, + }; +}; diff --git a/src/extensions/yfm/GPT/hooks/useGptHotKeys.ts b/src/extensions/yfm/GPT/hooks/useGptHotKeys.ts new file mode 100644 index 00000000..2041dc44 --- /dev/null +++ b/src/extensions/yfm/GPT/hooks/useGptHotKeys.ts @@ -0,0 +1,11 @@ +import type {Options} from 'react-hotkeys-hook'; +import {useHotkeys} from 'react-hotkeys-hook'; + +export function useGptHotKeys( + key: string, + callback: () => void, + options: Options = {}, + dependencies?: unknown[], +) { + useHotkeys(key, callback, {preventDefault: true, ...options}, dependencies); +} diff --git a/src/extensions/yfm/GPT/hooks/useOverflowingHorizontalItems.tsx b/src/extensions/yfm/GPT/hooks/useOverflowingHorizontalItems.tsx new file mode 100644 index 00000000..1a4a3905 --- /dev/null +++ b/src/extensions/yfm/GPT/hooks/useOverflowingHorizontalItems.tsx @@ -0,0 +1,104 @@ +import {useLayoutEffect, useMemo, useState} from 'react'; +import type {RefObject} from 'react'; + +import debounceFn from 'lodash/debounce'; + +export type UseOverflowingContainerListItemsProps = { + containerRef: RefObject; + items?: ItemType[]; + itemSelector: string; + moreButtonSelector: string; + marginBetweenItems?: number; +}; + +export function useOverflowingHorizontalItems({ + containerRef, + items, + itemSelector, + moreButtonSelector, + marginBetweenItems = 0, +}: UseOverflowingContainerListItemsProps) { + const [containerWidth, setContainerWidth] = useState(0); + const [itemWidths, setItemWidths] = useState([]); + const [moreButtonWidth, setMoreButtonWidth] = useState(0); + + useLayoutEffect(() => { + const measureItemSizes = () => { + const itemElements = Array.from( + containerRef.current?.querySelectorAll(itemSelector) ?? [], + ); + + const moreButton = containerRef.current?.querySelector(moreButtonSelector); + + setItemWidths(itemElements.map((item) => item.clientWidth)); + + if (moreButton) { + setMoreButtonWidth(moreButton.clientWidth); + } + }; + + requestAnimationFrame(measureItemSizes); + }, [containerRef, itemSelector, moreButtonSelector]); + + useLayoutEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const updateContainerSize = (entries: ResizeObserverEntry[]) => { + if (entries.length > 0) { + setContainerWidth(entries[0]!.contentRect.width); + } + }; + + const updateContainerSizeDebounced = debounceFn(updateContainerSize, 100); + const containerResizeObserver = new ResizeObserver(updateContainerSizeDebounced); + + containerResizeObserver.observe(container); + + return () => containerResizeObserver.unobserve(container); + }, [containerRef]); + + const isMeasured = itemWidths.length > 0; + + const {visibleItems, hiddenItems} = useMemo(() => { + if (!isMeasured) { + return { + visibleItems: items ?? [], + hiddenItems: [], + }; + } + + const itemsCount = itemWidths.length; + let visibleItemsCount = 0; + + const spaceForMoreButton = moreButtonWidth + marginBetweenItems; + let remainingContainerWidth = containerWidth; + + for (const width of itemWidths) { + const itemWidthWithMargin = width + marginBetweenItems; + + remainingContainerWidth -= itemWidthWithMargin; + + if (remainingContainerWidth < spaceForMoreButton) { + const isMoreThanOneItemLeft = itemsCount !== visibleItemsCount + 1; + const hasNoSpaceForTheLastItem = remainingContainerWidth < 0; + + if (isMoreThanOneItemLeft || hasNoSpaceForTheLastItem) { + break; + } + } + + visibleItemsCount++; + } + + return { + visibleItems: items?.slice(0, visibleItemsCount) ?? [], + hiddenItems: items?.slice(visibleItemsCount) ?? [], + }; + }, [containerWidth, isMeasured, itemWidths, items, marginBetweenItems, moreButtonWidth]); + + return {visibleItems, hiddenItems, measured: isMeasured}; +} diff --git a/src/extensions/yfm/GPT/hooks/usePresetList.ts b/src/extensions/yfm/GPT/hooks/usePresetList.ts new file mode 100644 index 00000000..6ae6441d --- /dev/null +++ b/src/extensions/yfm/GPT/hooks/usePresetList.ts @@ -0,0 +1,49 @@ +import {useMemo, useRef} from 'react'; + +import type {DropdownMenuItem} from '@gravity-ui/uikit'; + +import type {GptDialogProps} from '../GptDialog/GptDialog'; +import type {PresetListProps} from '../PresetList/PresetList'; +import {cnGptDialogPresetList} from '../PresetList/PresetList'; + +import {useOverflowingHorizontalItems} from './useOverflowingHorizontalItems'; + +type UsePresetListProps = Pick & { + onPresetClick: PresetListProps['onPresetClick']; +}; + +export const usePresetList = ({promptPresets, onPresetClick}: UsePresetListProps) => { + const presetsContainerRef = useRef(null); + + const {visibleItems, hiddenItems, measured} = useOverflowingHorizontalItems({ + containerRef: presetsContainerRef, + items: promptPresets, + itemSelector: `.${cnGptDialogPresetList('preset')}`, + moreButtonSelector: `.${cnGptDialogPresetList('more-button-wrapper')}`, + marginBetweenItems: 8, + }); + + const hiddenPresets: DropdownMenuItem[] = useMemo(() => { + const items: DropdownMenuItem[] = []; + + for (const item of hiddenItems) { + items.push({ + text: item.display, + action: () => onPresetClick(item.data), + items: [], + }); + } + + return items; + }, [onPresetClick, hiddenItems]); + + const showMoreButton = !measured || hiddenPresets.length > 0; + + return { + measured, + showMoreButton, + presetsContainerRef, + visiblePresets: visibleItems, + hiddenPresets, + }; +}; diff --git a/src/extensions/yfm/GPT/index.ts b/src/extensions/yfm/GPT/index.ts new file mode 100644 index 00000000..9132fd2b --- /dev/null +++ b/src/extensions/yfm/GPT/index.ts @@ -0,0 +1,2 @@ +export * from './toolbar'; +export * from './gptExtension/gptExtension'; diff --git a/src/extensions/yfm/GPT/plugin.ts b/src/extensions/yfm/GPT/plugin.ts new file mode 100644 index 00000000..ddc46ef9 --- /dev/null +++ b/src/extensions/yfm/GPT/plugin.ts @@ -0,0 +1,70 @@ +import {Plugin, PluginKey} from 'prosemirror-state'; +import {Decoration, DecorationSet} from 'prosemirror-view'; + +import {WIDGET_DECO_CLASS_NAME, WIDGET_DECO_SPEC_FLAG} from './constants'; +import type {GptWidgetDecoViewParams} from './gptExtension/view'; +import {GptWidgetDecoView} from './gptExtension/view'; + +export type GptWidgetMeta = + | { + action: 'show'; + from: number; + to: number; + } + | { + action: 'hide'; + }; + +const key = new PluginKey('gpt-widget'); + +export {key as pluginKey}; + +export const gptWidgetPlugin = (params: GptWidgetDecoViewParams): Plugin => { + return new Plugin({ + key, + state: { + init: () => DecorationSet.empty, + apply: (tr, decos) => { + const meta = tr.getMeta(key) as GptWidgetMeta | undefined; + const paramsGpt = params; + + if (meta?.action === 'show') { + if (meta.to === meta.from) { + const spanElem = document.createElement('span'); + spanElem.className = WIDGET_DECO_CLASS_NAME; + spanElem.textContent = ' '; + + paramsGpt.disablePromptPresets = true; + + return DecorationSet.create(tr.doc, [ + Decoration.widget(meta.from, spanElem, { + [WIDGET_DECO_SPEC_FLAG]: true, + }), + ]); + } + + return DecorationSet.create(tr.doc, [ + Decoration.inline( + meta.from, + meta.to, + {nodeName: 'span', class: WIDGET_DECO_CLASS_NAME}, + {[WIDGET_DECO_SPEC_FLAG]: true}, + ), + ]); + } + + if (meta?.action === 'hide') { + paramsGpt.disablePromptPresets = false; + + return DecorationSet.empty; + } + + return decos.map(tr.mapping, tr.doc); + }, + }, + props: { + decorations: (state) => key.getState(state), + }, + view: (view) => new GptWidgetDecoView(view, params), + }); +}; diff --git a/src/extensions/yfm/GPT/toolbar.ts b/src/extensions/yfm/GPT/toolbar.ts new file mode 100644 index 00000000..c5708595 --- /dev/null +++ b/src/extensions/yfm/GPT/toolbar.ts @@ -0,0 +1,22 @@ +import {cn} from '@bem-react/classname'; + +import {i18n} from '../../../i18n/gpt/extension'; +import gptIcon from '../../../icons/GPT'; +import {ToolbarDataType, type WToolbarSingleItemData} from '../../../toolbar'; + +import {gptHotKeys} from './constants'; + +export const cnGptButton = cn('gpt-button'); + +export const wGptToolbarItem: WToolbarSingleItemData = { + type: ToolbarDataType.SingleButton, + id: 'gpt', + title: () => `${i18n('help-with-text')}`, + hotkey: gptHotKeys.openGptKeyTooltip, + icon: {data: gptIcon}, + disabledPopoverVisible: false, + exec: (actionsStorage) => actionsStorage.actions.addGptWidget.run({}), + isActive: (actionsStorage) => actionsStorage.actions.addGptWidget.isActive(), + isEnable: (actionsStorage) => actionsStorage.actions.addGptWidget.isEnable(), + className: cnGptButton(), +}; diff --git a/src/extensions/yfm/GPT/utils.ts b/src/extensions/yfm/GPT/utils.ts new file mode 100644 index 00000000..433a6368 --- /dev/null +++ b/src/extensions/yfm/GPT/utils.ts @@ -0,0 +1,41 @@ +import type React from 'react'; + +import {i18n} from '../../../i18n/gpt/dialog'; + +import {GptDialogProps} from './GptDialog/GptDialog'; + +type CombinedKeyboardEvent = KeyboardEvent | React.KeyboardEvent; + +export function getAlertGptInfo(gptAlert: GptDialogProps['gptAlertProps']) { + return { + alertMessage: gptAlert?.message || i18n('alert-gpt-presets-info'), + alertTheme: gptAlert?.theme || 'info', + alertClassName: gptAlert?.className, + }; +} + +export function getDisableReplaceButtonText(disablePromptPresets?: boolean) { + return disablePromptPresets ? i18n(`replace-disabled`) : i18n(`replace`); +} + +export function getInputPlaceHolder( + disablePromptPresets?: boolean, + disabledPromptPlaceholder?: string, + customPromptPlaceholder?: string, +) { + return disablePromptPresets ? disabledPromptPlaceholder : customPromptPlaceholder; +} + +export const isEnter = (event: CombinedKeyboardEvent) => + event.code === 'Enter' || event.code === 'NumpadEnter'; + +export function focusWithoutScroll(element?: HTMLElement | null) { + const x = window.scrollX; + const y = window.scrollY; + + element?.focus({ + preventScroll: true, + }); + + window.scrollTo(x, y); +} diff --git a/src/extensions/yfm/index.ts b/src/extensions/yfm/index.ts index 22287691..5480b486 100644 --- a/src/extensions/yfm/index.ts +++ b/src/extensions/yfm/index.ts @@ -11,3 +11,4 @@ export * from './YfmHeading'; export * from './YfmNote'; export * from './YfmTable'; export * from './YfmTabs'; +export * from './GPT'; diff --git a/src/i18n/gpt/dialog/en.json b/src/i18n/gpt/dialog/en.json new file mode 100644 index 00000000..c1101178 --- /dev/null +++ b/src/i18n/gpt/dialog/en.json @@ -0,0 +1,16 @@ +{ + "answer-title": "What do you want to do with the response?", + "close-button": "Close", + "dislike": "Bad response", + "error": "An error occurred", + "feedback-message": "Thanks for your feedback!", + "fresh-start-button": "Start again", + "like": "Good response", + "more": "More", + "only-presets-title": "Help with text", + "refetch": "Try again", + "replace": "Replace the selected text", + "replace-disabled": "Insert text", + "try-again": "Try again", + "alert-gpt-presets-info": "Highlight text to see Yandex GPT presets" +} diff --git a/src/i18n/gpt/dialog/index.ts b/src/i18n/gpt/dialog/index.ts new file mode 100644 index 00000000..c4db49f8 --- /dev/null +++ b/src/i18n/gpt/dialog/index.ts @@ -0,0 +1,8 @@ +import {registerKeyset} from '../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = registerKeyset('gpt-dialog', {en, ru}); + +export type I18nKey = Parameters[0]; diff --git a/src/i18n/gpt/dialog/ru.json b/src/i18n/gpt/dialog/ru.json new file mode 100644 index 00000000..85537e77 --- /dev/null +++ b/src/i18n/gpt/dialog/ru.json @@ -0,0 +1,16 @@ +{ + "answer-title": "Что вы хотите сделать с ответом?", + "close-button": "Закрыть", + "dislike": "Плохой ответ", + "error": "Произошла ошибка", + "feedback-message": "Ваш голос учтён!", + "fresh-start-button": "Начать сначала", + "like": "Хороший ответ", + "more": "Ещё", + "only-presets-title": "Помощь с текстом", + "refetch": "Попробовать ещё", + "replace": "Заменить выделенный текст", + "replace-disabled": "Вставить текст", + "try-again": "Иначе", + "alert-gpt-presets-info": "Выделите текст, чтобы увидеть пресеты Yandex GPT" +} diff --git a/src/i18n/gpt/errors/en.json b/src/i18n/gpt/errors/en.json new file mode 100644 index 00000000..f695e49f --- /dev/null +++ b/src/i18n/gpt/errors/en.json @@ -0,0 +1,5 @@ +{ + "error-text": "An error occurred while generating a reply, please retry the request", + "retry-button": "Try again", + "start-again-button": "To the beginning" +} diff --git a/src/i18n/gpt/errors/index.ts b/src/i18n/gpt/errors/index.ts new file mode 100644 index 00000000..5cccc76c --- /dev/null +++ b/src/i18n/gpt/errors/index.ts @@ -0,0 +1,8 @@ +import {registerKeyset} from '../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = registerKeyset('gpt-dialog-error-screen', {en, ru}); + +export type I18nKey = Parameters[0]; diff --git a/src/i18n/gpt/errors/ru.json b/src/i18n/gpt/errors/ru.json new file mode 100644 index 00000000..577997fe --- /dev/null +++ b/src/i18n/gpt/errors/ru.json @@ -0,0 +1,5 @@ +{ + "error-text": "Ошибка при генерации ответа, попробуйте повторить запрос", + "retry-button": "Повторить", + "start-again-button": "В начало" +} diff --git a/src/i18n/gpt/extension/en.json b/src/i18n/gpt/extension/en.json new file mode 100644 index 00000000..8c72b785 --- /dev/null +++ b/src/i18n/gpt/extension/en.json @@ -0,0 +1,6 @@ +{ + "confirm-cancel": "Cancel", + "confirm-ok": "Close", + "confirm-title": "Do you want to close the GPT editor?", + "help-with-text": "Help with text" +} diff --git a/src/i18n/gpt/extension/index.ts b/src/i18n/gpt/extension/index.ts new file mode 100644 index 00000000..14c0b51f --- /dev/null +++ b/src/i18n/gpt/extension/index.ts @@ -0,0 +1,8 @@ +import {registerKeyset} from '../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = registerKeyset('gpt', {en, ru}); + +export type I18nKey = Parameters[0]; diff --git a/src/i18n/gpt/extension/ru.json b/src/i18n/gpt/extension/ru.json new file mode 100644 index 00000000..ab1333d0 --- /dev/null +++ b/src/i18n/gpt/extension/ru.json @@ -0,0 +1,6 @@ +{ + "confirm-cancel": "Отменить", + "confirm-ok": "Закрыть", + "confirm-title": "Хотите закрыть помощника GPT?", + "help-with-text": "Помощь с текстом" +} diff --git a/src/i18n/gpt/loading/en.json b/src/i18n/gpt/loading/en.json new file mode 100644 index 00000000..a996ab19 --- /dev/null +++ b/src/i18n/gpt/loading/en.json @@ -0,0 +1,3 @@ +{ + "loading-text": "GPT is generating a response..." +} diff --git a/src/i18n/gpt/loading/index.ts b/src/i18n/gpt/loading/index.ts new file mode 100644 index 00000000..5162fe2b --- /dev/null +++ b/src/i18n/gpt/loading/index.ts @@ -0,0 +1,8 @@ +import {registerKeyset} from '../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = registerKeyset('gpt-dialog-loading-screen', {en, ru}); + +export type I18nKey = Parameters[0]; diff --git a/src/i18n/gpt/loading/ru.json b/src/i18n/gpt/loading/ru.json new file mode 100644 index 00000000..e7ee1372 --- /dev/null +++ b/src/i18n/gpt/loading/ru.json @@ -0,0 +1,3 @@ +{ + "loading-text": "GPT генерирует ответ..." +} diff --git a/src/i18n/menubar/en.json b/src/i18n/menubar/en.json index 7c61ff49..6905918a 100644 --- a/src/i18n/menubar/en.json +++ b/src/i18n/menubar/en.json @@ -19,6 +19,7 @@ "file": "File", "folding-heading": "Collapsed section", "folding-heading_hint": "The text under the heading can be collapsed or expanded", + "gpt": "GPT widget", "heading": "Heading", "heading1": "Heading 1", "heading2": "Heading 2", diff --git a/src/i18n/menubar/ru.json b/src/i18n/menubar/ru.json index 70ba1983..45cd18e3 100644 --- a/src/i18n/menubar/ru.json +++ b/src/i18n/menubar/ru.json @@ -19,6 +19,7 @@ "file": "Файл", "folding-heading": "Свёрнутый раздел", "folding-heading_hint": "Текст под заголовком можно свернуть или раскрыть", + "gpt": "GPT-виджет", "heading": "Заголовок", "heading1": "Заголовок 1", "heading2": "Заголовок 2", diff --git a/src/icons/GPT.tsx b/src/icons/GPT.tsx new file mode 100644 index 00000000..95f8fc74 --- /dev/null +++ b/src/icons/GPT.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import {useTheme} from '@gravity-ui/uikit'; + +const GPTIcon = () => { + // It may be worth taking the hook outside the icon + const theme = useTheme(); + + const gradient = + theme === 'dark' || theme === 'dark-hc' ? ( + <> + + + + ) : ( + <> + + + + ); + + return ( + + + + + + + {gradient} + + + + + + + ); +}; +export default GPTIcon; diff --git a/src/icons/GPTLoading.tsx b/src/icons/GPTLoading.tsx new file mode 100644 index 00000000..6635e314 --- /dev/null +++ b/src/icons/GPTLoading.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const GPTLoading = () => ( + + + + + + + + + + +); + +export default GPTLoading; diff --git a/src/icons/index.ts b/src/icons/index.ts index 6e089ac6..61c7cb6e 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -1,8 +1,10 @@ +import GPTIcon from './GPT'; +import GPTLoading from './GPTLoading'; import MermaidIcon from './Mermaid'; import MonoIcon from './Mono'; import TabsIcon from './Tabs'; -export {MermaidIcon, MonoIcon, TabsIcon}; +export {MermaidIcon, MonoIcon, TabsIcon, GPTIcon, GPTLoading}; export { ArrowUturnCcwLeft as UndoIcon, diff --git a/src/react-utils/useAutoFocus.ts b/src/react-utils/useAutoFocus.ts index b134c420..21f3d0e4 100644 --- a/src/react-utils/useAutoFocus.ts +++ b/src/react-utils/useAutoFocus.ts @@ -1,6 +1,6 @@ import {RefObject, useEffect} from 'react'; -export const useAutoFocus = (nodeRef: RefObject) => { +export const useAutoFocus = (nodeRef: RefObject, dependencies: unknown[] = []) => { useEffect(() => { const {current: anchor} = nodeRef; const timeout = setTimeout(() => { @@ -12,5 +12,5 @@ export const useAutoFocus = (nodeRef: RefObject) => { }; // https://github.com/facebook/react/issues/23392#issuecomment-1055610198 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeRef.current]); + }, [nodeRef.current, ...dependencies]); };