diff --git a/package.json b/package.json index b2f7287d..c2dd90cb 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tabs": "^1.1.9", + "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "2.0.0", "@slate-yjs/core": "^1.0.2", @@ -119,6 +120,7 @@ "react-hook-form": "^7.52.2", "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-katex": "^3.0.1", "react-measure": "^2.5.2", "react-player": "^2.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e249298d..f101d1ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.9 version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': + specifier: ^1.2.15 + version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -290,6 +293,9 @@ importers: react-i18next: specifier: ^14.1.0 version: 14.1.3(i18next@22.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-infinite-scroll-component: + specifier: ^6.1.0 + version: 6.1.0(react@18.3.1) react-katex: specifier: ^3.0.1 version: 3.1.0(prop-types@15.8.1)(react@18.3.1) @@ -2366,6 +2372,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.6': resolution: {integrity: sha512-2JMfHJf/eVnwq+2dewT3C0acmCWD3XiVA1Da+jTDqo342UlU13WvXtqHhG+yJw5JeQmu4ue2eMy6gcEArLBlcw==} peerDependencies: @@ -2497,6 +2506,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.9': resolution: {integrity: sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==} peerDependencies: @@ -2710,6 +2732,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.2': resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==} peerDependencies: @@ -2845,8 +2880,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.13': - resolution: {integrity: sha512-e/e43mQAwgYs8BY4y9l99xTK6ig1bK2uXsFLOMn9IZ16lAgulSTsotcPHVT2ZlSb/ye6Sllq7IgyDB8dGhpeXQ==} + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2965,6 +3000,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -9024,7 +9072,7 @@ snapshots: '@radix-ui/react-separator': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.21)(react@18.3.1) '@radix-ui/react-switch': 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toast': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) axios: 1.9.0 class-variance-authority: 0.7.1 @@ -11151,6 +11199,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.2(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11281,6 +11331,19 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-dismissable-layer@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -11532,6 +11595,16 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.21)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-primitive@2.1.2(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.2(@types/react@18.3.21)(react@18.3.1) @@ -11685,20 +11758,20 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) - '@radix-ui/react-toast@1.2.13(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.21)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.21)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.2(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.21)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.21)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.21)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.2(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -11795,6 +11868,15 @@ snapshots: '@types/react': 18.3.21 '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.21 + '@types/react-dom': 18.3.7(@types/react@18.3.21) + '@radix-ui/rect@1.1.1': {} '@reduxjs/toolkit@2.0.0(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1)': diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index c8e1bb3d..298f736d 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1,4 +1,4 @@ -import { RepeatedChatMessage } from '@appflowyinc/ai-chat'; +import { RepeatedChatMessage } from '@/components/chat'; import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index 7fe5ebaa..869897dd 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -1,4 +1,4 @@ -import { RepeatedChatMessage } from '@appflowyinc/ai-chat'; +import { RepeatedChatMessage } from '@/components/chat'; import * as random from 'lib0/random'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 042e6e67..33c73868 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -1,4 +1,4 @@ -import { RepeatedChatMessage } from '@appflowyinc/ai-chat'; +import { RepeatedChatMessage } from '@/components/chat'; import { AxiosInstance } from 'axios'; import { GlobalComment, Reaction } from '@/application/comment.type'; diff --git a/src/application/types.ts b/src/application/types.ts index 6fc67c1b..a33e9a8e 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1,4 +1,4 @@ -import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat'; +import { PromptDatabaseConfiguration } from '@/components/chat'; import { AxiosInstance } from 'axios'; import * as Y from 'yjs'; diff --git a/src/components/ai-chat/AIChat.tsx b/src/components/ai-chat/AIChat.tsx index 21695b3e..3f95e699 100644 --- a/src/components/ai-chat/AIChat.tsx +++ b/src/components/ai-chat/AIChat.tsx @@ -1,4 +1,4 @@ -import { Chat, ChatRequest } from '@appflowyinc/ai-chat'; +import { Chat, ChatRequest } from '@/components/chat'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import React, { useEffect, useMemo } from 'react'; @@ -8,7 +8,7 @@ import { useCurrentUser, useService } from '@/components/main/app.hooks'; import { getPlatform } from '@/utils/platform'; import { downloadPage } from '@/utils/url'; -import '@appflowyinc/ai-chat/style'; +import '@/components/chat/styles/index.scss'; export function AIChat({ chatId, onRendered }: { chatId: string; onRendered?: () => void }) { const service = useService(); diff --git a/src/components/app/hooks/useDatabaseOperations.ts b/src/components/app/hooks/useDatabaseOperations.ts index 9cf61fe2..5f9b092c 100644 --- a/src/components/app/hooks/useDatabaseOperations.ts +++ b/src/components/app/hooks/useDatabaseOperations.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat'; +import { PromptDatabaseConfiguration } from '@/components/chat'; import { DatabasePrompt, diff --git a/src/components/chat/assets/icons/ai-more.svg b/src/components/chat/assets/icons/ai-more.svg new file mode 100644 index 00000000..73e8f73f --- /dev/null +++ b/src/components/chat/assets/icons/ai-more.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/ai.svg b/src/components/chat/assets/icons/ai.svg new file mode 100644 index 00000000..321f3775 --- /dev/null +++ b/src/components/chat/assets/icons/ai.svg @@ -0,0 +1,7 @@ + + + + diff --git a/src/components/chat/assets/icons/ai_sparks.svg b/src/components/chat/assets/icons/ai_sparks.svg new file mode 100644 index 00000000..e3cf2c1c --- /dev/null +++ b/src/components/chat/assets/icons/ai_sparks.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/chat/assets/icons/arrow-up.svg b/src/components/chat/assets/icons/arrow-up.svg new file mode 100644 index 00000000..6c1c0177 --- /dev/null +++ b/src/components/chat/assets/icons/arrow-up.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/components/chat/assets/icons/at.svg b/src/components/chat/assets/icons/at.svg new file mode 100644 index 00000000..53a2d20f --- /dev/null +++ b/src/components/chat/assets/icons/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/chat/assets/icons/auto-text.svg b/src/components/chat/assets/icons/auto-text.svg new file mode 100644 index 00000000..9e0cc0dc --- /dev/null +++ b/src/components/chat/assets/icons/auto-text.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/board.svg b/src/components/chat/assets/icons/board.svg new file mode 100644 index 00000000..8ba9a9bc --- /dev/null +++ b/src/components/chat/assets/icons/board.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/components/chat/assets/icons/bullet-list.svg b/src/components/chat/assets/icons/bullet-list.svg new file mode 100644 index 00000000..e82e3b80 --- /dev/null +++ b/src/components/chat/assets/icons/bullet-list.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/components/chat/assets/icons/calendar.svg b/src/components/chat/assets/icons/calendar.svg new file mode 100644 index 00000000..c1824e86 --- /dev/null +++ b/src/components/chat/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/change-font.svg b/src/components/chat/assets/icons/change-font.svg new file mode 100644 index 00000000..4e406db3 --- /dev/null +++ b/src/components/chat/assets/icons/change-font.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/components/chat/assets/icons/chat-outlined.svg b/src/components/chat/assets/icons/chat-outlined.svg new file mode 100644 index 00000000..87989592 --- /dev/null +++ b/src/components/chat/assets/icons/chat-outlined.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/check-circle.svg b/src/components/chat/assets/icons/check-circle.svg new file mode 100644 index 00000000..8c59d0ee --- /dev/null +++ b/src/components/chat/assets/icons/check-circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/components/chat/assets/icons/check.svg b/src/components/chat/assets/icons/check.svg new file mode 100644 index 00000000..5ed80178 --- /dev/null +++ b/src/components/chat/assets/icons/check.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/chevron.svg b/src/components/chat/assets/icons/chevron.svg new file mode 100644 index 00000000..d9e6ba3d --- /dev/null +++ b/src/components/chat/assets/icons/chevron.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/clip.svg b/src/components/chat/assets/icons/clip.svg new file mode 100644 index 00000000..364ba80f --- /dev/null +++ b/src/components/chat/assets/icons/clip.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/chat/assets/icons/close.svg b/src/components/chat/assets/icons/close.svg new file mode 100644 index 00000000..03f4fd18 --- /dev/null +++ b/src/components/chat/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/close_circle.svg b/src/components/chat/assets/icons/close_circle.svg new file mode 100644 index 00000000..40f2401c --- /dev/null +++ b/src/components/chat/assets/icons/close_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/continue-writing.svg b/src/components/chat/assets/icons/continue-writing.svg new file mode 100644 index 00000000..c72a1330 --- /dev/null +++ b/src/components/chat/assets/icons/continue-writing.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/copy.svg b/src/components/chat/assets/icons/copy.svg new file mode 100644 index 00000000..5559af5b --- /dev/null +++ b/src/components/chat/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/doc-forward.svg b/src/components/chat/assets/icons/doc-forward.svg new file mode 100644 index 00000000..62dc5a7d --- /dev/null +++ b/src/components/chat/assets/icons/doc-forward.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/doc.svg b/src/components/chat/assets/icons/doc.svg new file mode 100644 index 00000000..08d2a5e0 --- /dev/null +++ b/src/components/chat/assets/icons/doc.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/components/chat/assets/icons/download.svg b/src/components/chat/assets/icons/download.svg new file mode 100644 index 00000000..3b713e05 --- /dev/null +++ b/src/components/chat/assets/icons/download.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/chat/assets/icons/drop_menu_show.svg b/src/components/chat/assets/icons/drop_menu_show.svg new file mode 100644 index 00000000..42ead9ef --- /dev/null +++ b/src/components/chat/assets/icons/drop_menu_show.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/error.svg b/src/components/chat/assets/icons/error.svg new file mode 100644 index 00000000..89f8b0e6 --- /dev/null +++ b/src/components/chat/assets/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/chat/assets/icons/explain.svg b/src/components/chat/assets/icons/explain.svg new file mode 100644 index 00000000..272ca876 --- /dev/null +++ b/src/components/chat/assets/icons/explain.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/components/chat/assets/icons/fix-spelling.svg b/src/components/chat/assets/icons/fix-spelling.svg new file mode 100644 index 00000000..1b520c54 --- /dev/null +++ b/src/components/chat/assets/icons/fix-spelling.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/components/chat/assets/icons/grid.svg b/src/components/chat/assets/icons/grid.svg new file mode 100644 index 00000000..36cc2cec --- /dev/null +++ b/src/components/chat/assets/icons/grid.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/components/chat/assets/icons/image-text.svg b/src/components/chat/assets/icons/image-text.svg new file mode 100644 index 00000000..d5bc46dd --- /dev/null +++ b/src/components/chat/assets/icons/image-text.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/components/chat/assets/icons/image.svg b/src/components/chat/assets/icons/image.svg new file mode 100644 index 00000000..9e6ca022 --- /dev/null +++ b/src/components/chat/assets/icons/image.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/components/chat/assets/icons/improve-writing.svg b/src/components/chat/assets/icons/improve-writing.svg new file mode 100644 index 00000000..5d09d207 --- /dev/null +++ b/src/components/chat/assets/icons/improve-writing.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/components/chat/assets/icons/insert-below.svg b/src/components/chat/assets/icons/insert-below.svg new file mode 100644 index 00000000..33479a6a --- /dev/null +++ b/src/components/chat/assets/icons/insert-below.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/make-longer.svg b/src/components/chat/assets/icons/make-longer.svg new file mode 100644 index 00000000..f031a184 --- /dev/null +++ b/src/components/chat/assets/icons/make-longer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/make-shorter.svg b/src/components/chat/assets/icons/make-shorter.svg new file mode 100644 index 00000000..86633793 --- /dev/null +++ b/src/components/chat/assets/icons/make-shorter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chat/assets/icons/num-list.svg b/src/components/chat/assets/icons/num-list.svg new file mode 100644 index 00000000..5e5ddcb7 --- /dev/null +++ b/src/components/chat/assets/icons/num-list.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/components/chat/assets/icons/paragraph.svg b/src/components/chat/assets/icons/paragraph.svg new file mode 100644 index 00000000..2d5d14ca --- /dev/null +++ b/src/components/chat/assets/icons/paragraph.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/components/chat/assets/icons/regenerate-circle.svg b/src/components/chat/assets/icons/regenerate-circle.svg new file mode 100644 index 00000000..028db5b8 --- /dev/null +++ b/src/components/chat/assets/icons/regenerate-circle.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/components/chat/assets/icons/regenerate.svg b/src/components/chat/assets/icons/regenerate.svg new file mode 100644 index 00000000..55c9aced --- /dev/null +++ b/src/components/chat/assets/icons/regenerate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/chat/assets/icons/stop.svg b/src/components/chat/assets/icons/stop.svg new file mode 100644 index 00000000..4856f28a --- /dev/null +++ b/src/components/chat/assets/icons/stop.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/components/chat/assets/icons/success.svg b/src/components/chat/assets/icons/success.svg new file mode 100644 index 00000000..5b06ab2b --- /dev/null +++ b/src/components/chat/assets/icons/success.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/chat/assets/icons/summarize.svg b/src/components/chat/assets/icons/summarize.svg new file mode 100644 index 00000000..0e375323 --- /dev/null +++ b/src/components/chat/assets/icons/summarize.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/components/chat/assets/icons/table.svg b/src/components/chat/assets/icons/table.svg new file mode 100644 index 00000000..a357e8e0 --- /dev/null +++ b/src/components/chat/assets/icons/table.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/components/chat/assets/icons/text.svg b/src/components/chat/assets/icons/text.svg new file mode 100644 index 00000000..04a6b8f6 --- /dev/null +++ b/src/components/chat/assets/icons/text.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/components/chat/assets/icons/trans.svg b/src/components/chat/assets/icons/trans.svg new file mode 100644 index 00000000..095ea2d7 --- /dev/null +++ b/src/components/chat/assets/icons/trans.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/chat/assets/icons/undo.svg b/src/components/chat/assets/icons/undo.svg new file mode 100644 index 00000000..0a1f6525 --- /dev/null +++ b/src/components/chat/assets/icons/undo.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/components/chat/assets/logo.svg b/src/components/chat/assets/logo.svg new file mode 100644 index 00000000..cbc46a5c --- /dev/null +++ b/src/components/chat/assets/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/chat/chat/context.ts b/src/components/chat/chat/context.ts new file mode 100644 index 00000000..b652fe0e --- /dev/null +++ b/src/components/chat/chat/context.ts @@ -0,0 +1,13 @@ +import { ChatProps } from '../types'; +import { createContext, useContext } from 'react'; + +export const ChatContext = createContext(undefined); + +export function useChatContext() { + const context = useContext(ChatContext); + if(!context) { + throw new Error('useChatContext must be used within a ChatContextProvider'); + } + + return context; +} diff --git a/src/components/chat/chat/index.tsx b/src/components/chat/chat/index.tsx new file mode 100644 index 00000000..3a920c35 --- /dev/null +++ b/src/components/chat/chat/index.tsx @@ -0,0 +1,27 @@ +import Main from './main'; +import { TooltipProvider } from '../components/ui/tooltip'; +import { ChatI18nContext, getI18n, initI18n } from '../i18n/config'; +import { ChatProps } from '../types'; +import { Toaster } from '../components/ui/toaster'; +import '../styles/index.scss'; + +export * from '../provider/prompt-modal-provider'; +export * from '../provider/view-loader-provider'; + +initI18n(); +const i18n = getI18n(); + +export function Chat(props: ChatProps) { + return ( +
+ + +
+ + + +
+ ); +} + +export default Chat; diff --git a/src/components/chat/chat/main.tsx b/src/components/chat/chat/main.tsx new file mode 100644 index 00000000..706e3070 --- /dev/null +++ b/src/components/chat/chat/main.tsx @@ -0,0 +1,69 @@ +// Code: Chat main component +import { ChatContext } from './context'; +import { ChatInput } from '../components/chat-input'; +import { ChatMessages } from '../components/chat-messages'; +import { cn } from '../lib/utils'; +import { MessageAnimationProvider } from '../provider/message-animation-provider'; +import { EditorProvider } from '../provider/editor-provider'; +import { MessagesHandlerProvider } from '../provider/messages-handler-provider'; +import { ChatMessagesProvider } from '../provider/messages-provider'; +import { PromptModalProvider } from '../provider/prompt-modal-provider'; +import { ResponseFormatProvider } from '../provider/response-format-provider'; +import { SelectionModeProvider } from '../provider/selection-mode-provider'; +import { SuggestionsProvider } from '../provider/suggestions-provider'; +import { ChatProps } from '../types'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ViewLoaderProvider } from '../provider/view-loader-provider'; + +function Main(props: ChatProps) { + const { currentUser, selectionMode } = props; + + return ( + + + + + + + props.requestInstance.getView(viewId, forceRefresh) + } + fetchViews={(forceRefresh?: boolean) => + props.requestInstance.fetchViews(forceRefresh) + } + > + + + + +
+ + + + {!selectionMode && } + + +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default Main; diff --git a/src/components/chat/components/add-messages-to-page-wrapper/index.tsx b/src/components/chat/components/add-messages-to-page-wrapper/index.tsx new file mode 100644 index 00000000..8fa44e19 --- /dev/null +++ b/src/components/chat/components/add-messages-to-page-wrapper/index.tsx @@ -0,0 +1,128 @@ +import { useChatContext } from '../../chat/context'; +import { SpaceList } from '../add-messages-to-page-wrapper/space-list'; +import { Label } from '../../../ui/label'; +import { SearchInput } from '../ui/search-input'; +import { Popover, PopoverContent, PopoverTrigger } from '../../../ui/popover'; +import { Separator } from '../../../ui/separator'; +import { toast } from '../../hooks/use-toast'; +import { useViewContentInserter } from '../../hooks/use-view-content-inserter'; +import { useTranslation } from '../../i18n'; +import { useEditorContext } from '../../provider/editor-provider'; +import { ChatMessage } from '../../types'; +import { useCallback, useState } from 'react'; +import { useViewLoader } from '../../provider/view-loader-provider'; + +export function AddMessageToPageWrapper({ onFinished, messages, children }: { + messages: ChatMessage[]; + children?: React.ReactNode; + onFinished?: () => void; +}) { + const { + openingViewId, + chatId, + } = useChatContext(); + + const { getView } = useViewLoader(); + const { getEditor } = useEditorContext(); + const { + createViewWithContent, + insertContentToView, + } = useViewContentInserter(); + + const { t } = useTranslation(); + const [searchValue, setSearchValue] = useState(''); + + const getData = useCallback(() => { + return messages.reverse().flatMap(item => { + const editor = getEditor(item.message_id); + return editor?.getData() || []; + }); + }, [messages, getEditor]); + + const handleCreateViewWithContent = useCallback(async(parentViewId: string) => { + const data = getData(); + const chat = await getView(chatId, false); + + const name = `Messages extracted from "${chat?.name || 'Untitled'}"`; + + try { + await createViewWithContent(parentViewId, name, data); + toast({ + variant: 'success', + description: t('success.addMessageToPage', { + name, + }), + }); + onFinished?.(); + // eslint-disable-next-line + } catch(e: any) { + toast({ + variant: 'destructive', + description: e.message, + }); + } + }, [getData, getView, chatId, createViewWithContent, t, onFinished]); + + const handleInsertContentToView = useCallback(async(viewId: string) => { + const data = getData(); + const chat = await getView(chatId, false); + + try { + await insertContentToView(viewId, data); + toast({ + variant: 'success', + description: t('success.addMessageToPage', { + name: chat?.name || t('view.placeholder'), + }), + }); + onFinished?.(); + // eslint-disable-next-line + } catch(e: any) { + toast({ + variant: 'destructive', + description: e.message, + }); + } + }, [getData, getView, chatId, insertContentToView, t, onFinished]); + + if(openingViewId) { + return
{ + await handleInsertContentToView(openingViewId); + }} + >{children}
; + } + + return ( + + + {children} + + e.preventDefault()} + onCloseAutoFocus={e => e.preventDefault()} + > +
+ + + + +
+ +
+ +
+
+
+ ); +} + diff --git a/src/components/chat/components/add-messages-to-page-wrapper/space-list.tsx b/src/components/chat/components/add-messages-to-page-wrapper/space-list.tsx new file mode 100644 index 00000000..406bdf0d --- /dev/null +++ b/src/components/chat/components/add-messages-to-page-wrapper/space-list.tsx @@ -0,0 +1,96 @@ +import ViewList from '../add-messages-to-page-wrapper/view-list'; +import { Button } from '../../../ui/button'; +import LoadingDots from '../ui/loading-dots'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../ui/tooltip'; +import SpaceItem from '../view/space-item'; +import { useTranslation } from '../../i18n'; +import { searchViews } from '../../lib/views'; +import { View } from '../../types'; +import { useEffect, useMemo, useState } from 'react'; +import { PlusIcon } from 'lucide-react'; +import { useViewLoader } from '../../provider/view-loader-provider'; + +export function SpaceList({ + searchValue, + onCreateViewWithContent, + onInsertContentToView, +}: { + searchValue: string; + onCreateViewWithContent: (parentViewId: string) => void; + onInsertContentToView: (viewId: string) => void; +}) { + const { t } = useTranslation(); + + const { + fetchViews, + viewsLoading, + } = useViewLoader(); + + const [folder, setFolder] = useState(null); + + useEffect(() => { + void (async() => { + const data = await fetchViews(); + if(!data) return; + setFolder(data); + })(); + }, [fetchViews]); + + const filteredSpaces = useMemo(() => { + const spaces = folder?.children.filter(view => view.extra?.is_space); + return searchViews(spaces || [], searchValue); + }, [folder, searchValue]); + + if(viewsLoading) { + return
+ +
; + } + + if(!filteredSpaces || filteredSpaces.length === 0) { + return
+ {t('search.noSpacesFound')} +
; + } + + return ( +
+ {filteredSpaces.map((view: View) => { + return ( + + + + + + {t('addMessageToPage.createNewPage')} + + + + } + > + + + ); + })} +
+ ); +} + diff --git a/src/components/chat/components/add-messages-to-page-wrapper/view-item.tsx b/src/components/chat/components/add-messages-to-page-wrapper/view-item.tsx new file mode 100644 index 00000000..e58c95cc --- /dev/null +++ b/src/components/chat/components/add-messages-to-page-wrapper/view-item.tsx @@ -0,0 +1,119 @@ +import { ReactComponent as ChevronDown } from '../../assets/icons/drop_menu_show.svg'; +import { Button } from '../../../ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../ui/tooltip'; +import PageIcon from '../view/page-icon'; +import { useTranslation } from '../../i18n'; +import { cn } from '../../lib/utils'; +import { View } from '../../types'; +import { PlusIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { ReactComponent as AddPageIcon } from '../../assets/icons/doc-forward.svg'; + +export function ViewItem({ view, children, onCreateViewWithContent, onInsertContentToView }: { + view: View; + children?: React.ReactNode; + onCreateViewWithContent: (parentViewId: string) => void; + onInsertContentToView: (viewId: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const { t } = useTranslation(); + + const [isHovering, setIsHovering] = useState(false); + const name = view.name || t('view.placeholder'); + + const ToggleButton = useMemo(() => { + return view.children.length > 0 ? ( + + ) : ( +
+ ); + }, [expanded, view.children.length]); + + return ( +
+
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onClick={() => setExpanded(!expanded)} + className={'px-1.5 h-[28px] w-full select-none text-sm cursor-pointer rounded-[8px] flex items-center justify-between gap-2 hover:bg-muted'} + > +
+
+ {ToggleButton} + +
+ + + + {name} + + + {name} + + + +
+ {isHovering && ( +
{ + e.stopPropagation(); + }} + className={'flex items-center gap-1'} + > + + + + + + + {t('button.addToPage')} + + + + + + + + + + {t('addMessageToPage.createNewPage')} + + + +
+ )} +
+ {expanded && children} +
+ ); +} + diff --git a/src/components/chat/components/add-messages-to-page-wrapper/view-list.tsx b/src/components/chat/components/add-messages-to-page-wrapper/view-list.tsx new file mode 100644 index 00000000..f149a151 --- /dev/null +++ b/src/components/chat/components/add-messages-to-page-wrapper/view-list.tsx @@ -0,0 +1,39 @@ +import { ViewItem } from '../add-messages-to-page-wrapper/view-item'; +import { View } from '../../types'; + +function ViewList({ + item, + onCreateViewWithContent, + onInsertContentToView, +}: { + item: View; + onCreateViewWithContent: (parentViewId: string) => void; + onInsertContentToView: (viewId: string) => void; +}) { + if(!item.children || item.children.length === 0) { + return null; + } + + return ( +
+ {item.children.map((view: View) => { + return ( + + + + ); + })} +
+ ); +} + +export default ViewList; \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/ai-writer-menu-content.tsx b/src/components/chat/components/ai-writer/ai-writer-menu-content.tsx new file mode 100644 index 00000000..eaca676e --- /dev/null +++ b/src/components/chat/components/ai-writer/ai-writer-menu-content.tsx @@ -0,0 +1,114 @@ +import { Button } from '../../../ui/button'; +import { Separator } from '../../../ui/separator'; +import { useTranslation } from '../../i18n'; +import { ReactComponent as ImproveWritingIcon } from '../../assets/icons/improve-writing.svg'; +import { ReactComponent as AskAIIcon } from '../../assets/icons/ai.svg'; +import { ReactComponent as FixSpellingIcon } from '../../assets/icons/fix-spelling.svg'; +import { ReactComponent as ExplainIcon } from '../../assets/icons/explain.svg'; +import { ReactComponent as MakeLongerIcon } from '../../assets/icons/make-longer.svg'; +import { ReactComponent as MakeShorterIcon } from '../../assets/icons/make-shorter.svg'; +import { ReactComponent as ContinueWritingIcon } from '../../assets/icons/continue-writing.svg'; +import { AIAssistantType } from '../../types'; +import { useWriterContext } from '../../writer/context'; +import { useMemo } from 'react'; + +export function AiWriterMenuContent({ input, onClicked, isFilterOut }: { + onClicked: (type: AIAssistantType) => void; + isFilterOut?: (type: AIAssistantType) => boolean; + input: string; +}) { + const { t } = useTranslation(); + const { + improveWriting, + askAIAnything, + fixSpelling, + explain, + makeLonger, + makeShorter, + continueWriting, + } = useWriterContext(); + + const actions = useMemo(() => [{ + icon: ContinueWritingIcon, + label: t('writer.continue'), + key: AIAssistantType.ContinueWriting, + onClick: () => continueWriting(input), + }, { + icon: ImproveWritingIcon, + label: t('writer.improve'), + key: AIAssistantType.ImproveWriting, + onClick: () => improveWriting(input), + }, + { + key: AIAssistantType.AskAIAnything, + icon: AskAIIcon, + label: t('writer.askAI'), + onClick: () => askAIAnything(input), + }, + { + key: AIAssistantType.FixSpelling, + icon: FixSpellingIcon, + label: t('writer.fixSpelling'), + onClick: () => fixSpelling(input), + }, + { + key: AIAssistantType.Explain, + icon: ExplainIcon, + label: t('writer.explain'), + onClick: () => explain(input), + }, + ].filter(item => { + return !isFilterOut || !isFilterOut(item.key); + }), [askAIAnything, continueWriting, explain, fixSpelling, improveWriting, input, isFilterOut, t]); + + const otherActions = useMemo(() => [{ + icon: MakeLongerIcon, + label: t('writer.makeLonger'), + onClick: () => makeLonger(input), + key: AIAssistantType.MakeLonger, + }, + { + icon: MakeShorterIcon, + label: t('writer.makeShorter'), + onClick: () => makeShorter(input), + key: AIAssistantType.MakeShorter, + }].filter(item => { + return !isFilterOut || !isFilterOut(item.key); + }), [t, makeLonger, input, makeShorter, isFilterOut]); + + return
+ {actions.map((action, index) => ( + + ))} + {otherActions.length > 0 && } + + { + otherActions.map((action, index) => ( + + )) + } +
; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/confirm-discard.tsx b/src/components/chat/components/ai-writer/confirm-discard.tsx new file mode 100644 index 00000000..11bac97c --- /dev/null +++ b/src/components/chat/components/ai-writer/confirm-discard.tsx @@ -0,0 +1,51 @@ +import { Button } from '../../../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../ui/dialog'; +import { useTranslation } from '../../i18n'; +import { useWriterContext } from '../../writer/context'; +import React from 'react'; + +export const ConfirmDiscard = React.forwardRef void; +}>(({ open, onClose }, ref) => { + const { t } = useTranslation(); + const { exit } = useWriterContext(); + + return !open && onClose()} + > + e.preventDefault()} + onCloseAutoFocus={e => e.preventDefault()} + > + + {t('writer.discard')} + + {t('writer.confirm-discard')} + + + + + + + + ; +}); \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/error.tsx b/src/components/chat/components/ai-writer/error.tsx new file mode 100644 index 00000000..76b5aa5b --- /dev/null +++ b/src/components/chat/components/ai-writer/error.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from '../../i18n'; +import { ERROR_CODE_NO_LIMIT } from '../../lib/const'; +import { useWriterContext } from '../../writer/context'; +import { ReactComponent as XCircleIcon } from '../../assets/icons/error.svg'; + +export function Error() { + const { t } = useTranslation(); + + const { error, rewrite } = useWriterContext(); + + return ( +
+
+ + {error?.code === undefined ? + {t('writer.errors.connection')} + { + rewrite(); + }} + >{t('writer.button.retry')} + : error?.code === ERROR_CODE_NO_LIMIT ? t('writer.errors.noLimit') : error?.message} +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/index.tsx b/src/components/chat/components/ai-writer/index.tsx new file mode 100644 index 00000000..678f878e --- /dev/null +++ b/src/components/chat/components/ai-writer/index.tsx @@ -0,0 +1,159 @@ +import { usePromptModal } from '../../provider/prompt-modal-provider'; +import { ConfirmDiscard } from '../ai-writer/confirm-discard'; +import { Error } from '../ai-writer/error'; +import { Loading } from '../ai-writer/loading'; +import { AskAnything } from '../ai-writer/tools/ask-anything'; +import { Explain } from '../ai-writer/tools/explain'; +import { FixSpelling } from '../ai-writer/tools/fix-spelling'; +import { ImproveWriting } from '../ai-writer/tools/improve-writing'; +import { Popover, PopoverContent, PopoverTrigger } from '../../../ui/popover'; +import { useTranslation } from '../../i18n'; +import { AIAssistantType } from '../../types'; +import { useWriterContext } from '../../writer/context'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +type PointerDownOutsideEvent = CustomEvent<{ + originalEvent: PointerEvent; +}>; +type FocusOutsideEvent = CustomEvent<{ + originalEvent: FocusEvent; +}>; + +export function AIAssistant({ + children, +}: { + children?: React.ReactNode; +}) { + const { t } = useTranslation(); + const { + assistantType, + isFetching, + exit, + isApplying, + error, + scrollContainer, + hasAIAnswer, + } = useWriterContext(); + const open = Boolean(assistantType); + const [openModal, setOpenModal] = useState(false); + + const { + isOpen + } = usePromptModal(); + + const Tool = useMemo(() => { + if(error) return ; + if(isApplying || isFetching) return ; + switch(assistantType) { + case AIAssistantType.AskAIAnything: + return ; + case AIAssistantType.ContinueWriting: + return ; + + case AIAssistantType.Explain: + return ; + + case AIAssistantType.FixSpelling: + return ; + + case AIAssistantType.ImproveWriting: + return ; + case AIAssistantType.MakeLonger: + return ; + case AIAssistantType.MakeShorter: + return ; + default: + return null; + } + }, [error, isApplying, isFetching, assistantType, t]); + + const handleOpenChange = useCallback((status: boolean) => { + if(!status) { + exit(); + } + }, [exit]); + + const [width, setWidth] = useState(600); + + useEffect(() => { + const container = document.getElementById('appflowy-ai-writer'); + + if(container && open) { + setWidth(container.clientWidth); + } + if(scrollContainer) { + if(open) { + scrollContainer.style.userSelect = 'none'; + } else { + scrollContainer.style.userSelect = 'auto'; + } + } + + }, [open, scrollContainer]); + + const onInteractOutside = useCallback((e: PointerDownOutsideEvent | FocusOutsideEvent) => { + if(hasAIAnswer()) { + e.preventDefault(); + e.stopPropagation(); + e.detail.originalEvent.preventDefault(); + setOpenModal(true); + } + }, [hasAIAnswer]); + + return <> + + {children} + {open && { + e.stopPropagation(); + }} + onEscapeKeyDown={e => { + if (isOpen) { + e.preventDefault(); + return; + } + if (openModal) { + e.preventDefault(); + e.stopPropagation(); + setOpenModal(false); + return; + } + if(hasAIAnswer()) { + e.preventDefault(); + e.stopPropagation(); + setOpenModal(true); + } + }} + disableOutsidePointerEvents + onInteractOutside={onInteractOutside} + container={scrollContainer || document.body} + forceMount + id={'ai-assistant'} + className={'relative !bg-transparent max-w-full'} + side={'bottom'} + avoidCollisions={false} + collisionPadding={0} + align={'start'} + style={{ + width, + borderWidth: 0, + boxShadow: 'none', + padding: 0, + userSelect: 'none', + }} + >{Tool} + { + setOpenModal(false); + }} + /> + } + + + + ; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/loading.tsx b/src/components/chat/components/ai-writer/loading.tsx new file mode 100644 index 00000000..43f557e9 --- /dev/null +++ b/src/components/chat/components/ai-writer/loading.tsx @@ -0,0 +1,43 @@ +import { Button } from '../../../ui/button'; +import LoadingDots from '../ui/loading-dots'; +import { useTranslation } from '../../i18n'; +import { useWriterContext } from '../../writer/context'; +import { ReactComponent as StopIcon } from '../../assets/icons/stop.svg'; + +export function Loading() { + const { t } = useTranslation(); + const { + isApplying, + isFetching, + stop, + } = useWriterContext(); + + return ( +
+
+ { + isFetching ? t('writer.analyzing') : isApplying ? t('writer.editing') : null + } + +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/render-editor.tsx b/src/components/chat/components/ai-writer/render-editor.tsx new file mode 100644 index 00000000..3c85b12e --- /dev/null +++ b/src/components/chat/components/ai-writer/render-editor.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertDescription } from '../ui/alert'; + +import { Editor, EditorData, useEditor } from '@appflowyinc/editor'; + +import { useEffect } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +export function RenderEditor({ + content, + onDataChange, +}: { + content: string; + onDataChange?: (data: EditorData) => void; +}) { + + const editor = useEditor(); + + useEffect(() => { + editor.applyMarkdown(content); + onDataChange?.(editor.getData()); + }, [content, editor, onDataChange]); + + return ( +
+ + + Failed to render content + + } + > + {content && } + + +
+ ); +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/ask-anything.tsx b/src/components/chat/components/ai-writer/tools/ask-anything.tsx new file mode 100644 index 00000000..0804b79b --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/ask-anything.tsx @@ -0,0 +1,45 @@ +import { ReactComponent as TryAgainIcon } from '../../../assets/icons/undo.svg'; +import { CommentWithAskAnything } from './with-comment'; +import { useTranslation } from '../../../i18n'; +import { useWriterContext } from '../../../writer/context'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { useMemo } from 'react'; + +export function AskAnything({ + title, +}: { + title: string +}) { + const { t } = useTranslation(); + const { + rewrite, + keep, + exit, + } = useWriterContext(); + + const actions = useMemo(() => [ + { + label: t('writer.button.keep'), + onClick: keep, + icon: , + }, + { + label: t('writer.button.discard'), + onClick: () => { + exit(); + }, + icon: , + }, + { + label: t('writer.button.rewrite'), + onClick: rewrite, + icon: , + }, + ], [exit, keep, rewrite, t]); + + return ; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/explain-toolbar.tsx b/src/components/chat/components/ai-writer/tools/explain-toolbar.tsx new file mode 100644 index 00000000..d6520380 --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/explain-toolbar.tsx @@ -0,0 +1,56 @@ +import { RenderEditor } from '../render-editor'; +import { Button } from '../../ui/button'; +import { Label } from '../../ui/label'; +import { useTranslation } from '../../../i18n'; +import { useWriterContext } from '../../../writer/context'; +import { EditorProvider } from '@appflowyinc/editor'; +import { XIcon } from 'lucide-react'; +import { ReactComponent as InsertBelowIcon } from '../../../assets/icons/insert-below.svg'; +import { ReactComponent as TryAgainIcon } from '../../../assets/icons/undo.svg'; + +export function ExplainToolbar() { + const { t } = useTranslation(); + const { + rewrite, + keep: insertBelow, + exit, + placeholderContent, + setEditorData, + } = useWriterContext(); + + return
+ +
+ + + +
+
+ + + +
+
; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/explain.tsx b/src/components/chat/components/ai-writer/tools/explain.tsx new file mode 100644 index 00000000..1ca45ebd --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/explain.tsx @@ -0,0 +1,24 @@ +import { ExplainToolbar } from './explain-toolbar'; +import { WritingInput } from '../writing-input'; +import { ApplyingState, useWriterContext } from '../../../writer/context'; +import { useCallback } from 'react'; + +export function Explain() { + const { askAIAnythingWithRequest, applyingState } = useWriterContext(); + + const showToolbar = applyingState === ApplyingState.completed; + + const handleSubmit = useCallback(async(content: string) => { + return askAIAnythingWithRequest(content); + }, [askAIAnythingWithRequest]); + + return
+
+ {showToolbar && } + +
+
; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/fix-spelling.tsx b/src/components/chat/components/ai-writer/tools/fix-spelling.tsx new file mode 100644 index 00000000..d3673bc5 --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/fix-spelling.tsx @@ -0,0 +1,64 @@ +import { CommentWithAskAnything } from './with-comment'; + +import { useTranslation } from '../../../i18n'; +import { useWriterContext } from '../../../writer/context'; +import { useMemo } from 'react'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { ReactComponent as TryAgainIcon } from '../../../assets/icons/undo.svg'; +import { ReactComponent as InsertBelowIcon } from '../../../assets/icons/insert-below.svg'; + +export function FixSpelling() { + const { t } = useTranslation(); + const { + rewrite, + accept, + keep: insertBelow, + exit, + placeholderContent, + } = useWriterContext(); + + const actions = useMemo(() => placeholderContent ? [ + { + label: t('writer.button.accept'), + onClick: accept, + icon: , + }, + { + label: t('writer.button.discard'), + onClick: () => { + exit(); + }, + icon: , + }, + { + label: t('writer.button.insert-below'), + onClick: insertBelow, + icon: , + }, + { + label: t('writer.button.try-again'), + onClick: rewrite, + icon: , + }, + ] : [ + { + label: t('writer.button.try-again'), + onClick: rewrite, + icon: , + }, + { + label: t('writer.button.close'), + onClick: () => { + exit(); + }, + icon: , + }, + ], [accept, exit, insertBelow, placeholderContent, rewrite, t]); + + return ; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/improve-writing.tsx b/src/components/chat/components/ai-writer/tools/improve-writing.tsx new file mode 100644 index 00000000..06d8afad --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/improve-writing.tsx @@ -0,0 +1,67 @@ +import { CommentWithAskAnything } from './with-comment'; + +import { useTranslation } from '../../../i18n'; +import { useWriterContext } from '../../../writer/context'; +import { useMemo } from 'react'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { ReactComponent as TryAgainIcon } from '../../../assets/icons/undo.svg'; +import { ReactComponent as InsertBelowIcon } from '../../../assets/icons/insert-below.svg'; + +export function ImproveWriting({ + title, +}: { + title: string +}) { + const { + rewrite, + accept, + keep: insertBelow, + exit, + placeholderContent, + } = useWriterContext(); + const { t } = useTranslation(); + + const actions = useMemo(() => placeholderContent ? [ + { + label: t('writer.button.accept'), + onClick: accept, + icon: , + }, + { + label: t('writer.button.discard'), + onClick: () => { + exit(); + }, + icon: , + }, + { + label: t('writer.button.insert-below'), + onClick: insertBelow, + icon: , + }, + { + label: t('writer.button.rewrite'), + onClick: rewrite, + icon: , + }, + ] : [ + { + label: t('writer.button.try-again'), + onClick: rewrite, + icon: , + }, + { + label: t('writer.button.close'), + onClick: () => { + exit(); + }, + icon: , + }, + ], [accept, exit, insertBelow, placeholderContent, rewrite, t]); + + return ; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/tools/with-comment.tsx b/src/components/chat/components/ai-writer/tools/with-comment.tsx new file mode 100644 index 00000000..8488dbce --- /dev/null +++ b/src/components/chat/components/ai-writer/tools/with-comment.tsx @@ -0,0 +1,98 @@ +import { RenderEditor } from '../render-editor'; +import { WritingInput } from '../writing-input'; +import { Button } from '../../ui/button'; +import { Label } from '../../ui/label'; +import { ApplyingState, useWriterContext } from '../../../writer/context'; +import { EditorProvider } from '@appflowyinc/editor'; +import { ReactNode, useCallback, useMemo } from 'react'; + +export function withComment(WrappedComponent: React.ComponentType<{ + noBorder?: boolean; + onSubmit: (content: string) => Promise<() => void>; + noSwitchMode: boolean; +}>) { + return ({ + actions, + title, + noSwitchMode, + }: { + noSwitchMode: boolean, + actions: { + onClick: () => void; + icon: ReactNode; + label: string; + }[]; + title: string; + }) => { + + const { + comment, + applyingState, + askAIAnythingWithRequest, + } = useWriterContext(); + + const handleSubmit = useCallback(async(content: string) => { + return askAIAnythingWithRequest(content); + }, [askAIAnythingWithRequest]); + + const showToolbar = applyingState === ApplyingState.completed; + + const renderToolbar = useMemo(() => { + return actions.map(({ + onClick, + icon, + label, + }) => ( + + )); + }, [actions]); + + if(!comment) { + return
+ {showToolbar && +
+ {renderToolbar} +
} + +
; + } + + return
+ {showToolbar && +
+ {renderToolbar} +
} +
+
+ +
+ + + +
+
+ + +
+
; + }; +} + +export const CommentWithAskAnything = withComment(WritingInput); diff --git a/src/components/chat/components/ai-writer/use-ensure-bottom-visible.ts b/src/components/chat/components/ai-writer/use-ensure-bottom-visible.ts new file mode 100644 index 00000000..6f53a642 --- /dev/null +++ b/src/components/chat/components/ai-writer/use-ensure-bottom-visible.ts @@ -0,0 +1,128 @@ +import { ApplyingState, useWriterContext } from '../../writer/context'; +import throttle from 'lodash-es/throttle'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const SCROLL_CONFIG = { + // Offset to ensure the bottom is visible + OFFSET: 150, + // Delay to throttle the scroll event + DELAY: 50, + // Delay to prevent auto scroll when user is scrolling + PREVENT_SCROLL_DELAY: 150, +}; + +function useEnsureBottomVisible() { + const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); + const isUserScrollingRef = useRef(false); + const { + placeholderContent, + scrollContainer, + hasAIAnswer, + applyingState, + } = useWriterContext(); + + const enableAutoScroll = useCallback(() => { + isUserScrollingRef.current = false; + setIsAutoScrollEnabled(true); + }, []); + + const applyingStateRef = useRef(applyingState); + + useEffect(() => { + applyingStateRef.current = applyingState; + if(applyingState === ApplyingState.idle) { + enableAutoScroll(); + } + }, [applyingState, enableAutoScroll]); + + const getTarget = useCallback(() => { + return document.querySelector('.writer-anchor'); + }, []); + + const scrollToBottom = useCallback((container: HTMLElement) => { + const containerRect = container.getBoundingClientRect(); + const rect = document.querySelector('.writer-anchor')?.getBoundingClientRect(); + if(!rect) return; + + const offset = rect.bottom - containerRect.bottom; + + if(offset < 0) return; + + container.scrollTo({ + top: container.scrollTop + offset + SCROLL_CONFIG.OFFSET, + behavior: 'smooth', + }); + }, []); + + const scrollIntoView = useMemo(() => { + return throttle(() => { + const target = getTarget(); + if(!scrollContainer || !target || isUserScrollingRef.current) return; + + scrollToBottom(scrollContainer); + + }, SCROLL_CONFIG.DELAY); + }, [getTarget, scrollContainer, scrollToBottom]); + + const handleScroll = useCallback((event: WheelEvent) => { + const target = getTarget(); + + if(!isAutoScrollEnabled || !target) return; + + if([ApplyingState.analyzing, ApplyingState.applying].includes(applyingStateRef.current)) { + isUserScrollingRef.current = true; + if(event.deltaY < -50) { + console.error('User is scrolling, disabling auto scroll', event.deltaY); + setIsAutoScrollEnabled(false); + } else { + isUserScrollingRef.current = false; + } + } + + }, [getTarget, isAutoScrollEnabled]); + + useEffect(() => { + if(!isAutoScrollEnabled) return; + + scrollContainer?.addEventListener('wheel', handleScroll, { passive: true }); + + window.addEventListener('wheel', handleScroll, { passive: true }); + + return () => { + scrollContainer?.removeEventListener('wheel', handleScroll); + window.removeEventListener('wheel', handleScroll); + + }; + }, [handleScroll, isAutoScrollEnabled, scrollContainer]); + + useEffect(() => { + const target = getTarget(); + if(!placeholderContent || !target) { + return; + } + + if(isAutoScrollEnabled && ([ApplyingState.analyzing, ApplyingState.applying, ApplyingState.completed].includes(applyingState))) { + const rect = target.getBoundingClientRect(); + const viewportRect = scrollContainer?.getBoundingClientRect(); + + if(viewportRect && rect.bottom > viewportRect.bottom) { + scrollIntoView(); + } + + } + }, [getTarget, isAutoScrollEnabled, placeholderContent, scrollContainer, scrollIntoView, applyingState]); + + useEffect(() => { + if(!placeholderContent && hasAIAnswer()) { + enableAutoScroll(); + } + }, [hasAIAnswer, enableAutoScroll, placeholderContent]); + + return { + isAutoScrollEnabled, + enableAutoScroll, + disableAutoScroll: () => setIsAutoScrollEnabled(false), + }; +} + +export default useEnsureBottomVisible; \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/utils.ts b/src/components/chat/components/ai-writer/utils.ts new file mode 100644 index 00000000..435a4805 --- /dev/null +++ b/src/components/chat/components/ai-writer/utils.ts @@ -0,0 +1,8 @@ +export function getToolPosition(writer: HTMLElement) { + const writerRect = writer.getBoundingClientRect(); + + return { + top: writerRect.top + writerRect.height, + left: writerRect.left, + }; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/view-tree/index.tsx b/src/components/chat/components/ai-writer/view-tree/index.tsx new file mode 100644 index 00000000..f8171c4c --- /dev/null +++ b/src/components/chat/components/ai-writer/view-tree/index.tsx @@ -0,0 +1,112 @@ +import LoadingDots from '../../ui/loading-dots'; +import { toast } from '../../../hooks/use-toast'; +import { useTranslation } from '../../../i18n'; +import { searchViews } from '../../../lib/views'; +import { Spaces } from './spaces'; +import { Button } from '../../ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover'; +import { SearchInput } from '../../ui/search-input'; +import { Separator } from '../../ui/separator'; +import { useCheckboxTree } from '../../../hooks/use-checkbox-tree'; +import { View } from '../../../types'; +import { useWriterContext } from '../../../writer/context'; +import { ChevronDown } from 'lucide-react'; +import { ReactComponent as DocIcon } from '../../../assets/icons/doc.svg'; +import { useEffect, useMemo, useState } from 'react'; + +export function ViewTree() { + const [searchValue, setSearchValue] = useState(''); + const { + viewId, + setRagIds, + } = useWriterContext(); + const { fetchViews } = useWriterContext(); + const [viewsLoading, setViewsLoading] = useState(true); + const [folder, setFolder] = useState(null); + const viewIds = useMemo(() => [viewId], [viewId]); + + const { t } = useTranslation(); + + useEffect(() => { + void (async() => { + setViewsLoading(true); + try { + const data = await fetchViews(); + if(!data) return; + setFolder(data); + // eslint-disable-next-line + } catch(e: any) { + toast({ + variant: 'destructive', + description: e.message, + }); + } finally { + setViewsLoading(false); + } + })(); + }, [fetchViews]); + + const views = useMemo(() => { + return folder?.children || []; + }, [folder]); + + const { + getSelected, + getCheckStatus, + toggleNode, + getInitialExpand, + } = useCheckboxTree(viewIds, views); + + const length = getSelected().length; + + const spaces = useMemo(() => { + const spaces = folder?.children.filter(view => view.extra?.is_space); + return searchViews(spaces || [], searchValue); + }, [folder, searchValue]); + + return + + + + +
+ + +
+ { + if(view.view_id === viewId) return; + const ids = toggleNode(view); + setRagIds(Array.from(ids)); + } + } + /> +
+ +
+
+
; +} \ No newline at end of file diff --git a/src/components/chat/components/ai-writer/view-tree/spaces.tsx b/src/components/chat/components/ai-writer/view-tree/spaces.tsx new file mode 100644 index 00000000..88431a71 --- /dev/null +++ b/src/components/chat/components/ai-writer/view-tree/spaces.tsx @@ -0,0 +1,58 @@ +import ViewChildren from '../../view/view-children'; +import SpaceItem from '../../view/space-item'; +import LoadingDots from '../../ui/loading-dots'; +import { useTranslation } from '../../../i18n'; +import { View } from '../../../types'; +import { CheckStatus } from '../../../types/checkbox'; + +interface SpacesProps { + getCheckStatus: (view: View) => CheckStatus; + getInitialExpand: (viewId: string) => boolean; + onToggle: (view: View) => void; + spaces: View[]; + viewsLoading: boolean; +} + +export function Spaces({ + getCheckStatus, + onToggle, + spaces, + viewsLoading, + getInitialExpand, +}: SpacesProps) { + const { t } = useTranslation(); + + if(viewsLoading) { + return
+ +
; + } + + if(!spaces || spaces.length === 0) { + return
+ {t('search.noSpacesFound')} +
; + } + + return ( +
+ {spaces.map((view: View) => { + return ( + + + + ); + })} +
+ ); +} + diff --git a/src/components/chat/components/ai-writer/writing-input.tsx b/src/components/chat/components/ai-writer/writing-input.tsx new file mode 100644 index 00000000..57060a00 --- /dev/null +++ b/src/components/chat/components/ai-writer/writing-input.tsx @@ -0,0 +1,349 @@ +import { ReactComponent as SendIcon } from '../../assets/icons/arrow-up.svg'; +import { ReactComponent as AutoTextIcon } from '../../assets/icons/auto-text.svg'; +import { ReactComponent as ImageTextIcon } from '../../assets/icons/image-text.svg'; +import { ViewTree } from '../ai-writer/view-tree'; +import { WritingMore } from '../ai-writer/writing-more'; +import { Button } from '../../../ui/button'; +import { FormatGroup } from '../ui/format-group'; +import LoadingDots from '../ui/loading-dots'; +import { Textarea } from '../ui/textarea'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '../../../ui/tooltip'; +import { toast } from '../../hooks/use-toast'; +import { useTranslation } from '../../i18n'; +import { cn } from '../../lib/utils'; +import { usePromptModal } from '../../provider/prompt-modal-provider'; +import { ChatInputMode } from '../../types'; +import { AiPrompt } from '../../types/prompt'; +import { useWriterContext } from '../../writer/context'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { PromptModal } from '../chat-input/prompt-modal'; + +const MAX_HEIGHT = 200; +// Prevent focus on page load and cause the page to scroll +const FOCUS_DELAY = 250; + +export function WritingInput({ + onSubmit, + noBorder, + noSwitchMode, +}: { + onSubmit: (message: string) => Promise<() => void>; + noBorder?: boolean; + noSwitchMode?: boolean; +}) { + const { t } = useTranslation(); + + const [, setFocused] = useState(false); + const [message, setMessage] = useState(''); + const { + assistantType, + isApplying, + isFetching, + responseMode, + responseFormat, + setResponseFormat, + setResponseMode, + hasAIAnswer, + scrollContainer, + } = useWriterContext(); + const { openModal, currentPromptId, updateCurrentPromptId } = + usePromptModal(); + + const textareaRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (assistantType === undefined) { + setMessage(''); + } + }, [assistantType]); + + const adjustHeight = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // reset height + textarea.style.height = 'auto'; + + // calculate height + const newHeight = Math.min(textarea.scrollHeight, MAX_HEIGHT); + textarea.style.height = `${newHeight}px`; + + // toggle overflowY + textarea.style.overflowY = + textarea.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden'; + + // adjust container height + if (containerRef.current) { + containerRef.current.style.height = `${newHeight + (responseMode === ChatInputMode.FormatResponse ? 54 + 24 : 30 + 16)}px`; // 32px padding + } + }, [responseMode]); + + const handleInput = () => { + adjustHeight(); + }; + + const handleChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + adjustHeight(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleSubmit(); + } + }; + + const handleSubmit = async () => { + if (!message.trim()) { + toast({ + variant: 'destructive', + description: `${t('errors.emptyMessage')}`, + }); + return; + } + if (isFetching || isApplying) { + toast({ + variant: 'destructive', + description: `${t('errors.wait')}`, + }); + return; + } + setMessage(''); + adjustHeight(); + + try { + await onSubmit(message); + // eslint-disable-next-line + } catch (e: any) { + toast({ + variant: 'destructive', + description: e.message, + }); + } + }; + + useEffect(() => { + const handleResize = () => { + if (message) { + adjustHeight(); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [adjustHeight, message]); + + useEffect(() => { + adjustHeight(); + }, [adjustHeight]); + + useEffect(() => { + setTimeout(() => { + const rect = textareaRef.current?.getBoundingClientRect(); + const containerRect = scrollContainer?.getBoundingClientRect(); + if (!rect || !containerRect) return; + + const inViewport = + rect.top >= containerRect.top && rect.bottom <= containerRect.bottom; + const bottomInView = + rect.top < containerRect.top && rect.bottom > containerRect.top; + const topInView = + rect.bottom > containerRect.bottom && rect.top < containerRect.bottom; + + if (inViewport || bottomInView || topInView) { + textareaRef.current?.focus(); + return; + } + + console.error('Disable focus on page load', { + rect, + containerRect, + }); + }, FOCUS_DELAY); + }, [scrollContainer]); + + useEffect(() => { + adjustHeight(); + }, [adjustHeight, currentPromptId]); + + const handleUsePrompt = useCallback( + (prompt: AiPrompt) => { + updateCurrentPromptId(prompt.id); + setResponseMode(ChatInputMode.Auto); + setMessage(prompt.content); + if (textareaRef) { + textareaRef.current?.focus(); + setFocused(true); + } + }, + [setResponseMode, updateCurrentPromptId], + ); + + const formatTooltip = + responseMode === ChatInputMode.FormatResponse + ? t('input.button.auto') + : t('input.button.format'); + const FormatIcon = + responseMode === ChatInputMode.FormatResponse + ? AutoTextIcon + : ImageTextIcon; + return ( +
+
+ {responseMode === ChatInputMode.FormatResponse && ( + { + setResponseFormat({ + ...responseFormat, + output_layout: newOutLayout, + }); + }} + setOutputContent={(newOutContent) => { + setResponseFormat({ + ...responseFormat, + output_content: newOutContent, + }); + }} + outputContent={responseFormat.output_content} + outputLayout={responseFormat.output_layout} + /> + )} +